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.eclipse.aether.internal.impl;
20  
21  import java.io.IOException;
22  import java.nio.file.Files;
23  import java.nio.file.Path;
24  import java.util.Collection;
25  import java.util.Collections;
26  import java.util.HashMap;
27  import java.util.HashSet;
28  import java.util.Map;
29  import java.util.Objects;
30  import java.util.Properties;
31  import java.util.concurrent.ConcurrentHashMap;
32  import java.util.regex.Pattern;
33  
34  import org.eclipse.aether.RepositorySystemSession;
35  import org.eclipse.aether.artifact.Artifact;
36  import org.eclipse.aether.metadata.Metadata;
37  import org.eclipse.aether.repository.LocalArtifactRegistration;
38  import org.eclipse.aether.repository.LocalArtifactRequest;
39  import org.eclipse.aether.repository.LocalArtifactResult;
40  import org.eclipse.aether.repository.RemoteRepository;
41  import org.eclipse.aether.repository.RepositoryKeyFunction;
42  import org.eclipse.aether.util.ConfigUtils;
43  import org.slf4j.Logger;
44  import org.slf4j.LoggerFactory;
45  
46  import static java.util.Objects.requireNonNull;
47  
48  /**
49   * These are implementation details for enhanced local repository manager, subject to change without prior notice.
50   * Repositories from which a cached artifact was resolved are tracked in a properties file named
51   * <code>_remote.repositories</code>, with content key as filename&gt;repo_id and value as empty string. If a file has
52   * been installed in the repository, but not downloaded from a remote repository, it is tracked as empty repository id
53   * and always resolved. For example:
54   *
55   * <pre>
56   * artifact-1.0.pom>=
57   * artifact-1.0.jar>=
58   * artifact-1.0.pom>central=
59   * artifact-1.0.jar>central=
60   * artifact-1.0.zip>central=
61   * artifact-1.0-classifier.zip>central=
62   * artifact-1.0.pom>my_repo_id=
63   * </pre>
64   *
65   * The repository id component of a tracking key is produced by the tracking-scoped repository key function, which
66   * is URL-qualified by default: two repositories that merely share an id but point at different URLs are tracked as
67   * different origins (see
68   * {@link org.eclipse.aether.ConfigurationProperties#REPOSITORY_TRACKING_REPOSITORY_KEY_FUNCTION}). This does not hold
69   * unconditionally when {@link EnhancedLocalRepositoryManagerFactory#CONFIG_PROP_LEGACY_LOCAL_REPOSITORY} is enabled
70   * (the default): for backward compatibility with Maven 3.9 and older, a same-id fallback then also accepts an
71   * artifact tracked under a different URL as long as the repository id matches (see {@link #applyTracking}).
72   *
73   * @see EnhancedLocalRepositoryManagerFactory
74   */
75  class EnhancedLocalRepositoryManager extends SimpleLocalRepositoryManager {
76  
77      private static final Logger LOGGER = LoggerFactory.getLogger(EnhancedLocalRepositoryManager.class);
78  
79      private static final String LOCAL_REPO_ID = "";
80  
81      private static final Pattern SHA1_HEX = Pattern.compile("[0-9a-f]{40}");
82  
83      /**
84       * Shared sentinel for "no tracking data". Mutation is forbidden: the instance is shared
85       * across threads via {@link #trackingFileCache} and returned directly from {@link #readRepos}.
86       */
87      private static final Properties EMPTY_PROPERTIES = new Properties() {
88          @Override
89          public synchronized Object put(Object key, Object value) {
90              throw new UnsupportedOperationException("EMPTY_PROPERTIES is read-only");
91          }
92  
93          @Override
94          public synchronized Object remove(Object key) {
95              throw new UnsupportedOperationException("EMPTY_PROPERTIES is read-only");
96          }
97  
98          @Override
99          public synchronized void clear() {
100             throw new UnsupportedOperationException("EMPTY_PROPERTIES is read-only");
101         }
102     };
103 
104     private final String trackingFilename;
105 
106     private final boolean legacyLocalRepository;
107 
108     private final TrackingFileManager trackingFileManager;
109 
110     private final LocalPathPrefixComposer localPathPrefixComposer;
111 
112     /**
113      * Repository key function used solely for the provenance tracking entries (the repository component of the
114      * keys in the tracking file); path composition keeps using the (system-wide) key function held by the
115      * superclass. URL-qualified by default, so two repositories merely sharing an id are not treated as the same
116      * origin - see {@link org.eclipse.aether.ConfigurationProperties#REPOSITORY_TRACKING_REPOSITORY_KEY_FUNCTION}.
117      * Lookups of entries written under a different key function miss and fail safe: the artifact stays tracked
118      * (so the untracked inter-op fallback in {@link #checkFind} does not accept it) but unavailable, forcing a
119      * checksum-validated re-fetch.
120      */
121     private final RepositoryKeyFunction trackingRepositoryKeyFunction;
122 
123     /**
124      * Cache of tracking file contents, keyed by tracking file path. Eliminates redundant disk I/O
125      * when multiple artifacts in the same directory are resolved — they all share the same
126      * {@code _remote.repositories} tracking file. Invalidated on writes via {@link #addRepo}.
127      * <p>
128      * Cached {@link Properties} instances are shared across threads and must be treated as
129      * read-only by callers of {@link #readRepos}. The cache is scoped to this manager instance
130      * (one per session), so concurrent builds in separate JVMs each maintain independent caches.
131      * If another process updates a tracking file after it has been cached here, a stale entry is
132      * NOT always harmless: for a tracked artifact it merely causes a redundant download, but in the
133      * untracked inter-op branch of {@link #checkFind} staleness would be trust-increasing (the file
134      * would be accepted as locally installed although its recorded origin says otherwise). That
135      * branch therefore re-reads the tracking file from disk before accepting, see
136      * {@link #readReposFresh}.
137      */
138     private final ConcurrentHashMap<Path, Properties> trackingFileCache = new ConcurrentHashMap<>();
139 
140     EnhancedLocalRepositoryManager(
141             Path basedir,
142             LocalPathComposer localPathComposer,
143             RepositoryKeyFunction trackingRepositoryKeyFunction,
144             String trackingFilename,
145             boolean legacyLocalRepository,
146             TrackingFileManager trackingFileManager,
147             LocalPathPrefixComposer localPathPrefixComposer) {
148         super(basedir, "enhanced", localPathComposer);
149         this.trackingRepositoryKeyFunction = requireNonNull(trackingRepositoryKeyFunction);
150         this.trackingFilename = requireNonNull(trackingFilename);
151         this.legacyLocalRepository = legacyLocalRepository;
152         this.trackingFileManager = requireNonNull(trackingFileManager);
153         this.localPathPrefixComposer = requireNonNull(localPathPrefixComposer);
154     }
155 
156     private String concatPaths(String prefix, String artifactPath) {
157         if (prefix == null || prefix.isEmpty()) {
158             return artifactPath;
159         }
160         return prefix + '/' + artifactPath;
161     }
162 
163     @Override
164     public String getPathForLocalArtifact(Artifact artifact) {
165         return concatPaths(
166                 localPathPrefixComposer.getPathPrefixForLocalArtifact(artifact),
167                 super.getPathForLocalArtifact(artifact));
168     }
169 
170     @Override
171     public String getPathForRemoteArtifact(Artifact artifact, RemoteRepository repository, String context) {
172         return concatPaths(
173                 localPathPrefixComposer.getPathPrefixForRemoteArtifact(artifact, repository),
174                 super.getPathForRemoteArtifact(artifact, repository, context));
175     }
176 
177     @Override
178     public String getPathForLocalMetadata(Metadata metadata) {
179         return concatPaths(
180                 localPathPrefixComposer.getPathPrefixForLocalMetadata(metadata),
181                 super.getPathForLocalMetadata(metadata));
182     }
183 
184     @Override
185     public String getPathForRemoteMetadata(Metadata metadata, RemoteRepository repository, String context) {
186         requireNonNull(metadata, "metadata cannot be null");
187         requireNonNull(repository, "repository cannot be null");
188         if (legacyLocalRepository) {
189             return concatPaths(
190                     localPathPrefixComposer.getPathPrefixForRemoteMetadata(metadata, repository),
191                     super.getPathForRemoteMetadata(metadata, repository, context));
192         } else {
193             return concatPaths(
194                     localPathPrefixComposer.getPathPrefixForRemoteMetadata(metadata, repository),
195                     localPathComposer.getPathForMetadata(metadata, getTrackingRepositoryKey(repository, context)));
196         }
197     }
198 
199     @Override
200     public LocalArtifactResult find(RepositorySystemSession session, LocalArtifactRequest request) {
201         Artifact artifact = request.getArtifact();
202         LocalArtifactResult result = new LocalArtifactResult(request);
203 
204         boolean verifyRealPath = ConfigUtils.getBoolean(
205                 session,
206                 EnhancedLocalRepositoryManagerFactory.DEFAULT_VERIFY_REAL_PATH,
207                 EnhancedLocalRepositoryManagerFactory.CONFIG_PROP_VERIFY_REAL_PATH);
208 
209         Path filePath;
210 
211         // Local repository CANNOT have timestamped installed, they are created only during deploy
212         if (Objects.equals(artifact.getVersion(), artifact.getBaseVersion())) {
213             filePath = getAbsolutePathForLocalArtifact(artifact);
214             checkFind(
215                     getPrefixDirectory(localPathPrefixComposer.getPathPrefixForLocalArtifact(artifact)),
216                     filePath,
217                     result,
218                     verifyRealPath);
219         }
220 
221         if (!result.isAvailable()) {
222             for (RemoteRepository repository : request.getRepositories()) {
223                 filePath = getAbsolutePathForRemoteArtifact(artifact, repository, request.getContext());
224 
225                 checkFind(
226                         getPrefixDirectory(
227                                 localPathPrefixComposer.getPathPrefixForRemoteArtifact(artifact, repository)),
228                         filePath,
229                         result,
230                         verifyRealPath);
231 
232                 if (result.isAvailable()) {
233                     break;
234                 }
235             }
236         }
237 
238         return result;
239     }
240 
241     private Path getPrefixDirectory(String prefix) {
242         Path basePath = getRepository().getBasePath();
243         return prefix == null || prefix.isEmpty() ? basePath : basePath.resolve(prefix);
244     }
245 
246     /**
247      * Verifies that the real (on-disk) spelling of the given artifact path matches the requested spelling,
248      * relative to the directory of its local path prefix. On case-insensitive or normalization-preserving
249      * filesystems (the macOS and Windows defaults) a cached file whose stored name differs from the requested one
250      * - for example one cached for case-colliding coordinates - still passes the file-existence check, while the
251      * tracking data in the tracking file is compared exactly: the aliased file would then be treated as
252      * present-but-untracked and accepted with no download and no checksum verification. Such aliases are treated
253      * as "not present" instead (fail closed), forcing a proper download for the requested coordinates. Only the
254      * coordinate-derived part of the path is compared, as a suffix of the real path: the base directory and the
255      * prefix directories are configuration, not coordinates, so they may be symbolic links - see
256      * {@link EnhancedLocalRepositoryManagerFactory#CONFIG_PROP_VERIFY_REAL_PATH} to opt out.
257      */
258     private boolean hasFaithfulRealPath(Path prefixDirectory, Path path) {
259         try {
260             String requested = prefixDirectory.relativize(path).toString();
261             String real = path.toRealPath().toString();
262             // compare strings: Path comparison ignores case on Windows
263             if (!real.endsWith(path.getFileSystem().getSeparator() + requested)) {
264                 LOGGER.warn(
265                         "Rejecting locally cached artifact {}: its on-disk path {} does not match the requested"
266                                 + " coordinates (filesystem case/normalization alias); treating it as not present",
267                         path,
268                         real);
269                 return false;
270             }
271             return true;
272         } catch (IOException | IllegalArgumentException e) {
273             // the real identity of the file cannot be established: fail closed, forcing a re-download
274             return false;
275         }
276     }
277 
278     private void checkFind(Path prefixDirectory, Path path, LocalArtifactResult result, boolean verifyRealPath) {
279         if (Files.isRegularFile(path) && (!verifyRealPath || hasFaithfulRealPath(prefixDirectory, path))) {
280             result.setPath(path);
281 
282             Properties props = readRepos(path);
283 
284             if (!applyTracking(path, result, props) && !isTracked(props, path)) {
285                 /*
286                  * The (possibly cached) state claims the artifact is untracked, and the untracked inter-op
287                  * branch below is trust-increasing: it accepts the file as locally installed. That decision
288                  * must never rest on tracking state that may be older than the file it judges (another process
289                  * sharing this local repository may have recorded the true origin of the file after our cache
290                  * entry was populated), so re-read the tracking file from disk before concluding untracked.
291                  */
292                 props = readReposFresh(path);
293                 if (!applyTracking(path, result, props) && !isTracked(props, path)) {
294                     /*
295                      * NOTE: The artifact is present but not tracked at all, for inter-op with simple local repo, assume
296                      * the artifact was locally installed.
297                      */
298                     result.setAvailable(true);
299                 }
300             }
301         }
302     }
303 
304     /**
305      * Applies the tracking-state based acceptance rules to given result: an artifact installed into the local
306      * repository is always accepted, an artifact downloaded from a remote repository is accepted only if
307      * downloaded from one of the request repositories.
308      *
309      * @return {@code true} if the result was made available, {@code false} otherwise.
310      */
311     private boolean applyTracking(Path path, LocalArtifactResult result, Properties props) {
312         if (props.get(getKey(path, LOCAL_REPO_ID)) != null) {
313             // artifact installed into the local repo is always accepted
314             result.setAvailable(true);
315             return true;
316         }
317         String context = result.getRequest().getContext();
318         for (RemoteRepository repository : result.getRequest().getRepositories()) {
319             String trackingKey = getTrackingRepositoryKey(repository, context);
320             if (props.get(getKey(path, trackingKey)) != null) {
321                 // artifact downloaded from remote repository is accepted only downloaded from request
322                 // repositories
323                 result.setAvailable(true);
324                 result.setRepository(repository);
325                 return true;
326             }
327             if (legacyLocalRepository) {
328                 // Backward compatibility fallback: if the tracking key function is URL-qualified (e.g. nid_hurl)
329                 // but the tracking file was written by an older resolver using the system-wide key function
330                 // (e.g. nid, producing ID-only entries like "artifact>central="), the URL-qualified lookup above
331                 // misses. Try the system-wide key function as a fallback: if it matches, the artifact was genuinely
332                 // downloaded from this repository under the old key scheme. Accept it and log a migration notice.
333                 String legacyKey = simpleRepositoryKeyFunction.apply(repository, context);
334                 if (!legacyKey.equals(trackingKey) && props.get(getKey(path, legacyKey)) != null) {
335                     LOGGER.debug(
336                             "Accepting locally cached artifact {} via legacy tracking key '{}'"
337                                     + " (current key function would produce '{}'); the entry will be"
338                                     + " upgraded on next download",
339                             path.getFileName(),
340                             legacyKey,
341                             trackingKey);
342                     result.setAvailable(true);
343                     result.setRepository(repository);
344                     return true;
345                 }
346                 // Same-ID-different-URL fallback: if the tracking file contains a URL-qualified entry for the
347                 // same repository ID but with a different URL hash (e.g. real Central tracked as
348                 // "central-<sha1(realUrl)>=" but the current build overrides central to "file:target/null"),
349                 // the exact lookup misses because sha1(realUrl) != sha1(file:target/null). Match by repo-ID
350                 // prefix: any entry starting with "filename>repoId-" is accepted as originating from the same
351                 // logical repository. The remainder must be exactly one SHA-1 hash, otherwise a different repository
352                 // whose ID merely starts with "repoId-" (e.g. "central-snapshots") would be accepted as well.
353                 String repoIdPrefix = getKey(path, legacyKey + "-");
354                 for (Object key : props.keySet()) {
355                     String k = key.toString();
356                     if (k.startsWith(repoIdPrefix) && isSha1(k.substring(repoIdPrefix.length()))) {
357                         LOGGER.debug(
358                                 "Accepting locally cached artifact {} via same-id tracking entry '{}'"
359                                         + " (current URL-qualified key would be '{}')",
360                                 path.getFileName(),
361                                 k,
362                                 getKey(path, trackingKey));
363                         result.setAvailable(true);
364                         result.setRepository(repository);
365                         return true;
366                     }
367                 }
368             }
369         }
370         return false;
371     }
372 
373     private static boolean isSha1(String s) {
374         return SHA1_HEX.matcher(s).matches();
375     }
376 
377     @Override
378     public void add(RepositorySystemSession session, LocalArtifactRegistration request) {
379         Collection<String> repositories;
380         // no contexts would record nothing, leaving the artifact untracked, hence later accepted as locally installed
381         Collection<String> contexts =
382                 request.getContexts().isEmpty() ? Collections.singleton("") : request.getContexts();
383         if (request.getRepository() == null) {
384             repositories = Collections.singleton(LOCAL_REPO_ID);
385         } else {
386             repositories = getRepositoryKeys(request.getRepository(), contexts);
387         }
388         if (request.getRepository() == null) {
389             addArtifact(request.getArtifact(), repositories, null, null);
390         } else {
391             for (String context : contexts) {
392                 addArtifact(request.getArtifact(), repositories, request.getRepository(), context);
393             }
394         }
395     }
396 
397     private Collection<String> getRepositoryKeys(RemoteRepository repository, Collection<String> contexts) {
398         Collection<String> keys = new HashSet<>();
399 
400         if (contexts != null) {
401             for (String context : contexts) {
402                 keys.add(getTrackingRepositoryKey(repository, context));
403             }
404         }
405 
406         return keys;
407     }
408 
409     private void addArtifact(
410             Artifact artifact, Collection<String> repositories, RemoteRepository repository, String context) {
411         requireNonNull(artifact, "artifact cannot be null");
412         Path file = repository == null
413                 ? getAbsolutePathForLocalArtifact(artifact)
414                 : getAbsolutePathForRemoteArtifact(artifact, repository, context);
415         addRepo(file, repositories);
416     }
417 
418     private Properties readRepos(Path artifactPath) {
419         Path trackingFile = getTrackingFile(artifactPath);
420         return trackingFileCache.computeIfAbsent(trackingFile, tf -> {
421             Properties props = trackingFileManager.read(tf);
422             return (props != null) ? props : EMPTY_PROPERTIES;
423         });
424     }
425 
426     /**
427      * Reads the tracking file for given artifact directly from disk, bypassing (and deliberately not populating)
428      * {@link #trackingFileCache}: callers use the result for a trust-increasing decision, which must not be taken
429      * on — nor allowed to re-cache — possibly stale state.
430      */
431     private Properties readReposFresh(Path artifactPath) {
432         Properties props = trackingFileManager.read(getTrackingFile(artifactPath));
433         return (props != null) ? props : EMPTY_PROPERTIES;
434     }
435 
436     private void addRepo(Path artifactPath, Collection<String> repositories) {
437         Map<String, String> updates = new HashMap<>();
438         for (String repository : repositories) {
439             updates.put(getKey(artifactPath, repository), "");
440         }
441 
442         Path trackingPath = getTrackingFile(artifactPath);
443 
444         // Invalidate cache before AND after the write. Before: using put() with the returned Properties
445         // would be racy — two concurrent addRepo() calls could reorder their puts, leaving stale data.
446         // After: a reader may re-populate the cache with the pre-write contents between the first
447         // invalidation and the write completing; without a post-write invalidation that stale entry —
448         // which is trust-affecting, see checkFind — would survive for the whole session.
449         trackingFileCache.remove(trackingPath);
450         try {
451             trackingFileManager.update(trackingPath, updates);
452         } finally {
453             trackingFileCache.remove(trackingPath);
454         }
455     }
456 
457     private Path getTrackingFile(Path artifactPath) {
458         return artifactPath.getParent().resolve(trackingFilename);
459     }
460 
461     private String getKey(Path path, String repository) {
462         return path.getFileName() + ">" + repository;
463     }
464 
465     /**
466      * Returns the tracking key of given repository, derived with the tracking-scoped key function (URL-qualified
467      * by default). Deliberately distinct from {@link #simpleRepositoryKeyFunction}, which follows
468      * the Maven 3.9 (Resolver 1.x) key function and is used for path composition: tracking must bind an artifact to the full
469      * identity of its origin, while on-disk layout and repository aggregation identity stay unchanged.
470      */
471     private String getTrackingRepositoryKey(RemoteRepository repository, String context) {
472         return trackingRepositoryKeyFunction.apply(repository, context);
473     }
474 
475     private boolean isTracked(Properties props, Path path) {
476         if (props != null) {
477             String keyPrefix = path.getFileName() + ">";
478             for (Object key : props.keySet()) {
479                 if (key.toString().startsWith(keyPrefix)) {
480                     return true;
481                 }
482             }
483         }
484         return false;
485     }
486 }