Skip to content

Commit f6c6485

Browse files
authored
Preserve Unix file permissions when caching attachedOutputs (#392)
1 parent 49dc9ff commit f6c6485

File tree

7 files changed

+294
-14
lines changed

7 files changed

+294
-14
lines changed

pom.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,11 @@ under the License.
139139
<artifactId>commons-io</artifactId>
140140
<version>2.20.0</version>
141141
</dependency>
142+
<dependency>
143+
<groupId>org.apache.commons</groupId>
144+
<artifactId>commons-compress</artifactId>
145+
<version>1.28.0</version>
146+
</dependency>
142147
<dependency>
143148
<groupId>javax.annotation</groupId>
144149
<artifactId>javax.annotation-api</artifactId>

src/main/java/org/apache/maven/buildcache/CacheControllerImpl.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -881,7 +881,7 @@ private boolean zipAndAttachArtifact(MavenProject project, Path dir, String clas
881881
throws IOException {
882882
Path temp = Files.createTempFile("maven-incremental-", project.getArtifactId());
883883
temp.toFile().deleteOnExit();
884-
boolean hasFile = CacheUtils.zip(dir, temp, glob);
884+
boolean hasFile = CacheUtils.zip(dir, temp, glob, cacheConfig.isPreservePermissions());
885885
if (hasFile) {
886886
projectHelper.attachArtifact(project, "zip", classifier, temp.toFile());
887887
}
@@ -896,7 +896,7 @@ private void restoreGeneratedSources(Artifact artifact, Path artifactFilePath, M
896896
if (!Files.exists(outputDir)) {
897897
Files.createDirectories(outputDir);
898898
}
899-
CacheUtils.unzip(artifactFilePath, outputDir);
899+
CacheUtils.unzip(artifactFilePath, outputDir, cacheConfig.isPreservePermissions());
900900
}
901901

902902
// TODO: move to config

src/main/java/org/apache/maven/buildcache/CacheUtils.java

Lines changed: 107 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,18 @@
2929
import java.nio.file.StandardCopyOption;
3030
import java.nio.file.attribute.BasicFileAttributes;
3131
import java.nio.file.attribute.FileTime;
32+
import java.nio.file.attribute.PosixFilePermission;
3233
import java.util.Arrays;
3334
import java.util.Collection;
35+
import java.util.HashSet;
3436
import java.util.List;
3537
import java.util.NoSuchElementException;
38+
import java.util.Set;
3639
import 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;
4144
import org.apache.commons.lang3.StringUtils;
4245
import org.apache.commons.lang3.Strings;
4346
import 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
}

src/main/java/org/apache/maven/buildcache/xml/CacheConfig.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,8 @@ public interface CacheConfig {
108108

109109
List<DirName> getAttachedOutputs();
110110

111+
boolean isPreservePermissions();
112+
111113
boolean adjustMetaInfVersion();
112114

113115
boolean calculateProjectVersionChecksum();

src/main/java/org/apache/maven/buildcache/xml/CacheConfigImpl.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -578,6 +578,13 @@ public List<DirName> getAttachedOutputs() {
578578
return attachedOutputs == null ? Collections.emptyList() : attachedOutputs.getDirNames();
579579
}
580580

581+
@Override
582+
public boolean isPreservePermissions() {
583+
checkInitializedState();
584+
final AttachedOutputs attachedOutputs = getConfiguration().getAttachedOutputs();
585+
return attachedOutputs == null || attachedOutputs.isPreservePermissions();
586+
}
587+
581588
@Override
582589
public boolean adjustMetaInfVersion() {
583590
if (isEnabled()) {

src/main/mdo/build-cache-config.mdo

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,12 @@ under the License.
376376
<name>AttachedOutputs</name>
377377
<description>Section relative to outputs which are not artifacts but need to be saved/restored.</description>
378378
<fields>
379+
<field>
380+
<name>preservePermissions</name>
381+
<type>boolean</type>
382+
<defaultValue>true</defaultValue>
383+
<description>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.</description>
384+
</field>
379385
<field>
380386
<name>dirNames</name>
381387
<association>

0 commit comments

Comments
 (0)