javax.annotation
javax.annotation-api
diff --git a/src/main/java/org/apache/maven/buildcache/CacheControllerImpl.java b/src/main/java/org/apache/maven/buildcache/CacheControllerImpl.java
index e71dcb7a..6dfee156 100644
--- a/src/main/java/org/apache/maven/buildcache/CacheControllerImpl.java
+++ b/src/main/java/org/apache/maven/buildcache/CacheControllerImpl.java
@@ -881,7 +881,7 @@ private boolean zipAndAttachArtifact(MavenProject project, Path dir, String clas
throws IOException {
Path temp = Files.createTempFile("maven-incremental-", project.getArtifactId());
temp.toFile().deleteOnExit();
- boolean hasFile = CacheUtils.zip(dir, temp, glob);
+ boolean hasFile = CacheUtils.zip(dir, temp, glob, cacheConfig.isPreservePermissions());
if (hasFile) {
projectHelper.attachArtifact(project, "zip", classifier, temp.toFile());
}
@@ -896,7 +896,7 @@ private void restoreGeneratedSources(Artifact artifact, Path artifactFilePath, M
if (!Files.exists(outputDir)) {
Files.createDirectories(outputDir);
}
- CacheUtils.unzip(artifactFilePath, outputDir);
+ CacheUtils.unzip(artifactFilePath, outputDir, cacheConfig.isPreservePermissions());
}
// TODO: move to config
diff --git a/src/main/java/org/apache/maven/buildcache/CacheUtils.java b/src/main/java/org/apache/maven/buildcache/CacheUtils.java
index 2eded63a..67d639a4 100644
--- a/src/main/java/org/apache/maven/buildcache/CacheUtils.java
+++ b/src/main/java/org/apache/maven/buildcache/CacheUtils.java
@@ -29,15 +29,18 @@
import java.nio.file.StandardCopyOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.nio.file.attribute.FileTime;
+import java.nio.file.attribute.PosixFilePermission;
import java.util.Arrays;
import java.util.Collection;
+import java.util.HashSet;
import java.util.List;
import java.util.NoSuchElementException;
+import java.util.Set;
import java.util.stream.Stream;
-import java.util.zip.ZipEntry;
-import java.util.zip.ZipInputStream;
-import java.util.zip.ZipOutputStream;
+import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
+import org.apache.commons.compress.archivers.zip.ZipArchiveInputStream;
+import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.Strings;
import org.apache.commons.lang3.mutable.MutableBoolean;
@@ -154,12 +157,23 @@ public static boolean isArchive(File file) {
* @param dir directory to zip
* @param zip zip to populate
* @param glob glob to apply to filenames
+ * @param preservePermissions whether to preserve Unix file permissions in the zip.
+ * Important: When {@code true}, permissions are stored in ZIP entry headers,
+ * which means they become part of the ZIP file's binary content. As a result, hashing
+ * the ZIP file (e.g., for cache keys) will include permission information, ensuring
+ * cache invalidation when file permissions change. This behavior is similar to how Git
+ * includes file mode in tree hashes.
* @return true if at least one file has been included in the zip.
* @throws IOException
*/
- public static boolean zip(final Path dir, final Path zip, final String glob) throws IOException {
+ public static boolean zip(final Path dir, final Path zip, final String glob, boolean preservePermissions)
+ throws IOException {
final MutableBoolean hasFiles = new MutableBoolean();
- try (ZipOutputStream zipOutputStream = new ZipOutputStream(Files.newOutputStream(zip))) {
+ // Check once if filesystem supports POSIX permissions instead of catching exceptions for every file
+ final boolean supportsPosix = preservePermissions
+ && dir.getFileSystem().supportedFileAttributeViews().contains("posix");
+
+ try (ZipArchiveOutputStream zipOutputStream = new ZipArchiveOutputStream(Files.newOutputStream(zip))) {
PathMatcher matcher =
"*".equals(glob) ? null : FileSystems.getDefault().getPathMatcher("glob:" + glob);
@@ -170,12 +184,19 @@ public FileVisitResult visitFile(Path path, BasicFileAttributes basicFileAttribu
throws IOException {
if (matcher == null || matcher.matches(path.getFileName())) {
- final ZipEntry zipEntry =
- new ZipEntry(dir.relativize(path).toString());
- zipOutputStream.putNextEntry(zipEntry);
+ final ZipArchiveEntry zipEntry =
+ new ZipArchiveEntry(dir.relativize(path).toString());
+
+ // Preserve Unix permissions if requested and filesystem supports it
+ if (supportsPosix) {
+ Set permissions = Files.getPosixFilePermissions(path);
+ zipEntry.setUnixMode(permissionsToMode(permissions));
+ }
+
+ zipOutputStream.putArchiveEntry(zipEntry);
Files.copy(path, zipOutputStream);
hasFiles.setTrue();
- zipOutputStream.closeEntry();
+ zipOutputStream.closeArchiveEntry();
}
return FileVisitResult.CONTINUE;
}
@@ -184,9 +205,13 @@ public FileVisitResult visitFile(Path path, BasicFileAttributes basicFileAttribu
return hasFiles.booleanValue();
}
- public static void unzip(Path zip, Path out) throws IOException {
- try (ZipInputStream zis = new ZipInputStream(Files.newInputStream(zip))) {
- ZipEntry entry = zis.getNextEntry();
+ public static void unzip(Path zip, Path out, boolean preservePermissions) throws IOException {
+ // Check once if filesystem supports POSIX permissions instead of catching exceptions for every file
+ final boolean supportsPosix = preservePermissions
+ && out.getFileSystem().supportedFileAttributeViews().contains("posix");
+
+ try (ZipArchiveInputStream zis = new ZipArchiveInputStream(Files.newInputStream(zip))) {
+ ZipArchiveEntry entry = zis.getNextEntry();
while (entry != null) {
Path file = out.resolve(entry.getName());
if (!file.normalize().startsWith(out.normalize())) {
@@ -200,6 +225,16 @@ public static void unzip(Path zip, Path out) throws IOException {
Files.copy(zis, file, StandardCopyOption.REPLACE_EXISTING);
}
Files.setLastModifiedTime(file, FileTime.fromMillis(entry.getTime()));
+
+ // Restore Unix permissions if requested and filesystem supports it
+ if (supportsPosix) {
+ int unixMode = entry.getUnixMode();
+ if (unixMode != 0) {
+ Set permissions = modeToPermissions(unixMode);
+ Files.setPosixFilePermissions(file, permissions);
+ }
+ }
+
entry = zis.getNextEntry();
}
}
@@ -217,4 +252,64 @@ public static void debugPrintCollection(
}
}
}
+
+ /**
+ * Convert POSIX file permissions to Unix mode integer, following Git's approach of only
+ * preserving the owner executable bit.
+ *
+ * Git stores file permissions as either {@code 100644} (non-executable) or {@code 100755}
+ * (executable). This simplified approach focuses on the functional aspect (executability)
+ * while ignoring platform-specific permission details that are generally irrelevant for
+ * cross-platform builds.
+ *
+ * @param permissions POSIX file permissions
+ * @return Unix mode: {@code 0100755} if owner-executable, {@code 0100644} otherwise
+ */
+ private static int permissionsToMode(Set permissions) {
+ // Following Git's approach: preserve only the owner executable bit
+ // Git uses 100644 (rw-r--r--) for regular files and 100755 (rwxr-xr-x) for executables
+ if (permissions.contains(PosixFilePermission.OWNER_EXECUTE)) {
+ return 0100755; // Regular file, executable
+ } else {
+ return 0100644; // Regular file, non-executable
+ }
+ }
+
+ /**
+ * Convert Unix mode integer to POSIX file permissions, following Git's simplified approach.
+ *
+ * This method interprets the two Git-standard modes:
+ *
+ * - {@code 0100755} - Executable file: sets owner+group+others read/execute, owner write
+ * - {@code 0100644} - Regular file: sets owner+group+others read, owner write
+ *
+ *
+ * The key distinction is the presence of the execute bit. Other permission variations
+ * are normalized to these two standard patterns for portability.
+ *
+ * @param mode Unix mode (should be either {@code 0100755} or {@code 0100644})
+ * @return Set of POSIX file permissions
+ */
+ private static Set modeToPermissions(int mode) {
+ Set permissions = new HashSet<>();
+
+ // Check owner executable bit (following Git's approach)
+ if ((mode & 0100) != 0) {
+ // Mode 100755: rwxr-xr-x (executable file)
+ permissions.add(PosixFilePermission.OWNER_READ);
+ permissions.add(PosixFilePermission.OWNER_WRITE);
+ permissions.add(PosixFilePermission.OWNER_EXECUTE);
+ permissions.add(PosixFilePermission.GROUP_READ);
+ permissions.add(PosixFilePermission.GROUP_EXECUTE);
+ permissions.add(PosixFilePermission.OTHERS_READ);
+ permissions.add(PosixFilePermission.OTHERS_EXECUTE);
+ } else {
+ // Mode 100644: rw-r--r-- (regular file)
+ permissions.add(PosixFilePermission.OWNER_READ);
+ permissions.add(PosixFilePermission.OWNER_WRITE);
+ permissions.add(PosixFilePermission.GROUP_READ);
+ permissions.add(PosixFilePermission.OTHERS_READ);
+ }
+ return permissions;
+ }
}
diff --git a/src/main/java/org/apache/maven/buildcache/xml/CacheConfig.java b/src/main/java/org/apache/maven/buildcache/xml/CacheConfig.java
index d86b15d4..27d6d94d 100644
--- a/src/main/java/org/apache/maven/buildcache/xml/CacheConfig.java
+++ b/src/main/java/org/apache/maven/buildcache/xml/CacheConfig.java
@@ -108,6 +108,8 @@ public interface CacheConfig {
List getAttachedOutputs();
+ boolean isPreservePermissions();
+
boolean adjustMetaInfVersion();
boolean calculateProjectVersionChecksum();
diff --git a/src/main/java/org/apache/maven/buildcache/xml/CacheConfigImpl.java b/src/main/java/org/apache/maven/buildcache/xml/CacheConfigImpl.java
index cd6e87c0..70ab0479 100644
--- a/src/main/java/org/apache/maven/buildcache/xml/CacheConfigImpl.java
+++ b/src/main/java/org/apache/maven/buildcache/xml/CacheConfigImpl.java
@@ -578,6 +578,13 @@ public List getAttachedOutputs() {
return attachedOutputs == null ? Collections.emptyList() : attachedOutputs.getDirNames();
}
+ @Override
+ public boolean isPreservePermissions() {
+ checkInitializedState();
+ final AttachedOutputs attachedOutputs = getConfiguration().getAttachedOutputs();
+ return attachedOutputs == null || attachedOutputs.isPreservePermissions();
+ }
+
@Override
public boolean adjustMetaInfVersion() {
if (isEnabled()) {
diff --git a/src/main/mdo/build-cache-config.mdo b/src/main/mdo/build-cache-config.mdo
index 52ae0da0..d060dbcb 100644
--- a/src/main/mdo/build-cache-config.mdo
+++ b/src/main/mdo/build-cache-config.mdo
@@ -376,6 +376,12 @@ under the License.
AttachedOutputs
Section relative to outputs which are not artifacts but need to be saved/restored.
+
+ preservePermissions
+ boolean
+ true
+ Preserve Unix file permissions when saving/restoring attached outputs. When enabled, permissions are stored in ZIP entry headers and become part of the cache key, ensuring cache invalidation when permissions change. This is similar to how Git includes file mode in tree hashes. Disabling this may improve portability across different systems but will not preserve executable bits.
+
dirNames
diff --git a/src/test/java/org/apache/maven/buildcache/CacheUtilsPermissionsTest.java b/src/test/java/org/apache/maven/buildcache/CacheUtilsPermissionsTest.java
new file mode 100644
index 00000000..4f0d0eec
--- /dev/null
+++ b/src/test/java/org/apache/maven/buildcache/CacheUtilsPermissionsTest.java
@@ -0,0 +1,165 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.buildcache;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.PosixFilePermission;
+import java.nio.file.attribute.PosixFilePermissions;
+import java.util.Arrays;
+import java.util.Set;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Tests for permission preservation in CacheUtils.zip() and CacheUtils.unzip() methods.
+ * These tests verify that Unix file permissions affect ZIP file hashes when preservation
+ * is enabled, and do not affect hashes when disabled.
+ */
+class CacheUtilsPermissionsTest {
+
+ @TempDir
+ Path tempDir;
+
+ /**
+ * Tests that ZIP file hash changes when permissions change (when preservePermissions=true).
+ * This ensures that the cache invalidates when file permissions change, maintaining
+ * cache correctness similar to how Git includes file mode in tree hashes.
+ */
+ @Test
+ void testPermissionsAffectFileHashWhenEnabled() throws IOException {
+ // Skip test on non-POSIX filesystems (e.g., Windows)
+ if (!tempDir.getFileSystem().supportedFileAttributeViews().contains("posix")) {
+ return;
+ }
+
+ // Given: Same directory content with different permissions
+ Path sourceDir1 = tempDir.resolve("source1");
+ Files.createDirectories(sourceDir1);
+ Path file1 = sourceDir1.resolve("script.sh");
+ writeString(file1, "#!/bin/bash\necho hello");
+
+ // Set executable permissions (755)
+ Set execPermissions = PosixFilePermissions.fromString("rwxr-xr-x");
+ Files.setPosixFilePermissions(file1, execPermissions);
+
+ // Create second directory with identical content but different permissions
+ Path sourceDir2 = tempDir.resolve("source2");
+ Files.createDirectories(sourceDir2);
+ Path file2 = sourceDir2.resolve("script.sh");
+ writeString(file2, "#!/bin/bash\necho hello"); // Identical content
+
+ // Set non-executable permissions (644)
+ Set normalPermissions = PosixFilePermissions.fromString("rw-r--r--");
+ Files.setPosixFilePermissions(file2, normalPermissions);
+
+ // When: Create ZIP files with preservePermissions=true
+ Path zip1 = tempDir.resolve("cache1.zip");
+ Path zip2 = tempDir.resolve("cache2.zip");
+ CacheUtils.zip(sourceDir1, zip1, "*", true);
+ CacheUtils.zip(sourceDir2, zip2, "*", true);
+
+ // Then: ZIP files should have different hashes despite identical content
+ byte[] hash1 = Files.readAllBytes(zip1);
+ byte[] hash2 = Files.readAllBytes(zip2);
+
+ boolean hashesAreDifferent = !Arrays.equals(hash1, hash2);
+ assertTrue(
+ hashesAreDifferent,
+ "ZIP files with same content but different permissions should have different hashes "
+ + "when preservePermissions=true. This ensures cache invalidation when permissions change "
+ + "(executable vs non-executable files).");
+ }
+
+ /**
+ * Tests that ZIP file hash does NOT significantly vary when permissions change but
+ * preservePermissions=false. While ZIP timestamps may still cause minor differences,
+ * the key point is that permission information is NOT deterministically stored.
+ */
+ @Test
+ void testPermissionsDoNotAffectHashWhenDisabled() throws IOException {
+ // Skip test on non-POSIX filesystems (e.g., Windows)
+ if (!tempDir.getFileSystem().supportedFileAttributeViews().contains("posix")) {
+ return;
+ }
+
+ // Given: Same directory content with different permissions
+ Path sourceDir1 = tempDir.resolve("source1");
+ Files.createDirectories(sourceDir1);
+ Path file1 = sourceDir1.resolve("script.sh");
+ writeString(file1, "#!/bin/bash\necho hello");
+
+ // Set executable permissions (755)
+ Set execPermissions = PosixFilePermissions.fromString("rwxr-xr-x");
+ Files.setPosixFilePermissions(file1, execPermissions);
+
+ // Create second directory with identical content but different permissions
+ Path sourceDir2 = tempDir.resolve("source2");
+ Files.createDirectories(sourceDir2);
+ Path file2 = sourceDir2.resolve("script.sh");
+ writeString(file2, "#!/bin/bash\necho hello"); // Identical content
+
+ // Set non-executable permissions (644)
+ Set normalPermissions = PosixFilePermissions.fromString("rw-r--r--");
+ Files.setPosixFilePermissions(file2, normalPermissions);
+
+ // When: Create ZIP files with preservePermissions=false
+ Path zip1 = tempDir.resolve("cache1.zip");
+ Path zip2 = tempDir.resolve("cache2.zip");
+ CacheUtils.zip(sourceDir1, zip1, "*", false);
+ CacheUtils.zip(sourceDir2, zip2, "*", false);
+
+ // Unzip and verify permissions are NOT preserved
+ Path extractDir1 = tempDir.resolve("extracted1");
+ Path extractDir2 = tempDir.resolve("extracted2");
+ Files.createDirectories(extractDir1);
+ Files.createDirectories(extractDir2);
+ CacheUtils.unzip(zip1, extractDir1, false);
+ CacheUtils.unzip(zip2, extractDir2, false);
+
+ Path extractedFile1 = extractDir1.resolve("script.sh");
+ Path extractedFile2 = extractDir2.resolve("script.sh");
+
+ Set perms1 = Files.getPosixFilePermissions(extractedFile1);
+ Set perms2 = Files.getPosixFilePermissions(extractedFile2);
+
+ // Files should NOT retain their original different permissions
+ // Both should have default permissions determined by umask
+ assertFalse(
+ perms1.equals(execPermissions) && perms2.equals(normalPermissions),
+ "When preservePermissions=false, original permissions should NOT be preserved. "
+ + "Files should use system default permissions (umask).");
+ }
+
+ /**
+ * Java 8 compatible version of Files.writeString().
+ */
+ private void writeString(Path path, String content) throws IOException {
+ try (OutputStream out = Files.newOutputStream(path)) {
+ out.write(content.getBytes(StandardCharsets.UTF_8));
+ }
+ }
+}