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.checksum;
020
021import javax.inject.Inject;
022import javax.inject.Named;
023import javax.inject.Singleton;
024
025import java.io.BufferedReader;
026import java.io.IOException;
027import java.io.UncheckedIOException;
028import java.nio.charset.StandardCharsets;
029import java.nio.file.Files;
030import java.nio.file.Path;
031import java.util.ArrayList;
032import java.util.HashMap;
033import java.util.List;
034import java.util.Map;
035import java.util.Objects;
036import java.util.concurrent.ConcurrentHashMap;
037import java.util.concurrent.atomic.AtomicBoolean;
038import java.util.function.Function;
039import java.util.stream.Collectors;
040
041import org.eclipse.aether.MultiRuntimeException;
042import org.eclipse.aether.RepositorySystemSession;
043import org.eclipse.aether.artifact.Artifact;
044import org.eclipse.aether.impl.RepositorySystemLifecycle;
045import org.eclipse.aether.internal.impl.LocalPathComposer;
046import org.eclipse.aether.repository.ArtifactRepository;
047import org.eclipse.aether.spi.connector.checksum.ChecksumAlgorithmFactory;
048import org.eclipse.aether.spi.io.PathProcessor;
049import org.eclipse.aether.spi.remoterepo.RepositoryKeyFunctionFactory;
050import org.eclipse.aether.util.ConfigUtils;
051import org.slf4j.Logger;
052import org.slf4j.LoggerFactory;
053
054import static java.util.Objects.requireNonNull;
055
056/**
057 * Compact file {@link FileTrustedChecksumsSourceSupport} implementation that use specified directory as base
058 * directory, where it expects a "summary" file named as "checksums.${checksumExt}" for each checksum algorithm.
059 * File format is GNU Coreutils compatible: each line holds checksum followed by two spaces and artifact relative path
060 * (from local repository root, without leading "./"). This means that trusted checksums summary file can be used to
061 * validate artifacts or generate it using standard GNU tools like GNU {@code sha1sum} is (for BSD derivatives same
062 * file can be used with {@code -r} switch).
063 * <p>
064 * The format supports comments "#" (hash) and empty lines for easier structuring the file content, and both are
065 * ignored. Also, their presence makes the summary file incompatible with GNU Coreutils format. On save of the
066 * summary file, the comments and empty lines are lost, and file is sorted by path names for easier diffing
067 * (2nd column in file).
068 * <p>
069 * The source by default is "origin aware", and it will factor in origin repository ID as well into summary file name,
070 * for example "checksums-central.sha256".
071 * <p>
072 * Example commands for managing summary file (in examples will use repository ID "central"):
073 * <ul>
074 *     <li>To create summary file: {@code find * -not -name "checksums-central.sha256" -type f -print0 |
075 *       xargs -0 sha256sum | sort -k 2 > checksums-central.sha256}</li>
076 *     <li>To verify artifacts using summary file: {@code sha256sum --quiet -c checksums-central.sha256}</li>
077 * </ul>
078 * <p>
079 * The checksums summary file is lazily loaded and remains cached during lifetime of the component, so file changes
080 * during lifecycle of the component are not picked up. This implementation can be simultaneously used to lookup and
081 * also record checksums. The recorded checksums will become visible for every session, and will be flushed
082 * at repository system shutdown, merged with existing ones on disk.
083 * <p>
084 * The name of this implementation is "summaryFile".
085 *
086 * @see <a href="https://man7.org/linux/man-pages/man1/sha1sum.1.html">sha1sum man page</a>
087 * @see <a href="https://www.gnu.org/software/coreutils/manual/coreutils.html#md5sum-invocation">GNU Coreutils: md5sum</a>
088 * @since 1.9.0
089 */
090@Singleton
091@Named(SummaryFileTrustedChecksumsSource.NAME)
092public final class SummaryFileTrustedChecksumsSource extends FileTrustedChecksumsSourceSupport {
093    public static final String NAME = "summaryFile";
094
095    private static final String CONFIG_PROPS_PREFIX =
096            FileTrustedChecksumsSourceSupport.CONFIG_PROPS_PREFIX + NAME + ".";
097
098    /**
099     * Is checksum source enabled?
100     *
101     * @configurationSource {@link RepositorySystemSession#getConfigProperties()}
102     * @configurationType {@link java.lang.Boolean}
103     * @configurationDefaultValue false
104     */
105    public static final String CONFIG_PROP_ENABLED = FileTrustedChecksumsSourceSupport.CONFIG_PROPS_PREFIX + NAME;
106
107    /**
108     * The basedir where checksums are. If relative, is resolved from local repository root.
109     *
110     * @configurationSource {@link RepositorySystemSession#getConfigProperties()}
111     * @configurationType {@link java.lang.String}
112     * @configurationDefaultValue {@link #LOCAL_REPO_PREFIX_DIR}
113     */
114    public static final String CONFIG_PROP_BASEDIR = CONFIG_PROPS_PREFIX + "basedir";
115
116    public static final String LOCAL_REPO_PREFIX_DIR = ".checksums";
117
118    /**
119     * Is source origin aware?
120     *
121     * @configurationSource {@link RepositorySystemSession#getConfigProperties()}
122     * @configurationType {@link java.lang.Boolean}
123     * @configurationDefaultValue true
124     */
125    public static final String CONFIG_PROP_ORIGIN_AWARE = CONFIG_PROPS_PREFIX + "originAware";
126
127    public static final String CHECKSUMS_FILE_PREFIX = "checksums";
128
129    private static final Logger LOGGER = LoggerFactory.getLogger(SummaryFileTrustedChecksumsSource.class);
130
131    private final LocalPathComposer localPathComposer;
132
133    private final RepositorySystemLifecycle repositorySystemLifecycle;
134
135    private final PathProcessor pathProcessor;
136
137    private final ConcurrentHashMap<Path, ConcurrentHashMap<String, String>> checksums;
138
139    private final ConcurrentHashMap<Path, Boolean> changedChecksums;
140
141    private final AtomicBoolean onShutdownHandlerRegistered;
142
143    @Inject
144    public SummaryFileTrustedChecksumsSource(
145            RepositoryKeyFunctionFactory repoKeyFunctionFactory,
146            LocalPathComposer localPathComposer,
147            RepositorySystemLifecycle repositorySystemLifecycle,
148            PathProcessor pathProcessor) {
149        super(repoKeyFunctionFactory);
150        this.localPathComposer = requireNonNull(localPathComposer);
151        this.repositorySystemLifecycle = requireNonNull(repositorySystemLifecycle);
152        this.pathProcessor = requireNonNull(pathProcessor);
153        this.checksums = new ConcurrentHashMap<>();
154        this.changedChecksums = new ConcurrentHashMap<>();
155        this.onShutdownHandlerRegistered = new AtomicBoolean(false);
156    }
157
158    @Override
159    protected boolean isEnabled(RepositorySystemSession session) {
160        return ConfigUtils.getBoolean(session, false, CONFIG_PROP_ENABLED);
161    }
162
163    private boolean isOriginAware(RepositorySystemSession session) {
164        return ConfigUtils.getBoolean(session, true, CONFIG_PROP_ORIGIN_AWARE);
165    }
166
167    @Override
168    protected Map<String, String> doGetTrustedArtifactChecksums(
169            RepositorySystemSession session,
170            Artifact artifact,
171            ArtifactRepository artifactRepository,
172            List<ChecksumAlgorithmFactory> checksumAlgorithmFactories) {
173        final HashMap<String, String> result = new HashMap<>();
174        final Path basedir = getBasedir(session, LOCAL_REPO_PREFIX_DIR, CONFIG_PROP_BASEDIR, false);
175        if (Files.isDirectory(basedir)) {
176            final String artifactPath = localPathComposer.getPathForArtifact(artifact, false);
177            final boolean originAware = isOriginAware(session);
178            for (ChecksumAlgorithmFactory checksumAlgorithmFactory : checksumAlgorithmFactories) {
179                Path summaryFile = summaryFile(
180                        basedir,
181                        originAware,
182                        repositoryKey(session, artifactRepository),
183                        checksumAlgorithmFactory.getFileExtension());
184                ConcurrentHashMap<String, String> algorithmChecksums =
185                        checksums.computeIfAbsent(summaryFile, f -> loadProvidedChecksums(summaryFile));
186                String checksum = algorithmChecksums.get(artifactPath);
187                if (checksum != null) {
188                    result.put(checksumAlgorithmFactory.getName(), checksum);
189                }
190            }
191        }
192        return result;
193    }
194
195    @Override
196    protected Writer doGetTrustedArtifactChecksumsWriter(RepositorySystemSession session) {
197        if (onShutdownHandlerRegistered.compareAndSet(false, true)) {
198            repositorySystemLifecycle.addOnSystemEndedHandler(this::saveRecordedLines);
199        }
200        return new SummaryFileWriter(
201                checksums,
202                getBasedir(session, LOCAL_REPO_PREFIX_DIR, CONFIG_PROP_BASEDIR, true),
203                isOriginAware(session),
204                r -> repositoryKey(session, r));
205    }
206
207    /**
208     * Returns the summary file path. The file itself and its parent directories may not exist, this method merely
209     * calculate the path.
210     */
211    private Path summaryFile(Path basedir, boolean originAware, String safeRepositoryId, String checksumExtension) {
212        String fileName = CHECKSUMS_FILE_PREFIX;
213        if (originAware) {
214            fileName += "-" + safeRepositoryId;
215        }
216        return basedir.resolve(fileName + "." + checksumExtension);
217    }
218
219    private ConcurrentHashMap<String, String> loadProvidedChecksums(Path summaryFile) {
220        ConcurrentHashMap<String, String> result = new ConcurrentHashMap<>();
221        if (Files.isRegularFile(summaryFile)) {
222            try (BufferedReader reader = Files.newBufferedReader(summaryFile, StandardCharsets.UTF_8)) {
223                String line;
224                while ((line = reader.readLine()) != null) {
225                    if (!line.startsWith("#") && !line.isEmpty()) {
226                        String[] parts = line.split("  ", 2);
227                        if (parts.length == 2) {
228                            String newChecksum = parts[0];
229                            String artifactPath = parts[1];
230                            String oldChecksum = result.put(artifactPath, newChecksum);
231                            if (oldChecksum != null) {
232                                if (Objects.equals(oldChecksum, newChecksum)) {
233                                    LOGGER.warn(
234                                            "Checksums file '{}' contains duplicate checksums for artifact {}: {}",
235                                            summaryFile,
236                                            artifactPath,
237                                            oldChecksum);
238                                } else {
239                                    LOGGER.warn(
240                                            "Checksums file '{}' contains different checksums for artifact {}: "
241                                                    + "old '{}' replaced by new '{}'",
242                                            summaryFile,
243                                            artifactPath,
244                                            oldChecksum,
245                                            newChecksum);
246                                }
247                            }
248                        } else {
249                            LOGGER.warn("Checksums file '{}' ignored malformed line '{}'", summaryFile, line);
250                        }
251                    }
252                }
253            } catch (IOException e) {
254                throw new UncheckedIOException(e);
255            }
256            LOGGER.info("Loaded {} trusted checksums from {}", result.size(), summaryFile);
257        }
258        return result;
259    }
260
261    private class SummaryFileWriter implements Writer {
262        private final ConcurrentHashMap<Path, ConcurrentHashMap<String, String>> cache;
263
264        private final Path basedir;
265
266        private final boolean originAware;
267
268        private final Function<ArtifactRepository, String> repositoryKeyFunction;
269
270        private SummaryFileWriter(
271                ConcurrentHashMap<Path, ConcurrentHashMap<String, String>> cache,
272                Path basedir,
273                boolean originAware,
274                Function<ArtifactRepository, String> repositoryKeyFunction) {
275            this.cache = cache;
276            this.basedir = basedir;
277            this.originAware = originAware;
278            this.repositoryKeyFunction = repositoryKeyFunction;
279        }
280
281        @Override
282        public void addTrustedArtifactChecksums(
283                Artifact artifact,
284                ArtifactRepository artifactRepository,
285                List<ChecksumAlgorithmFactory> checksumAlgorithmFactories,
286                Map<String, String> trustedArtifactChecksums) {
287            String artifactPath = localPathComposer.getPathForArtifact(artifact, false);
288            for (ChecksumAlgorithmFactory checksumAlgorithmFactory : checksumAlgorithmFactories) {
289                Path summaryFile = summaryFile(
290                        basedir,
291                        originAware,
292                        repositoryKeyFunction.apply(artifactRepository),
293                        checksumAlgorithmFactory.getFileExtension());
294                String checksum = requireNonNull(trustedArtifactChecksums.get(checksumAlgorithmFactory.getName()));
295
296                String oldChecksum = cache.computeIfAbsent(summaryFile, k -> loadProvidedChecksums(summaryFile))
297                        .put(artifactPath, checksum);
298
299                if (oldChecksum == null) {
300                    changedChecksums.put(summaryFile, Boolean.TRUE); // new
301                } else if (!Objects.equals(oldChecksum, checksum)) {
302                    changedChecksums.put(summaryFile, Boolean.TRUE); // replaced
303                    LOGGER.info(
304                            "Trusted checksum for artifact {} replaced: old {}, new {}",
305                            artifact,
306                            oldChecksum,
307                            checksum);
308                }
309            }
310        }
311    }
312
313    /**
314     * On-close handler that saves recorded checksums, if any.
315     */
316    private void saveRecordedLines() {
317        if (changedChecksums.isEmpty()) {
318            return;
319        }
320
321        ArrayList<Exception> exceptions = new ArrayList<>();
322        for (Map.Entry<Path, ConcurrentHashMap<String, String>> entry : checksums.entrySet()) {
323            Path summaryFile = entry.getKey();
324            if (changedChecksums.get(summaryFile) != Boolean.TRUE) {
325                continue;
326            }
327            ConcurrentHashMap<String, String> recordedLines = entry.getValue();
328            if (!recordedLines.isEmpty()) {
329                try {
330                    ConcurrentHashMap<String, String> result = new ConcurrentHashMap<>();
331                    result.putAll(loadProvidedChecksums(summaryFile));
332                    result.putAll(recordedLines);
333
334                    LOGGER.info("Saving {} checksums to '{}'", result.size(), summaryFile);
335                    pathProcessor.writeWithBackup(
336                            summaryFile,
337                            result.entrySet().stream()
338                                    .sorted(Map.Entry.comparingByKey())
339                                    .map(e -> e.getValue() + "  " + e.getKey())
340                                    .collect(Collectors.joining(System.lineSeparator())));
341                } catch (IOException e) {
342                    exceptions.add(e);
343                }
344            }
345        }
346        MultiRuntimeException.mayThrow("session save checksums failure", exceptions);
347    }
348}