2929import java .nio .file .StandardCopyOption ;
3030import java .nio .file .attribute .BasicFileAttributes ;
3131import java .nio .file .attribute .FileTime ;
32+ import java .nio .file .attribute .PosixFilePermission ;
3233import java .util .Arrays ;
3334import java .util .Collection ;
35+ import java .util .HashSet ;
3436import java .util .List ;
3537import java .util .NoSuchElementException ;
38+ import java .util .Set ;
3639import java .util .stream .Stream ;
37- import java .util .zip .ZipEntry ;
38- import java .util .zip .ZipInputStream ;
39- import java .util .zip .ZipOutputStream ;
4040
41+ import org .apache .commons .compress .archivers .zip .ZipArchiveEntry ;
42+ import org .apache .commons .compress .archivers .zip .ZipArchiveInputStream ;
43+ import org .apache .commons .compress .archivers .zip .ZipArchiveOutputStream ;
4144import org .apache .commons .lang3 .StringUtils ;
4245import org .apache .commons .lang3 .Strings ;
4346import org .apache .commons .lang3 .mutable .MutableBoolean ;
@@ -154,12 +157,23 @@ public static boolean isArchive(File file) {
154157 * @param dir directory to zip
155158 * @param zip zip to populate
156159 * @param glob glob to apply to filenames
160+ * @param preservePermissions whether to preserve Unix file permissions in the zip.
161+ * <p><b>Important:</b> When {@code true}, permissions are stored in ZIP entry headers,
162+ * which means they become part of the ZIP file's binary content. As a result, hashing
163+ * the ZIP file (e.g., for cache keys) will include permission information, ensuring
164+ * cache invalidation when file permissions change. This behavior is similar to how Git
165+ * includes file mode in tree hashes.</p>
157166 * @return true if at least one file has been included in the zip.
158167 * @throws IOException
159168 */
160- public static boolean zip (final Path dir , final Path zip , final String glob ) throws IOException {
169+ public static boolean zip (final Path dir , final Path zip , final String glob , boolean preservePermissions )
170+ throws IOException {
161171 final MutableBoolean hasFiles = new MutableBoolean ();
162- try (ZipOutputStream zipOutputStream = new ZipOutputStream (Files .newOutputStream (zip ))) {
172+ // Check once if filesystem supports POSIX permissions instead of catching exceptions for every file
173+ final boolean supportsPosix = preservePermissions
174+ && dir .getFileSystem ().supportedFileAttributeViews ().contains ("posix" );
175+
176+ try (ZipArchiveOutputStream zipOutputStream = new ZipArchiveOutputStream (Files .newOutputStream (zip ))) {
163177
164178 PathMatcher matcher =
165179 "*" .equals (glob ) ? null : FileSystems .getDefault ().getPathMatcher ("glob:" + glob );
@@ -170,12 +184,19 @@ public FileVisitResult visitFile(Path path, BasicFileAttributes basicFileAttribu
170184 throws IOException {
171185
172186 if (matcher == null || matcher .matches (path .getFileName ())) {
173- final ZipEntry zipEntry =
174- new ZipEntry (dir .relativize (path ).toString ());
175- zipOutputStream .putNextEntry (zipEntry );
187+ final ZipArchiveEntry zipEntry =
188+ new ZipArchiveEntry (dir .relativize (path ).toString ());
189+
190+ // Preserve Unix permissions if requested and filesystem supports it
191+ if (supportsPosix ) {
192+ Set <PosixFilePermission > permissions = Files .getPosixFilePermissions (path );
193+ zipEntry .setUnixMode (permissionsToMode (permissions ));
194+ }
195+
196+ zipOutputStream .putArchiveEntry (zipEntry );
176197 Files .copy (path , zipOutputStream );
177198 hasFiles .setTrue ();
178- zipOutputStream .closeEntry ();
199+ zipOutputStream .closeArchiveEntry ();
179200 }
180201 return FileVisitResult .CONTINUE ;
181202 }
@@ -184,9 +205,13 @@ public FileVisitResult visitFile(Path path, BasicFileAttributes basicFileAttribu
184205 return hasFiles .booleanValue ();
185206 }
186207
187- public static void unzip (Path zip , Path out ) throws IOException {
188- try (ZipInputStream zis = new ZipInputStream (Files .newInputStream (zip ))) {
189- ZipEntry entry = zis .getNextEntry ();
208+ public static void unzip (Path zip , Path out , boolean preservePermissions ) throws IOException {
209+ // Check once if filesystem supports POSIX permissions instead of catching exceptions for every file
210+ final boolean supportsPosix = preservePermissions
211+ && out .getFileSystem ().supportedFileAttributeViews ().contains ("posix" );
212+
213+ try (ZipArchiveInputStream zis = new ZipArchiveInputStream (Files .newInputStream (zip ))) {
214+ ZipArchiveEntry entry = zis .getNextEntry ();
190215 while (entry != null ) {
191216 Path file = out .resolve (entry .getName ());
192217 if (!file .normalize ().startsWith (out .normalize ())) {
@@ -200,6 +225,16 @@ public static void unzip(Path zip, Path out) throws IOException {
200225 Files .copy (zis , file , StandardCopyOption .REPLACE_EXISTING );
201226 }
202227 Files .setLastModifiedTime (file , FileTime .fromMillis (entry .getTime ()));
228+
229+ // Restore Unix permissions if requested and filesystem supports it
230+ if (supportsPosix ) {
231+ int unixMode = entry .getUnixMode ();
232+ if (unixMode != 0 ) {
233+ Set <PosixFilePermission > permissions = modeToPermissions (unixMode );
234+ Files .setPosixFilePermissions (file , permissions );
235+ }
236+ }
237+
203238 entry = zis .getNextEntry ();
204239 }
205240 }
@@ -217,4 +252,64 @@ public static <T> void debugPrintCollection(
217252 }
218253 }
219254 }
255+
256+ /**
257+ * Convert POSIX file permissions to Unix mode integer, following Git's approach of only
258+ * preserving the owner executable bit.
259+ *
260+ * <p>Git stores file permissions as either {@code 100644} (non-executable) or {@code 100755}
261+ * (executable). This simplified approach focuses on the functional aspect (executability)
262+ * while ignoring platform-specific permission details that are generally irrelevant for
263+ * cross-platform builds.</p>
264+ *
265+ * @param permissions POSIX file permissions
266+ * @return Unix mode: {@code 0100755} if owner-executable, {@code 0100644} otherwise
267+ */
268+ private static int permissionsToMode (Set <PosixFilePermission > permissions ) {
269+ // Following Git's approach: preserve only the owner executable bit
270+ // Git uses 100644 (rw-r--r--) for regular files and 100755 (rwxr-xr-x) for executables
271+ if (permissions .contains (PosixFilePermission .OWNER_EXECUTE )) {
272+ return 0100755 ; // Regular file, executable
273+ } else {
274+ return 0100644 ; // Regular file, non-executable
275+ }
276+ }
277+
278+ /**
279+ * Convert Unix mode integer to POSIX file permissions, following Git's simplified approach.
280+ *
281+ * <p>This method interprets the two Git-standard modes:</p>
282+ * <ul>
283+ * <li>{@code 0100755} - Executable file: sets owner+group+others read/execute, owner write</li>
284+ * <li>{@code 0100644} - Regular file: sets owner+group+others read, owner write</li>
285+ * </ul>
286+ *
287+ * <p>The key distinction is the presence of the execute bit. Other permission variations
288+ * are normalized to these two standard patterns for portability.</p>
289+ *
290+ * @param mode Unix mode (should be either {@code 0100755} or {@code 0100644})
291+ * @return Set of POSIX file permissions
292+ */
293+ private static Set <PosixFilePermission > modeToPermissions (int mode ) {
294+ Set <PosixFilePermission > permissions = new HashSet <>();
295+
296+ // Check owner executable bit (following Git's approach)
297+ if ((mode & 0100 ) != 0 ) {
298+ // Mode 100755: rwxr-xr-x (executable file)
299+ permissions .add (PosixFilePermission .OWNER_READ );
300+ permissions .add (PosixFilePermission .OWNER_WRITE );
301+ permissions .add (PosixFilePermission .OWNER_EXECUTE );
302+ permissions .add (PosixFilePermission .GROUP_READ );
303+ permissions .add (PosixFilePermission .GROUP_EXECUTE );
304+ permissions .add (PosixFilePermission .OTHERS_READ );
305+ permissions .add (PosixFilePermission .OTHERS_EXECUTE );
306+ } else {
307+ // Mode 100644: rw-r--r-- (regular file)
308+ permissions .add (PosixFilePermission .OWNER_READ );
309+ permissions .add (PosixFilePermission .OWNER_WRITE );
310+ permissions .add (PosixFilePermission .GROUP_READ );
311+ permissions .add (PosixFilePermission .OTHERS_READ );
312+ }
313+ return permissions ;
314+ }
220315}
0 commit comments