View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one
3    * or more contributor license agreements.  See the NOTICE file
4    * distributed with this work for additional information
5    * regarding copyright ownership.  The ASF licenses this file
6    * to you under the Apache License, Version 2.0 (the
7    * "License"); you may not use this file except in compliance
8    * with the License.  You may obtain a copy of the License at
9    *
10   *   http://www.apache.org/licenses/LICENSE-2.0
11   *
12   * Unless required by applicable law or agreed to in writing,
13   * software distributed under the License is distributed on an
14   * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15   * KIND, either express or implied.  See the License for the
16   * specific language governing permissions and limitations
17   * under the License.
18   */
19  package org.apache.maven.buildcache;
20  
21  import java.io.File;
22  import java.io.IOException;
23  import java.nio.file.FileSystems;
24  import java.nio.file.FileVisitResult;
25  import java.nio.file.Files;
26  import java.nio.file.Path;
27  import java.nio.file.PathMatcher;
28  import java.nio.file.SimpleFileVisitor;
29  import java.nio.file.StandardCopyOption;
30  import java.nio.file.attribute.BasicFileAttributes;
31  import java.nio.file.attribute.FileTime;
32  import java.nio.file.attribute.PosixFilePermission;
33  import java.util.Arrays;
34  import java.util.Collection;
35  import java.util.HashSet;
36  import java.util.List;
37  import java.util.NoSuchElementException;
38  import java.util.Set;
39  import java.util.stream.Stream;
40  
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;
44  import org.apache.commons.lang3.StringUtils;
45  import org.apache.commons.lang3.Strings;
46  import org.apache.commons.lang3.mutable.MutableBoolean;
47  import org.apache.maven.artifact.Artifact;
48  import org.apache.maven.artifact.handler.ArtifactHandler;
49  import org.apache.maven.buildcache.xml.build.Scm;
50  import org.apache.maven.execution.MavenSession;
51  import org.apache.maven.model.Dependency;
52  import org.apache.maven.plugin.MojoExecution;
53  import org.apache.maven.project.MavenProject;
54  import org.slf4j.Logger;
55  
56  import static org.apache.maven.artifact.Artifact.LATEST_VERSION;
57  import static org.apache.maven.artifact.Artifact.SNAPSHOT_VERSION;
58  
59  /**
60   * Cache Utils
61   */
62  public class CacheUtils {
63  
64      public static boolean isPom(MavenProject project) {
65          return project.getPackaging().equals("pom");
66      }
67  
68      public static boolean isPom(Dependency dependency) {
69          return dependency.getType().equals("pom");
70      }
71  
72      public static boolean isSnapshot(String version) {
73          return version.endsWith(SNAPSHOT_VERSION) || version.endsWith(LATEST_VERSION);
74      }
75  
76      public static String normalizedName(Artifact artifact) {
77          if (artifact.getFile() == null) {
78              return null;
79          }
80  
81          StringBuilder filename = new StringBuilder(artifact.getArtifactId());
82  
83          if (artifact.hasClassifier()) {
84              filename.append("-").append(artifact.getClassifier());
85          }
86  
87          final ArtifactHandler artifactHandler = artifact.getArtifactHandler();
88          if (artifactHandler != null && StringUtils.isNotBlank(artifactHandler.getExtension())) {
89              filename.append(".").append(artifactHandler.getExtension());
90          }
91          return filename.toString();
92      }
93  
94      public static String mojoExecutionKey(MojoExecution mojo) {
95          return String.join(
96                  ":",
97                  Arrays.asList(
98                          StringUtils.defaultIfEmpty(mojo.getExecutionId(), "emptyExecId"),
99                          StringUtils.defaultIfEmpty(mojo.getGoal(), "emptyGoal"),
100                         StringUtils.defaultIfEmpty(mojo.getLifecyclePhase(), "emptyLifecyclePhase"),
101                         StringUtils.defaultIfEmpty(mojo.getArtifactId(), "emptyArtifactId"),
102                         StringUtils.defaultIfEmpty(mojo.getGroupId(), "emptyGroupId")));
103     }
104 
105     public static Path getMultimoduleRoot(MavenSession session) {
106         return session.getRequest().getMultiModuleProjectDirectory().toPath();
107     }
108 
109     public static Scm readGitInfo(MavenSession session) throws IOException {
110         final Scm scmCandidate = new Scm();
111         final Path gitDir = getMultimoduleRoot(session).resolve(".git");
112         if (Files.isDirectory(gitDir)) {
113             final Path headFile = gitDir.resolve("HEAD");
114             if (Files.exists(headFile)) {
115                 String headRef = readFirstLine(headFile, "<missing branch>");
116                 if (headRef.startsWith("ref: ")) {
117                     String branch = Strings.CS.removeStart(headRef, "ref: ").trim();
118                     scmCandidate.setSourceBranch(branch);
119                     final Path refPath = gitDir.resolve(branch);
120                     if (Files.exists(refPath)) {
121                         String revision = readFirstLine(refPath, "<missing revision>");
122                         scmCandidate.setRevision(revision.trim());
123                     }
124                 } else {
125                     scmCandidate.setSourceBranch(headRef);
126                     scmCandidate.setRevision(headRef);
127                 }
128             }
129         }
130         return scmCandidate;
131     }
132 
133     private static String readFirstLine(Path path, String defaultValue) throws IOException {
134         try (Stream<String> lines = Files.lines(path)) {
135             return lines.findFirst().orElse(defaultValue);
136         }
137     }
138 
139     public static <T> T getLast(List<T> list) {
140         int size = list.size();
141         if (size > 0) {
142             return list.get(size - 1);
143         }
144         throw new NoSuchElementException();
145     }
146 
147     public static boolean isArchive(File file) {
148         String fileName = file.getName();
149         if (!file.isFile() || file.isHidden()) {
150             return false;
151         }
152         return Strings.CS.endsWithAny(fileName, ".jar", ".zip", ".war", ".ear");
153     }
154 
155     /**
156      * Put every matching files of a directory in a zip.
157      * @param dir directory to zip
158      * @param zip zip to populate
159      * @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>
166      * @return true if at least one file has been included in the zip.
167      * @throws IOException
168      */
169     public static boolean zip(final Path dir, final Path zip, final String glob, boolean preservePermissions)
170             throws IOException {
171         final MutableBoolean hasFiles = new MutableBoolean();
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))) {
177 
178             PathMatcher matcher =
179                     "*".equals(glob) ? null : FileSystems.getDefault().getPathMatcher("glob:" + glob);
180             Files.walkFileTree(dir, new SimpleFileVisitor<Path>() {
181 
182                 @Override
183                 public FileVisitResult visitFile(Path path, BasicFileAttributes basicFileAttributes)
184                         throws IOException {
185 
186                     if (matcher == null || matcher.matches(path.getFileName())) {
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);
197                         Files.copy(path, zipOutputStream);
198                         hasFiles.setTrue();
199                         zipOutputStream.closeArchiveEntry();
200                     }
201                     return FileVisitResult.CONTINUE;
202                 }
203             });
204         }
205         return hasFiles.booleanValue();
206     }
207 
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();
215             while (entry != null) {
216                 Path file = out.resolve(entry.getName());
217                 if (!file.normalize().startsWith(out.normalize())) {
218                     throw new RuntimeException("Bad zip entry");
219                 }
220                 if (entry.isDirectory()) {
221                     Files.createDirectory(file);
222                 } else {
223                     Path parent = file.getParent();
224                     Files.createDirectories(parent);
225                     Files.copy(zis, file, StandardCopyOption.REPLACE_EXISTING);
226                 }
227                 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 
238                 entry = zis.getNextEntry();
239             }
240         }
241     }
242 
243     public static <T> void debugPrintCollection(
244             Logger logger, Collection<T> values, String heading, String elementCaption) {
245         if (logger.isDebugEnabled() && values != null && !values.isEmpty()) {
246             final int size = values.size();
247             int i = 0;
248             logger.debug("{} (total {})", heading, size);
249             for (T value : values) {
250                 i++;
251                 logger.debug("{} {} of {} : {}", elementCaption, i, size, value);
252             }
253         }
254     }
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     }
315 }