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