001/*
002 * Licensed to the Apache Software Foundation (ASF) under one
003 * or more contributor license agreements.  See the NOTICE file
004 * distributed with this work for additional information
005 * regarding copyright ownership.  The ASF licenses this file
006 * to you under the Apache License, Version 2.0 (the
007 * "License"); you may not use this file except in compliance
008 * with the License.  You may obtain a copy of the License at
009 *
010 *   http://www.apache.org/licenses/LICENSE-2.0
011 *
012 * Unless required by applicable law or agreed to in writing,
013 * software distributed under the License is distributed on an
014 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
015 * KIND, either express or implied.  See the License for the
016 * specific language governing permissions and limitations
017 * under the License.
018 */
019package org.eclipse.aether.internal.impl;
020
021import javax.inject.Inject;
022import javax.inject.Named;
023import javax.inject.Singleton;
024
025import java.nio.file.Files;
026import java.nio.file.Path;
027import java.util.Collections;
028import java.util.HashMap;
029import java.util.Map;
030import java.util.Properties;
031import java.util.Set;
032import java.util.TreeSet;
033import java.util.concurrent.ConcurrentHashMap;
034
035import org.eclipse.aether.ConfigurationProperties;
036import org.eclipse.aether.RepositorySystemSession;
037import org.eclipse.aether.SessionData;
038import org.eclipse.aether.artifact.Artifact;
039import org.eclipse.aether.impl.UpdateCheck;
040import org.eclipse.aether.impl.UpdateCheckManager;
041import org.eclipse.aether.impl.UpdatePolicyAnalyzer;
042import org.eclipse.aether.metadata.Metadata;
043import org.eclipse.aether.repository.AuthenticationDigest;
044import org.eclipse.aether.repository.Proxy;
045import org.eclipse.aether.repository.RemoteRepository;
046import org.eclipse.aether.resolution.ResolutionErrorPolicy;
047import org.eclipse.aether.spi.io.PathProcessor;
048import org.eclipse.aether.transfer.ArtifactNotFoundException;
049import org.eclipse.aether.transfer.ArtifactTransferException;
050import org.eclipse.aether.transfer.MetadataNotFoundException;
051import org.eclipse.aether.transfer.MetadataTransferException;
052import org.eclipse.aether.util.ConfigUtils;
053import org.slf4j.Logger;
054import org.slf4j.LoggerFactory;
055
056import static java.util.Objects.requireNonNull;
057
058/**
059 */
060@Singleton
061@Named
062public class DefaultUpdateCheckManager implements UpdateCheckManager {
063
064    private static final Logger LOGGER = LoggerFactory.getLogger(DefaultUpdateCheckManager.class);
065
066    private static final String UPDATED_KEY_SUFFIX = ".lastUpdated";
067
068    private static final String ERROR_KEY_SUFFIX = ".error";
069
070    private static final String NOT_FOUND = "";
071
072    static final Object SESSION_CHECKS = new Object() {
073        @Override
074        public String toString() {
075            return "updateCheckManager.checks";
076        }
077    };
078
079    /**
080     * Manages the session state, i.e. influences if the same download requests to artifacts/metadata will happen
081     * multiple times within the same RepositorySystemSession. If "enabled" will enable the session state. If "bypass"
082     * will enable bypassing (i.e. store all artifact ids/metadata ids which have been updates but not evaluating
083     * those). All other values lead to disabling the session state completely.
084     *
085     * @configurationSource {@link RepositorySystemSession#getConfigProperties()}
086     * @configurationType {@link java.lang.String}
087     * @configurationDefaultValue {@link #DEFAULT_SESSION_STATE}
088     */
089    public static final String CONFIG_PROP_SESSION_STATE =
090            ConfigurationProperties.PREFIX_AETHER + "updateCheckManager.sessionState";
091
092    public static final String DEFAULT_SESSION_STATE = "enabled";
093
094    private static final int STATE_ENABLED = 0;
095
096    private static final int STATE_BYPASS = 1;
097
098    private static final int STATE_DISABLED = 2;
099
100    /**
101     * This "last modified" timestamp is used when no local file is present, signaling "first attempt" to cache a file,
102     * but as it is not present, outcome is simply always "go get it".
103     * <p>
104     * Its meaning is "we never downloaded it", so go grab it.
105     */
106    private static final long TS_NEVER = 0L;
107
108    /**
109     * This "last modified" timestamp is returned by {@link #getLastUpdated(Properties, String)} method when the
110     * timestamp entry is not found (due properties file not present or key not present in properties file, irrelevant).
111     * It means that the cached file (artifact or metadata) is present, but we cannot tell when was it downloaded. In
112     * this case, it is {@link UpdatePolicyAnalyzer} applying in-effect policy, that decide is update (re-download)
113     * needed or not. For example, if policy is "never", we should not re-download the file.
114     * <p>
115     * Its meaning is "we downloaded it, but have no idea when", so let the policy decide its fate.
116     */
117    private static final long TS_UNKNOWN = 1L;
118
119    private final TrackingFileManager trackingFileManager;
120
121    private final UpdatePolicyAnalyzer updatePolicyAnalyzer;
122
123    private final PathProcessor pathProcessor;
124
125    @Inject
126    public DefaultUpdateCheckManager(
127            TrackingFileManager trackingFileManager,
128            UpdatePolicyAnalyzer updatePolicyAnalyzer,
129            PathProcessor pathProcessor) {
130        this.trackingFileManager = requireNonNull(trackingFileManager, "tracking file manager cannot be null");
131        this.updatePolicyAnalyzer = requireNonNull(updatePolicyAnalyzer, "update policy analyzer cannot be null");
132        this.pathProcessor = requireNonNull(pathProcessor, "path processor cannot be null");
133    }
134
135    @Override
136    public void checkArtifact(RepositorySystemSession session, UpdateCheck<Artifact, ArtifactTransferException> check) {
137        requireNonNull(session, "session cannot be null");
138        requireNonNull(check, "check cannot be null");
139        final String updatePolicy = check.getArtifactPolicy();
140        if (check.getLocalLastUpdated() != 0
141                && !isUpdatedRequired(session, check.getLocalLastUpdated(), updatePolicy)) {
142            LOGGER.debug("Skipped remote request for {}, locally installed artifact up-to-date", check.getItem());
143
144            check.setRequired(false);
145            return;
146        }
147
148        Artifact artifact = check.getItem();
149        RemoteRepository repository = check.getRepository();
150
151        Path artifactPath =
152                requireNonNull(check.getPath(), String.format("The artifact '%s' has no file attached", artifact));
153
154        boolean fileExists = check.isFileValid() && Files.exists(artifactPath);
155
156        Path touchPath = getArtifactTouchFile(artifactPath);
157        Properties props = read(touchPath);
158
159        String updateKey = getUpdateKey(session, artifactPath, repository);
160        String dataKey = getDataKey(repository);
161
162        String error = getError(props, dataKey);
163
164        long lastUpdated;
165        if (error == null) {
166            if (fileExists) {
167                // last update was successful
168                lastUpdated = pathProcessor.lastModified(artifactPath, 0L);
169            } else {
170                // this is the first attempt ever
171                lastUpdated = TS_NEVER;
172            }
173        } else if (error.isEmpty()) {
174            // artifact did not exist
175            lastUpdated = getLastUpdated(props, dataKey);
176        } else {
177            // artifact could not be transferred
178            String transferKey = getTransferKey(session, repository);
179            lastUpdated = getLastUpdated(props, transferKey);
180        }
181
182        if (lastUpdated == TS_NEVER) {
183            check.setRequired(true);
184        } else if (isAlreadyUpdated(session, updateKey)) {
185            LOGGER.debug("Skipped remote request for {}, already updated during this session", check.getItem());
186
187            check.setRequired(false);
188            if (error != null) {
189                check.setException(newException(error, artifact, repository));
190            }
191        } else if (isUpdatedRequired(session, lastUpdated, updatePolicy)) {
192            check.setRequired(true);
193        } else if (fileExists) {
194            LOGGER.debug("Skipped remote request for {}, locally cached artifact up-to-date", check.getItem());
195
196            check.setRequired(false);
197        } else {
198            int errorPolicy = Utils.getPolicy(session, artifact, repository);
199            int cacheFlag = getCacheFlag(error);
200            if ((errorPolicy & cacheFlag) != 0) {
201                check.setRequired(false);
202                check.setException(newException(error, artifact, repository));
203            } else {
204                check.setRequired(true);
205            }
206        }
207    }
208
209    private static int getCacheFlag(String error) {
210        if (error == null || error.isEmpty()) {
211            return ResolutionErrorPolicy.CACHE_NOT_FOUND;
212        } else {
213            return ResolutionErrorPolicy.CACHE_TRANSFER_ERROR;
214        }
215    }
216
217    private ArtifactTransferException newException(String error, Artifact artifact, RemoteRepository repository) {
218        if (error == null || error.isEmpty()) {
219            return new ArtifactNotFoundException(
220                    artifact,
221                    repository,
222                    artifact
223                            + " was not found in " + repository.getUrl()
224                            + " during a previous attempt. This failure was"
225                            + " cached in the local repository and"
226                            + " resolution is not reattempted until the update interval of " + repository.getId()
227                            + " has elapsed or updates are forced",
228                    true);
229        } else {
230            return new ArtifactTransferException(
231                    artifact,
232                    repository,
233                    artifact + " failed to transfer from "
234                            + repository.getUrl() + " during a previous attempt. This failure"
235                            + " was cached in the local repository and"
236                            + " resolution is not reattempted until the update interval of " + repository.getId()
237                            + " has elapsed or updates are forced. Original error: " + error,
238                    true);
239        }
240    }
241
242    @Override
243    public void checkMetadata(RepositorySystemSession session, UpdateCheck<Metadata, MetadataTransferException> check) {
244        requireNonNull(session, "session cannot be null");
245        requireNonNull(check, "check cannot be null");
246        final String updatePolicy = check.getMetadataPolicy();
247        if (check.getLocalLastUpdated() != 0
248                && !isUpdatedRequired(session, check.getLocalLastUpdated(), updatePolicy)) {
249            LOGGER.debug("Skipped remote request for {} locally installed metadata up-to-date", check.getItem());
250
251            check.setRequired(false);
252            return;
253        }
254
255        Metadata metadata = check.getItem();
256        RemoteRepository repository = check.getRepository();
257
258        Path metadataPath =
259                requireNonNull(check.getPath(), String.format("The metadata '%s' has no file attached", metadata));
260
261        boolean fileExists = check.isFileValid() && Files.exists(metadataPath);
262
263        Path touchPath = getMetadataTouchFile(metadataPath);
264        Properties props = read(touchPath);
265
266        String updateKey = getUpdateKey(session, metadataPath, repository);
267        String dataKey = getDataKey(metadataPath);
268
269        String error = getError(props, dataKey);
270
271        long lastUpdated;
272        if (error == null) {
273            if (fileExists) {
274                // last update was successful
275                lastUpdated = getLastUpdated(props, dataKey);
276            } else {
277                // this is the first attempt ever
278                lastUpdated = TS_NEVER;
279            }
280        } else if (error.isEmpty()) {
281            // metadata did not exist
282            lastUpdated = getLastUpdated(props, dataKey);
283        } else {
284            // metadata could not be transferred
285            String transferKey = getTransferKey(session, metadataPath, repository);
286            lastUpdated = getLastUpdated(props, transferKey);
287        }
288
289        if (lastUpdated == TS_NEVER) {
290            check.setRequired(true);
291        } else if (isAlreadyUpdated(session, updateKey)) {
292            LOGGER.debug("Skipped remote request for {}, already updated during this session", check.getItem());
293
294            check.setRequired(false);
295            if (error != null) {
296                check.setException(newException(error, metadata, repository));
297            }
298        } else if (isUpdatedRequired(session, lastUpdated, updatePolicy)) {
299            check.setRequired(true);
300        } else if (fileExists) {
301            LOGGER.debug("Skipped remote request for {}, locally cached metadata up-to-date", check.getItem());
302
303            check.setRequired(false);
304        } else {
305            int errorPolicy = Utils.getPolicy(session, metadata, repository);
306            int cacheFlag = getCacheFlag(error);
307            if ((errorPolicy & cacheFlag) != 0) {
308                check.setRequired(false);
309                check.setException(newException(error, metadata, repository));
310            } else {
311                check.setRequired(true);
312            }
313        }
314    }
315
316    private MetadataTransferException newException(String error, Metadata metadata, RemoteRepository repository) {
317        if (error == null || error.isEmpty()) {
318            return new MetadataNotFoundException(
319                    metadata,
320                    repository,
321                    metadata + " was not found in "
322                            + repository.getUrl() + " during a previous attempt."
323                            + " This failure was cached in the local repository and"
324                            + " resolution is not be reattempted until the update interval of " + repository.getId()
325                            + " has elapsed or updates are forced",
326                    true);
327        } else {
328            return new MetadataTransferException(
329                    metadata,
330                    repository,
331                    metadata + " failed to transfer from "
332                            + repository.getUrl() + " during a previous attempt."
333                            + " This failure was cached in the local repository and"
334                            + " resolution will not be reattempted until the update interval of " + repository.getId()
335                            + " has elapsed or updates are forced. Original error: " + error,
336                    true);
337        }
338    }
339
340    private long getLastUpdated(Properties props, String key) {
341        String value = props.getProperty(key + UPDATED_KEY_SUFFIX, "");
342        try {
343            return (!value.isEmpty()) ? Long.parseLong(value) : TS_UNKNOWN;
344        } catch (NumberFormatException e) {
345            LOGGER.debug("Cannot parse last updated date {}, ignoring it", value, e);
346            return TS_UNKNOWN;
347        }
348    }
349
350    private String getError(Properties props, String key) {
351        return props.getProperty(key + ERROR_KEY_SUFFIX);
352    }
353
354    private Path getArtifactTouchFile(Path artifactPath) {
355        return artifactPath.getParent().resolve(artifactPath.getFileName() + UPDATED_KEY_SUFFIX);
356    }
357
358    private Path getMetadataTouchFile(Path metadataPath) {
359        return metadataPath.getParent().resolve("resolver-status.properties");
360    }
361
362    private String getDataKey(RemoteRepository repository) {
363        Set<String> mirroredUrls = Collections.emptySet();
364        if (repository.isRepositoryManager()) {
365            mirroredUrls = new TreeSet<>();
366            for (RemoteRepository mirroredRepository : repository.getMirroredRepositories()) {
367                mirroredUrls.add(normalizeRepoUrl(mirroredRepository.getUrl()));
368            }
369        }
370
371        StringBuilder buffer = new StringBuilder(1024);
372
373        buffer.append(normalizeRepoUrl(repository.getUrl()));
374        for (String mirroredUrl : mirroredUrls) {
375            buffer.append('+').append(mirroredUrl);
376        }
377
378        return buffer.toString();
379    }
380
381    private String getTransferKey(RepositorySystemSession session, RemoteRepository repository) {
382        return getRepoKey(session, repository);
383    }
384
385    private String getDataKey(Path metadataPath) {
386        return metadataPath.getFileName().toString();
387    }
388
389    private String getTransferKey(RepositorySystemSession session, Path metadataPath, RemoteRepository repository) {
390        return metadataPath.getFileName().toString() + '/' + getRepoKey(session, repository);
391    }
392
393    private String getRepoKey(RepositorySystemSession session, RemoteRepository repository) {
394        StringBuilder buffer = new StringBuilder(128);
395
396        Proxy proxy = repository.getProxy();
397        if (proxy != null) {
398            buffer.append(AuthenticationDigest.forProxy(session, repository)).append('@');
399            buffer.append(proxy.getHost()).append(':').append(proxy.getPort()).append('>');
400        }
401
402        buffer.append(AuthenticationDigest.forRepository(session, repository)).append('@');
403
404        buffer.append(repository.getContentType()).append('-');
405        buffer.append(repository.getId()).append('-');
406        buffer.append(normalizeRepoUrl(repository.getUrl()));
407
408        return buffer.toString();
409    }
410
411    private String normalizeRepoUrl(String url) {
412        String result = url;
413        if (url != null && !url.isEmpty() && !url.endsWith("/")) {
414            result = url + '/';
415        }
416        return result;
417    }
418
419    private String getUpdateKey(RepositorySystemSession session, Path path, RemoteRepository repository) {
420        return path.toAbsolutePath() + "|" + getRepoKey(session, repository);
421    }
422
423    private int getSessionState(RepositorySystemSession session) {
424        String mode = ConfigUtils.getString(session, DEFAULT_SESSION_STATE, CONFIG_PROP_SESSION_STATE);
425        if (Boolean.parseBoolean(mode) || "enabled".equalsIgnoreCase(mode)) {
426            // perform update check at most once per session, regardless of update policy
427            return STATE_ENABLED;
428        } else if ("bypass".equalsIgnoreCase(mode)) {
429            // evaluate update policy but record update in session to prevent potential future checks
430            return STATE_BYPASS;
431        } else {
432            // no session state at all, always evaluate update policy
433            return STATE_DISABLED;
434        }
435    }
436
437    private boolean isAlreadyUpdated(RepositorySystemSession session, Object updateKey) {
438        if (getSessionState(session) >= STATE_BYPASS) {
439            return false;
440        }
441        SessionData data = session.getData();
442        Object checkedFiles = data.get(SESSION_CHECKS);
443        if (!(checkedFiles instanceof Map)) {
444            return false;
445        }
446        return ((Map<?, ?>) checkedFiles).containsKey(updateKey);
447    }
448
449    @SuppressWarnings("unchecked")
450    private void setUpdated(RepositorySystemSession session, Object updateKey) {
451        if (getSessionState(session) >= STATE_DISABLED) {
452            return;
453        }
454        SessionData data = session.getData();
455        Object checkedFiles = data.computeIfAbsent(SESSION_CHECKS, () -> new ConcurrentHashMap<>(256));
456        ((Map<Object, Boolean>) checkedFiles).put(updateKey, Boolean.TRUE);
457    }
458
459    private boolean isUpdatedRequired(RepositorySystemSession session, long lastModified, String policy) {
460        return updatePolicyAnalyzer.isUpdatedRequired(session, lastModified, policy);
461    }
462
463    private Properties read(Path touchPath) {
464        Properties props = trackingFileManager.read(touchPath);
465        return (props != null) ? props : new Properties();
466    }
467
468    @Override
469    public void touchArtifact(RepositorySystemSession session, UpdateCheck<Artifact, ArtifactTransferException> check) {
470        requireNonNull(session, "session cannot be null");
471        requireNonNull(check, "check cannot be null");
472        Path artifactPath = check.getPath();
473        Path touchPath = getArtifactTouchFile(artifactPath);
474
475        String updateKey = getUpdateKey(session, artifactPath, check.getRepository());
476        String dataKey = getDataKey(check.getAuthoritativeRepository());
477        String transferKey = getTransferKey(session, check.getRepository());
478
479        setUpdated(session, updateKey);
480        Properties props = write(touchPath, dataKey, transferKey, check.getException());
481
482        if (Files.exists(artifactPath) && !hasErrors(props)) {
483            trackingFileManager.delete(touchPath);
484        }
485    }
486
487    private boolean hasErrors(Properties props) {
488        for (Object key : props.keySet()) {
489            if (key.toString().endsWith(ERROR_KEY_SUFFIX)) {
490                return true;
491            }
492        }
493        return false;
494    }
495
496    @Override
497    public void touchMetadata(RepositorySystemSession session, UpdateCheck<Metadata, MetadataTransferException> check) {
498        requireNonNull(session, "session cannot be null");
499        requireNonNull(check, "check cannot be null");
500        Path metadataPath = check.getPath();
501        Path touchPath = getMetadataTouchFile(metadataPath);
502
503        String updateKey = getUpdateKey(session, metadataPath, check.getRepository());
504        String dataKey = getDataKey(metadataPath);
505        String transferKey = getTransferKey(session, metadataPath, check.getRepository());
506
507        setUpdated(session, updateKey);
508        write(touchPath, dataKey, transferKey, check.getException());
509    }
510
511    private Properties write(Path touchPath, String dataKey, String transferKey, Exception error) {
512        Map<String, String> updates = new HashMap<>();
513
514        String timestamp = Long.toString(System.currentTimeMillis());
515
516        if (error == null) {
517            updates.put(dataKey + ERROR_KEY_SUFFIX, null);
518            updates.put(dataKey + UPDATED_KEY_SUFFIX, timestamp);
519            updates.put(transferKey + UPDATED_KEY_SUFFIX, null);
520        } else if (error instanceof ArtifactNotFoundException || error instanceof MetadataNotFoundException) {
521            updates.put(dataKey + ERROR_KEY_SUFFIX, NOT_FOUND);
522            updates.put(dataKey + UPDATED_KEY_SUFFIX, timestamp);
523            updates.put(transferKey + UPDATED_KEY_SUFFIX, null);
524        } else {
525            String msg = error.getMessage();
526            if (msg == null || msg.isEmpty()) {
527                msg = error.getClass().getSimpleName();
528            }
529            updates.put(dataKey + ERROR_KEY_SUFFIX, msg);
530            updates.put(dataKey + UPDATED_KEY_SUFFIX, null);
531            updates.put(transferKey + UPDATED_KEY_SUFFIX, timestamp);
532        }
533
534        return trackingFileManager.update(touchPath, updates);
535    }
536}