Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1059,7 +1059,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, cacheConfig.isPreservePermissions());
boolean hasFile = CacheUtils.zip(dir, temp, glob, cacheConfig.isPreservePermissions(), cacheConfig.isPreserveTimestamps());
if (hasFile) {
projectHelper.attachArtifact(project, "zip", classifier, temp.toFile());
}
Expand All @@ -1074,7 +1074,7 @@ private void restoreGeneratedSources(Artifact artifact, Path artifactFilePath, M
if (!Files.exists(outputDir)) {
Files.createDirectories(outputDir);
}
CacheUtils.unzip(artifactFilePath, outputDir, cacheConfig.isPreservePermissions());
CacheUtils.unzip(artifactFilePath, outputDir, cacheConfig.isPreservePermissions(), cacheConfig.isPreserveTimestamps());
}

// TODO: move to config
Expand Down
184 changes: 103 additions & 81 deletions src/main/java/org/apache/maven/buildcache/CacheUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -33,33 +33,30 @@
import java.nio.file.attribute.PosixFilePermission;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Set;
import java.util.stream.Stream;

import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream;
import org.apache.commons.compress.archivers.zip.ZipFile;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.Strings;
import org.apache.commons.lang3.mutable.MutableBoolean;
import org.apache.maven.artifact.Artifact;
import org.apache.maven.artifact.handler.ArtifactHandler;
import org.apache.maven.buildcache.xml.build.CompletedExecution;
import org.apache.maven.buildcache.xml.build.PropertyValue;
import org.apache.maven.buildcache.xml.build.Scm;
import org.apache.maven.execution.MavenSession;
import org.apache.maven.model.Dependency;
import org.apache.maven.plugin.MojoExecution;
import org.apache.maven.plugin.PluginParameterExpressionEvaluator;
import org.apache.maven.project.MavenProject;
import org.codehaus.plexus.component.configurator.expression.ExpressionEvaluationException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import static org.apache.maven.artifact.Artifact.LATEST_VERSION;
import static org.apache.maven.artifact.Artifact.SNAPSHOT_VERSION;
Expand All @@ -68,7 +65,6 @@
* Cache Utils
*/
public class CacheUtils {
private static final Logger LOGGER = LoggerFactory.getLogger(CacheUtils.class);

public static boolean isPom(MavenProject project) {
return project.getPackaging().equals("pom");
Expand Down Expand Up @@ -172,10 +168,12 @@ public static boolean isArchive(File file) {
* 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.</p>
* @param preserveTimestamps whether to preserve file and directory timestamps in the zip
* @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, boolean preservePermissions)
public static boolean zip(
final Path dir, final Path zip, final String glob, boolean preservePermissions, boolean preserveTimestamps)
throws IOException {
final MutableBoolean hasFiles = new MutableBoolean();
// Check once if filesystem supports POSIX permissions instead of catching exceptions for every file
Expand All @@ -186,16 +184,47 @@ public static boolean zip(final Path dir, final Path zip, final String glob, boo

PathMatcher matcher =
"*".equals(glob) ? null : FileSystems.getDefault().getPathMatcher("glob:" + glob);

// Track directories that contain matching files for glob filtering
final Set<Path> directoriesWithMatchingFiles = new HashSet<>();
// Track directory attributes for timestamp preservation
final Map<Path, BasicFileAttributes> directoryAttributes =
preserveTimestamps ? new HashMap<>() : Collections.emptyMap();

Files.walkFileTree(dir, new SimpleFileVisitor<Path>() {

@Override
public FileVisitResult preVisitDirectory(Path path, BasicFileAttributes attrs) throws IOException {
if (preserveTimestamps) {
// Store attributes for use in postVisitDirectory
directoryAttributes.put(path, attrs);
}
return FileVisitResult.CONTINUE;
}

@Override
public FileVisitResult visitFile(Path path, BasicFileAttributes basicFileAttributes)
throws IOException {

if (matcher == null || matcher.matches(path.getFileName())) {
if (preserveTimestamps) {
// Mark all parent directories as containing matching files
Path parent = path.getParent();
while (parent != null && !parent.equals(dir)) {
directoriesWithMatchingFiles.add(parent);
parent = parent.getParent();
}
}

final ZipArchiveEntry zipEntry =
new ZipArchiveEntry(dir.relativize(path).toString());

// Preserve timestamp if requested
if (preserveTimestamps) {
zipEntry.setTime(
basicFileAttributes.lastModifiedTime().toMillis());
}

// Preserve Unix permissions if requested and filesystem supports it
if (supportsPosix) {
Set<PosixFilePermission> permissions = Files.getPosixFilePermissions(path);
Expand All @@ -209,16 +238,43 @@ public FileVisitResult visitFile(Path path, BasicFileAttributes basicFileAttribu
}
return FileVisitResult.CONTINUE;
}

@Override
public FileVisitResult postVisitDirectory(Path path, IOException exc) throws IOException {
// Propagate any exception that occurred during directory traversal
if (exc != null) {
throw exc;
}

// Add directory entry only if preserving timestamps and:
// 1. It's not the root directory, AND
// 2. Either no glob filter (matcher is null) OR directory contains matching files
if (preserveTimestamps
&& !path.equals(dir)
&& (matcher == null || directoriesWithMatchingFiles.contains(path))) {
BasicFileAttributes attrs = directoryAttributes.get(path);
if (attrs != null) {
String relativePath = dir.relativize(path).toString() + "/";
ZipArchiveEntry zipEntry = new ZipArchiveEntry(relativePath);
zipEntry.setTime(attrs.lastModifiedTime().toMillis());
zipOutputStream.putArchiveEntry(zipEntry);
zipOutputStream.closeArchiveEntry();
}
}
return FileVisitResult.CONTINUE;
}
});
}
return hasFiles.booleanValue();
}

public static void unzip(Path zip, Path out, boolean preservePermissions) throws IOException {
public static void unzip(Path zip, Path out, boolean preservePermissions, boolean preserveTimestamps)
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");

Map<Path, Long> directoryTimestamps = preserveTimestamps ? new HashMap<>() : Collections.emptyMap();
try (ZipFile zipFile = ZipFile.builder().setFile(zip.toFile()).get()) {
Enumeration<ZipArchiveEntry> entries = zipFile.getEntries();
while (entries.hasMoreElements()) {
Expand All @@ -228,26 +284,53 @@ public static void unzip(Path zip, Path out, boolean preservePermissions) throws
throw new RuntimeException("Bad zip entry");
}
if (entry.isDirectory()) {
Files.createDirectory(file);
Files.createDirectories(file);
Comment thread
cowwoc marked this conversation as resolved.
if (preserveTimestamps) {
directoryTimestamps.put(file, entry.getTime());
}
} else {
Path parent = file.getParent();
Files.createDirectories(parent);
if (parent != null) {
Files.createDirectories(parent);
}
try (InputStream is = zipFile.getInputStream(entry)) {
Files.copy(is, 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<PosixFilePermission> permissions = modeToPermissions(unixMode);
Files.setPosixFilePermissions(file, permissions);

if (preserveTimestamps) {
// Set file timestamp with error handling
try {
Files.setLastModifiedTime(file, FileTime.fromMillis(entry.getTime()));
} catch (IOException e) {
// Timestamp setting is best-effort; silently ignore failures
// This can happen on filesystems that don't support modification times
}
}

// Restore Unix permissions if requested and filesystem supports it
if (supportsPosix) {
int unixMode = entry.getUnixMode();
if (unixMode != 0) {
Set<PosixFilePermission> permissions = modeToPermissions(unixMode);
Files.setPosixFilePermissions(file, permissions);
}
}
}
}
}

if (preserveTimestamps) {
// Set directory timestamps after all files have been extracted to avoid them being
// updated by file creation operations
for (Map.Entry<Path, Long> dirEntry : directoryTimestamps.entrySet()) {
try {
Files.setLastModifiedTime(dirEntry.getKey(), FileTime.fromMillis(dirEntry.getValue()));
} catch (IOException e) {
// Timestamp setting is best-effort; silently ignore failures
// This can happen on filesystems that don't support modification times
}
}
}
}

public static <T> void debugPrintCollection(
Expand Down Expand Up @@ -322,65 +405,4 @@ private static Set<PosixFilePermission> modeToPermissions(int mode) {
}
return permissions;
}

static Object interpolateExpression(String expression, MavenSession session, MojoExecution execution) {
try {
PluginParameterExpressionEvaluator evaluator = new PluginParameterExpressionEvaluator(session, execution);
return evaluator.evaluate(expression);
} catch (ExpressionEvaluationException e) {
LOGGER.warn("Cannot interpolate expression '{}': {}", expression, e.getMessage(), e);
return expression; // return the expression as is when interpolation fails
}
}

static String normalizeValue(Object value, Path baseDirPath) {
if (value instanceof File) {
Path path = ((File) value).toPath();
return normalizedPath(path, baseDirPath);
} else if (value instanceof Path) {
return normalizedPath(((Path) value), baseDirPath);
} else if (value != null && value.getClass().isArray()) {
return ArrayUtils.toString(value);
} else {
return String.valueOf(value);
}
}

/*
Best effort to normalize paths from Mojo fields.
- all absolute paths under project root are relativized for portability
- redundant '..' and '.' are removed to have consistent views on all paths
- all relative paths are considered portable and are not be touched
- absolute paths outside of project directory cannot be deterministically
relativized and are not touched
*/
private static String normalizedPath(Path path, Path baseDirPath) {
boolean isProjectSubdir = path.isAbsolute() && path.startsWith(baseDirPath);
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(
"normalizedPath isProjectSubdir {} path '{}' - baseDirPath '{}', path.isAbsolute() {},"
+ " path.startsWith(baseDirPath) {}",
isProjectSubdir,
path,
baseDirPath,
path.isAbsolute(),
path.startsWith(baseDirPath));
}
Path preparedPath = isProjectSubdir ? baseDirPath.relativize(path) : path;
String normalizedPath = preparedPath.normalize().toString();
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("normalizedPath '{}' - {} return {}", path, baseDirPath, normalizedPath);
}
return normalizedPath;
}

static void addProperty(
CompletedExecution execution, String propertyName, Object value, Path baseDirPath, boolean tracked) {
final PropertyValue valueType = new PropertyValue();
valueType.setName(propertyName);
final String valueText = normalizeValue(value, baseDirPath);
valueType.setValue(valueText);
valueType.setTracked(tracked);
execution.addProperty(valueType);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ public interface CacheConfig {

boolean isPreservePermissions();

boolean isPreserveTimestamps();

boolean adjustMetaInfVersion();

boolean calculateProjectVersionChecksum();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -597,6 +597,13 @@ public boolean isPreservePermissions() {
return attachedOutputs == null || attachedOutputs.isPreservePermissions();
}

@Override
public boolean isPreserveTimestamps() {
checkInitializedState();
final AttachedOutputs attachedOutputs = getConfiguration().getAttachedOutputs();
return attachedOutputs == null || attachedOutputs.isPreserveTimestamps();
}

@Override
public boolean adjustMetaInfVersion() {
if (isEnabled()) {
Expand Down
6 changes: 6 additions & 0 deletions src/main/mdo/build-cache-config.mdo
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,12 @@ under the License.
<defaultValue>true</defaultValue>
<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>
</field>
<field>
<name>preserveTimestamps</name>
<type>boolean</type>
<defaultValue>true</defaultValue>
<description>Preserve file and directory timestamps when saving/restoring attached outputs. Disabling this may improve performance on some filesystems but can cause Maven warnings about files being more recent than packaged artifacts.</description>
</field>
<field>
<name>dirNames</name>
<association>
Expand Down
Loading
Loading