001package org.eclipse.aether.internal.impl.checksum;
002
003/*
004 * Licensed to the Apache Software Foundation (ASF) under one
005 * or more contributor license agreements.  See the NOTICE file
006 * distributed with this work for additional information
007 * regarding copyright ownership.  The ASF licenses this file
008 * to you under the Apache License, Version 2.0 (the
009 * "License"); you may not use this file except in compliance
010 * with the License.  You may obtain a copy of the License at
011 *
012 *  http://www.apache.org/licenses/LICENSE-2.0
013 *
014 * Unless required by applicable law or agreed to in writing,
015 * software distributed under the License is distributed on an
016 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
017 * KIND, either express or implied.  See the License for the
018 * specific language governing permissions and limitations
019 * under the License.
020 */
021
022import javax.inject.Inject;
023import javax.inject.Named;
024import javax.inject.Singleton;
025
026import java.io.BufferedReader;
027import java.io.IOException;
028import java.io.UncheckedIOException;
029import java.nio.charset.StandardCharsets;
030import java.nio.file.Files;
031import java.nio.file.Path;
032import java.util.ArrayList;
033import java.util.HashMap;
034import java.util.List;
035import java.util.Map;
036import java.util.Objects;
037import java.util.concurrent.ConcurrentHashMap;
038import java.util.concurrent.atomic.AtomicBoolean;
039
040import org.eclipse.aether.MultiRuntimeException;
041import org.eclipse.aether.RepositorySystemSession;
042import org.eclipse.aether.artifact.Artifact;
043import org.eclipse.aether.impl.RepositorySystemLifecycle;
044import org.eclipse.aether.internal.impl.LocalPathComposer;
045import org.eclipse.aether.repository.ArtifactRepository;
046import org.eclipse.aether.spi.connector.checksum.ChecksumAlgorithmFactory;
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
091        extends FileTrustedChecksumsSourceSupport
092{
093    public static final String NAME = "summaryFile";
094
095    private static final String CHECKSUMS_FILE_PREFIX = "checksums";
096
097    private static final Logger LOGGER = LoggerFactory.getLogger( SummaryFileTrustedChecksumsSource.class );
098
099    private final LocalPathComposer localPathComposer;
100
101    private final RepositorySystemLifecycle repositorySystemLifecycle;
102
103    private final ConcurrentHashMap<Path, ConcurrentHashMap<String, String>> checksums;
104
105    private final ConcurrentHashMap<Path, Boolean> changedChecksums;
106
107    private final AtomicBoolean onShutdownHandlerRegistered;
108
109
110    @Inject
111    public SummaryFileTrustedChecksumsSource( LocalPathComposer localPathComposer,
112                                              RepositorySystemLifecycle repositorySystemLifecycle )
113    {
114        super( NAME );
115        this.localPathComposer = requireNonNull( localPathComposer );
116        this.repositorySystemLifecycle = requireNonNull( repositorySystemLifecycle );
117        this.checksums = new ConcurrentHashMap<>();
118        this.changedChecksums = new ConcurrentHashMap<>();
119        this.onShutdownHandlerRegistered = new AtomicBoolean( false );
120    }
121
122    @Override
123    protected Map<String, String> doGetTrustedArtifactChecksums(
124            RepositorySystemSession session, Artifact artifact, ArtifactRepository artifactRepository,
125            List<ChecksumAlgorithmFactory> checksumAlgorithmFactories )
126    {
127        final HashMap<String, String> result = new HashMap<>();
128        final Path basedir = getBasedir( session, false );
129        if ( Files.isDirectory( basedir ) )
130        {
131            final String artifactPath = localPathComposer.getPathForArtifact( artifact, false );
132            final boolean originAware = isOriginAware( session );
133            for ( ChecksumAlgorithmFactory checksumAlgorithmFactory : checksumAlgorithmFactories )
134            {
135                Path summaryFile = summaryFile( basedir, originAware, artifactRepository.getId(),
136                        checksumAlgorithmFactory.getFileExtension() );
137                ConcurrentHashMap<String, String> algorithmChecksums = checksums.computeIfAbsent( summaryFile, f ->
138                        {
139                            ConcurrentHashMap<String, String> loaded = loadProvidedChecksums( summaryFile );
140                            if ( Files.isRegularFile( summaryFile ) )
141                            {
142                                LOGGER.info( "Loaded {} {} trusted checksums for remote repository {}",
143                                        loaded.size(), checksumAlgorithmFactory.getName(), artifactRepository.getId() );
144                            }
145                            return loaded;
146                        }
147                );
148                String checksum = algorithmChecksums.get( artifactPath );
149                if ( checksum != null )
150                {
151                    result.put( checksumAlgorithmFactory.getName(), checksum );
152                }
153            }
154        }
155        return result;
156    }
157
158    @Override
159    protected SummaryFileWriter doGetTrustedArtifactChecksumsWriter( RepositorySystemSession session )
160    {
161        if ( onShutdownHandlerRegistered.compareAndSet( false, true ) )
162        {
163            repositorySystemLifecycle.addOnSystemEndedHandler( this::saveRecordedLines );
164        }
165        return new SummaryFileWriter( checksums, getBasedir( session, true ), isOriginAware( session ) );
166    }
167
168    /**
169     * Returns the summary file path. The file itself and its parent directories may not exist, this method merely
170     * calculate the path.
171     */
172    private Path summaryFile( Path basedir, boolean originAware, String repositoryId, String checksumExtension )
173    {
174        String fileName = CHECKSUMS_FILE_PREFIX;
175        if ( originAware )
176        {
177            fileName += "-" + repositoryId;
178        }
179        return basedir.resolve( fileName + "." + checksumExtension );
180    }
181
182    private ConcurrentHashMap<String, String> loadProvidedChecksums( Path summaryFile )
183    {
184        ConcurrentHashMap<String, String> result = new ConcurrentHashMap<>();
185        if ( Files.isRegularFile( summaryFile ) )
186        {
187            try ( BufferedReader reader = Files.newBufferedReader( summaryFile, StandardCharsets.UTF_8 ) )
188            {
189                String line;
190                while ( ( line = reader.readLine() ) != null )
191                {
192                    if ( !line.startsWith( "#" ) && !line.isEmpty() )
193                    {
194                        String[] parts = line.split( "  ", 2 );
195                        if ( parts.length == 2 )
196                        {
197                            String newChecksum = parts[0];
198                            String artifactPath = parts[1];
199                            String oldChecksum = result.put( artifactPath, newChecksum );
200                            if ( oldChecksum != null )
201                            {
202                                if ( Objects.equals( oldChecksum, newChecksum ) )
203                                {
204                                    LOGGER.warn(
205                                            "Checksums file '{}' contains duplicate checksums for artifact {}: {}",
206                                            summaryFile, artifactPath, oldChecksum );
207                                }
208                                else
209                                {
210                                    LOGGER.warn(
211                                            "Checksums file '{}' contains different checksums for artifact {}: "
212                                                    + "old '{}' replaced by new '{}'", summaryFile, artifactPath,
213                                            oldChecksum, newChecksum );
214                                }
215                            }
216                        }
217                        else
218                        {
219                            LOGGER.warn( "Checksums file '{}' ignored malformed line '{}'", summaryFile, line );
220                        }
221                    }
222                }
223            }
224            catch ( IOException e )
225            {
226                throw new UncheckedIOException( e );
227            }
228        }
229        return result;
230    }
231
232    private class SummaryFileWriter implements Writer
233    {
234        private final ConcurrentHashMap<Path, ConcurrentHashMap<String, String>> cache;
235
236        private final Path basedir;
237
238        private final boolean originAware;
239
240        private SummaryFileWriter( ConcurrentHashMap<Path, ConcurrentHashMap<String, String>> cache,
241                                   Path basedir,
242                                   boolean originAware )
243        {
244            this.cache = cache;
245            this.basedir = basedir;
246            this.originAware = originAware;
247        }
248
249        @Override
250        public void addTrustedArtifactChecksums( Artifact artifact,
251                                                 ArtifactRepository artifactRepository,
252                                                 List<ChecksumAlgorithmFactory> checksumAlgorithmFactories,
253                                                 Map<String, String> trustedArtifactChecksums )
254        {
255            String artifactPath = localPathComposer.getPathForArtifact( artifact, false );
256            for ( ChecksumAlgorithmFactory checksumAlgorithmFactory : checksumAlgorithmFactories )
257            {
258                Path summaryFile = summaryFile( basedir, originAware, artifactRepository.getId(),
259                        checksumAlgorithmFactory.getFileExtension() );
260                String checksum = requireNonNull(
261                        trustedArtifactChecksums.get( checksumAlgorithmFactory.getName() ) );
262
263                String oldChecksum = cache.computeIfAbsent( summaryFile, k -> loadProvidedChecksums( summaryFile ) )
264                        .put( artifactPath, checksum );
265
266                if ( oldChecksum == null )
267                {
268                    changedChecksums.put( summaryFile, Boolean.TRUE ); // new
269                }
270                else if ( !Objects.equals( oldChecksum, checksum ) )
271                {
272                    changedChecksums.put( summaryFile, Boolean.TRUE ); // replaced
273                    LOGGER.info( "Trusted checksum for artifact {} replaced: old {}, new {}",
274                            artifact, oldChecksum, checksum );
275                }
276            }
277        }
278    }
279
280    /**
281     * On-close handler that saves recorded checksums, if any.
282     */
283    private void saveRecordedLines()
284    {
285        if ( changedChecksums.isEmpty() )
286        {
287            return;
288        }
289
290        ArrayList<Exception> exceptions = new ArrayList<>();
291        for ( Map.Entry<Path, ConcurrentHashMap<String, String>> entry : checksums.entrySet() )
292        {
293            Path summaryFile = entry.getKey();
294            if ( changedChecksums.get( summaryFile ) != Boolean.TRUE )
295            {
296                continue;
297            }
298            ConcurrentHashMap<String, String> recordedLines = entry.getValue();
299            if ( !recordedLines.isEmpty() )
300            {
301                try
302                {
303                    ConcurrentHashMap<String, String> result = new ConcurrentHashMap<>();
304                    result.putAll( loadProvidedChecksums( summaryFile ) );
305                    result.putAll( recordedLines );
306
307                    LOGGER.info( "Saving {} checksums to '{}'", result.size(), summaryFile );
308                    FileUtils.writeFileWithBackup(
309                            summaryFile,
310                            p -> Files.write( p,
311                                    result.entrySet().stream()
312                                            .sorted( Map.Entry.comparingByValue() )
313                                            .map( e -> e.getValue() + "  " + e.getKey() )
314                                            .collect( toList() )
315                            )
316                    );
317                }
318                catch ( IOException e )
319                {
320                    exceptions.add( e );
321                }
322            }
323        }
324        MultiRuntimeException.mayThrow( "session save checksums failure", exceptions );
325    }
326}