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