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