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