View Javadoc
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.checksum;
20  
21  import javax.inject.Inject;
22  import javax.inject.Named;
23  import javax.inject.Singleton;
24  
25  import java.io.BufferedReader;
26  import java.io.IOException;
27  import java.io.UncheckedIOException;
28  import java.nio.charset.StandardCharsets;
29  import java.nio.file.Files;
30  import java.nio.file.Path;
31  import java.util.ArrayList;
32  import java.util.HashMap;
33  import java.util.List;
34  import java.util.Map;
35  import java.util.Objects;
36  import java.util.concurrent.ConcurrentHashMap;
37  import java.util.concurrent.atomic.AtomicBoolean;
38  
39  import org.eclipse.aether.MultiRuntimeException;
40  import org.eclipse.aether.RepositorySystemSession;
41  import org.eclipse.aether.artifact.Artifact;
42  import org.eclipse.aether.impl.RepositorySystemLifecycle;
43  import org.eclipse.aether.internal.impl.LocalPathComposer;
44  import org.eclipse.aether.repository.ArtifactRepository;
45  import org.eclipse.aether.spi.connector.checksum.ChecksumAlgorithmFactory;
46  import org.eclipse.aether.util.FileUtils;
47  import org.slf4j.Logger;
48  import org.slf4j.LoggerFactory;
49  
50  import static java.util.Objects.requireNonNull;
51  import static java.util.stream.Collectors.toList;
52  
53  /**
54   * Compact file {@link FileTrustedChecksumsSourceSupport} implementation that use specified directory as base
55   * directory, where it expects a "summary" file named as "checksums.${checksumExt}" for each checksum algorithm.
56   * File format is GNU Coreutils compatible: each line holds checksum followed by two spaces and artifact relative path
57   * (from local repository root, without leading "./"). This means that trusted checksums summary file can be used to
58   * validate artifacts or generate it using standard GNU tools like GNU {@code sha1sum} is (for BSD derivatives same
59   * file can be used with {@code -r} switch).
60   * <p>
61   * The format supports comments "#" (hash) and empty lines for easier structuring the file content, and both are
62   * ignored. Also, their presence makes the summary file incompatible with GNU Coreutils format. On save of the
63   * summary file, the comments and empty lines are lost, and file is sorted by path names for easier diffing
64   * (2nd column in file).
65   * <p>
66   * The source by default is "origin aware", and it will factor in origin repository ID as well into summary file name,
67   * for example "checksums-central.sha256".
68   * <p>
69   * Example commands for managing summary file (in examples will use repository ID "central"):
70   * <ul>
71   *     <li>To create summary file: {@code find * -not -name "checksums-central.sha256" -type f -print0 |
72   *       xargs -0 sha256sum | sort -k 2 > checksums-central.sha256}</li>
73   *     <li>To verify artifacts using summary file: {@code sha256sum --quiet -c checksums-central.sha256}</li>
74   * </ul>
75   * <p>
76   * The checksums summary file is lazily loaded and remains cached during lifetime of the component, so file changes
77   * during lifecycle of the component are not picked up. This implementation can be simultaneously used to lookup and
78   * also record checksums. The recorded checksums will become visible for every session, and will be flushed
79   * at repository system shutdown, merged with existing ones on disk.
80   * <p>
81   * The name of this implementation is "summaryFile".
82   *
83   * @see <a href="https://man7.org/linux/man-pages/man1/sha1sum.1.html">sha1sum man page</a>
84   * @see <a href="https://www.gnu.org/software/coreutils/manual/coreutils.html#md5sum-invocation">GNU Coreutils: md5sum</a>
85   * @since 1.9.0
86   */
87  @Singleton
88  @Named(SummaryFileTrustedChecksumsSource.NAME)
89  public final class SummaryFileTrustedChecksumsSource extends FileTrustedChecksumsSourceSupport {
90      public static final String NAME = "summaryFile";
91  
92      private static final String CHECKSUMS_FILE_PREFIX = "checksums";
93  
94      private static final Logger LOGGER = LoggerFactory.getLogger(SummaryFileTrustedChecksumsSource.class);
95  
96      private final LocalPathComposer localPathComposer;
97  
98      private final RepositorySystemLifecycle repositorySystemLifecycle;
99  
100     private final ConcurrentHashMap<Path, ConcurrentHashMap<String, String>> checksums;
101 
102     private final ConcurrentHashMap<Path, Boolean> changedChecksums;
103 
104     private final AtomicBoolean onShutdownHandlerRegistered;
105 
106     @Inject
107     public SummaryFileTrustedChecksumsSource(
108             LocalPathComposer localPathComposer, RepositorySystemLifecycle repositorySystemLifecycle) {
109         super(NAME);
110         this.localPathComposer = requireNonNull(localPathComposer);
111         this.repositorySystemLifecycle = requireNonNull(repositorySystemLifecycle);
112         this.checksums = new ConcurrentHashMap<>();
113         this.changedChecksums = new ConcurrentHashMap<>();
114         this.onShutdownHandlerRegistered = new AtomicBoolean(false);
115     }
116 
117     @Override
118     protected Map<String, String> doGetTrustedArtifactChecksums(
119             RepositorySystemSession session,
120             Artifact artifact,
121             ArtifactRepository artifactRepository,
122             List<ChecksumAlgorithmFactory> checksumAlgorithmFactories) {
123         final HashMap<String, String> result = new HashMap<>();
124         final Path basedir = getBasedir(session, false);
125         if (Files.isDirectory(basedir)) {
126             final String artifactPath = localPathComposer.getPathForArtifact(artifact, false);
127             final boolean originAware = isOriginAware(session);
128             for (ChecksumAlgorithmFactory checksumAlgorithmFactory : checksumAlgorithmFactories) {
129                 Path summaryFile = summaryFile(
130                         basedir, originAware, artifactRepository.getId(), checksumAlgorithmFactory.getFileExtension());
131                 ConcurrentHashMap<String, String> algorithmChecksums = checksums.computeIfAbsent(summaryFile, f -> {
132                     ConcurrentHashMap<String, String> loaded = loadProvidedChecksums(summaryFile);
133                     if (Files.isRegularFile(summaryFile)) {
134                         LOGGER.info(
135                                 "Loaded {} {} trusted checksums for remote repository {}",
136                                 loaded.size(),
137                                 checksumAlgorithmFactory.getName(),
138                                 artifactRepository.getId());
139                     }
140                     return loaded;
141                 });
142                 String checksum = algorithmChecksums.get(artifactPath);
143                 if (checksum != null) {
144                     result.put(checksumAlgorithmFactory.getName(), checksum);
145                 }
146             }
147         }
148         return result;
149     }
150 
151     @Override
152     protected SummaryFileWriter doGetTrustedArtifactChecksumsWriter(RepositorySystemSession session) {
153         if (onShutdownHandlerRegistered.compareAndSet(false, true)) {
154             repositorySystemLifecycle.addOnSystemEndedHandler(this::saveRecordedLines);
155         }
156         return new SummaryFileWriter(checksums, getBasedir(session, true), isOriginAware(session));
157     }
158 
159     /**
160      * Returns the summary file path. The file itself and its parent directories may not exist, this method merely
161      * calculate the path.
162      */
163     private Path summaryFile(Path basedir, boolean originAware, String repositoryId, String checksumExtension) {
164         String fileName = CHECKSUMS_FILE_PREFIX;
165         if (originAware) {
166             fileName += "-" + repositoryId;
167         }
168         return basedir.resolve(fileName + "." + checksumExtension);
169     }
170 
171     private ConcurrentHashMap<String, String> loadProvidedChecksums(Path summaryFile) {
172         ConcurrentHashMap<String, String> result = new ConcurrentHashMap<>();
173         if (Files.isRegularFile(summaryFile)) {
174             try (BufferedReader reader = Files.newBufferedReader(summaryFile, StandardCharsets.UTF_8)) {
175                 String line;
176                 while ((line = reader.readLine()) != null) {
177                     if (!line.startsWith("#") && !line.isEmpty()) {
178                         String[] parts = line.split("  ", 2);
179                         if (parts.length == 2) {
180                             String newChecksum = parts[0];
181                             String artifactPath = parts[1];
182                             String oldChecksum = result.put(artifactPath, newChecksum);
183                             if (oldChecksum != null) {
184                                 if (Objects.equals(oldChecksum, newChecksum)) {
185                                     LOGGER.warn(
186                                             "Checksums file '{}' contains duplicate checksums for artifact {}: {}",
187                                             summaryFile,
188                                             artifactPath,
189                                             oldChecksum);
190                                 } else {
191                                     LOGGER.warn(
192                                             "Checksums file '{}' contains different checksums for artifact {}: "
193                                                     + "old '{}' replaced by new '{}'",
194                                             summaryFile,
195                                             artifactPath,
196                                             oldChecksum,
197                                             newChecksum);
198                                 }
199                             }
200                         } else {
201                             LOGGER.warn("Checksums file '{}' ignored malformed line '{}'", summaryFile, line);
202                         }
203                     }
204                 }
205             } catch (IOException e) {
206                 throw new UncheckedIOException(e);
207             }
208         }
209         return result;
210     }
211 
212     private class SummaryFileWriter implements Writer {
213         private final ConcurrentHashMap<Path, ConcurrentHashMap<String, String>> cache;
214 
215         private final Path basedir;
216 
217         private final boolean originAware;
218 
219         private SummaryFileWriter(
220                 ConcurrentHashMap<Path, ConcurrentHashMap<String, String>> cache, Path basedir, boolean originAware) {
221             this.cache = cache;
222             this.basedir = basedir;
223             this.originAware = originAware;
224         }
225 
226         @Override
227         public void addTrustedArtifactChecksums(
228                 Artifact artifact,
229                 ArtifactRepository artifactRepository,
230                 List<ChecksumAlgorithmFactory> checksumAlgorithmFactories,
231                 Map<String, String> trustedArtifactChecksums) {
232             String artifactPath = localPathComposer.getPathForArtifact(artifact, false);
233             for (ChecksumAlgorithmFactory checksumAlgorithmFactory : checksumAlgorithmFactories) {
234                 Path summaryFile = summaryFile(
235                         basedir, originAware, artifactRepository.getId(), checksumAlgorithmFactory.getFileExtension());
236                 String checksum = requireNonNull(trustedArtifactChecksums.get(checksumAlgorithmFactory.getName()));
237 
238                 String oldChecksum = cache.computeIfAbsent(summaryFile, k -> loadProvidedChecksums(summaryFile))
239                         .put(artifactPath, checksum);
240 
241                 if (oldChecksum == null) {
242                     changedChecksums.put(summaryFile, Boolean.TRUE); // new
243                 } else if (!Objects.equals(oldChecksum, checksum)) {
244                     changedChecksums.put(summaryFile, Boolean.TRUE); // replaced
245                     LOGGER.info(
246                             "Trusted checksum for artifact {} replaced: old {}, new {}",
247                             artifact,
248                             oldChecksum,
249                             checksum);
250                 }
251             }
252         }
253     }
254 
255     /**
256      * On-close handler that saves recorded checksums, if any.
257      */
258     private void saveRecordedLines() {
259         if (changedChecksums.isEmpty()) {
260             return;
261         }
262 
263         ArrayList<Exception> exceptions = new ArrayList<>();
264         for (Map.Entry<Path, ConcurrentHashMap<String, String>> entry : checksums.entrySet()) {
265             Path summaryFile = entry.getKey();
266             if (changedChecksums.get(summaryFile) != Boolean.TRUE) {
267                 continue;
268             }
269             ConcurrentHashMap<String, String> recordedLines = entry.getValue();
270             if (!recordedLines.isEmpty()) {
271                 try {
272                     ConcurrentHashMap<String, String> result = new ConcurrentHashMap<>();
273                     result.putAll(loadProvidedChecksums(summaryFile));
274                     result.putAll(recordedLines);
275 
276                     LOGGER.info("Saving {} checksums to '{}'", result.size(), summaryFile);
277                     FileUtils.writeFileWithBackup(
278                             summaryFile,
279                             p -> Files.write(
280                                     p,
281                                     result.entrySet().stream()
282                                             .sorted(Map.Entry.comparingByValue())
283                                             .map(e -> e.getValue() + "  " + e.getKey())
284                                             .collect(toList())));
285                 } catch (IOException e) {
286                     exceptions.add(e);
287                 }
288             }
289         }
290         MultiRuntimeException.mayThrow("session save checksums failure", exceptions);
291     }
292 }