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>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 }