001package org.eclipse.aether.internal.impl.filter;
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.Collections;
034import java.util.List;
035import java.util.Map;
036import java.util.Set;
037import java.util.TreeSet;
038import java.util.concurrent.ConcurrentHashMap;
039import java.util.concurrent.atomic.AtomicBoolean;
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.metadata.Metadata;
046import org.eclipse.aether.repository.RemoteRepository;
047import org.eclipse.aether.resolution.ArtifactResult;
048import org.eclipse.aether.spi.connector.filter.RemoteRepositoryFilter;
049import org.eclipse.aether.spi.resolution.ArtifactResolverPostProcessor;
050import org.eclipse.aether.util.ConfigUtils;
051import org.eclipse.aether.util.FileUtils;
052import org.slf4j.Logger;
053import org.slf4j.LoggerFactory;
054
055import static java.util.Objects.requireNonNull;
056
057/**
058 * Remote repository filter source filtering on G coordinate. It is backed by a file that lists all allowed groupIds
059 * and groupId not present in this file are filtered out.
060 * <p>
061 * The file can be authored manually: format is one groupId per line, comments starting with "#" (hash) amd empty lines
062 * for structuring are supported. The file can also be pre-populated by "record" functionality of this filter.
063 * When "recording", this filter will not filter out anything, but will instead populate the file with all encountered
064 * groupIds.
065 * <p>
066 * The groupId file is expected on path "${basedir}/groupId-${repository.id}.txt".
067 * <p>
068 * The groupId file once loaded are cached in component, so in-flight groupId file change during component existence
069 * are NOT noticed.
070 *
071 * @since 1.9.0
072 */
073@Singleton
074@Named( GroupIdRemoteRepositoryFilterSource.NAME )
075public final class GroupIdRemoteRepositoryFilterSource
076        extends RemoteRepositoryFilterSourceSupport
077        implements ArtifactResolverPostProcessor
078{
079    public static final String NAME = "groupId";
080
081    private static final String CONF_NAME_RECORD = "record";
082
083    static final String GROUP_ID_FILE_PREFIX = "groupId-";
084
085    static final String GROUP_ID_FILE_SUFFIX = ".txt";
086
087    private static final Logger LOGGER = LoggerFactory.getLogger( GroupIdRemoteRepositoryFilterSource.class );
088
089    private final RepositorySystemLifecycle repositorySystemLifecycle;
090
091    private final ConcurrentHashMap<Path, Set<String>> rules;
092
093    private final ConcurrentHashMap<Path, Boolean> changedRules;
094
095    private final AtomicBoolean onShutdownHandlerRegistered;
096
097    @Inject
098    public GroupIdRemoteRepositoryFilterSource( RepositorySystemLifecycle repositorySystemLifecycle )
099    {
100        super( NAME );
101        this.repositorySystemLifecycle = requireNonNull( repositorySystemLifecycle );
102        this.rules = new ConcurrentHashMap<>();
103        this.changedRules = new ConcurrentHashMap<>();
104        this.onShutdownHandlerRegistered = new AtomicBoolean( false );
105    }
106
107    @Override
108    public RemoteRepositoryFilter getRemoteRepositoryFilter( RepositorySystemSession session )
109    {
110        if ( isEnabled( session ) && !isRecord( session ) )
111        {
112            return new GroupIdFilter( session );
113        }
114        return null;
115    }
116
117    @Override
118    public void postProcess( RepositorySystemSession session, List<ArtifactResult> artifactResults )
119    {
120        if ( isEnabled( session ) && isRecord( session ) )
121        {
122            if ( onShutdownHandlerRegistered.compareAndSet( false, true ) )
123            {
124                repositorySystemLifecycle.addOnSystemEndedHandler( this::saveRecordedLines );
125            }
126            for ( ArtifactResult artifactResult : artifactResults )
127            {
128                if ( artifactResult.isResolved() && artifactResult.getRepository() instanceof RemoteRepository )
129                {
130                    Path filePath = filePath( getBasedir( session, false ),
131                            artifactResult.getRepository().getId() );
132                    boolean newGroupId =
133                            rules.computeIfAbsent( filePath, f -> Collections.synchronizedSet( new TreeSet<>() ) )
134                                    .add( artifactResult.getArtifact().getGroupId() );
135                    if ( newGroupId )
136                    {
137                        changedRules.put( filePath, Boolean.TRUE );
138                    }
139                }
140            }
141        }
142    }
143
144    /**
145     * Returns the groupId path. The file and parents may not exist, this method merely calculate the path.
146     */
147    private Path filePath( Path basedir, String remoteRepositoryId )
148    {
149        return basedir.resolve(
150                GROUP_ID_FILE_PREFIX + remoteRepositoryId + GROUP_ID_FILE_SUFFIX );
151    }
152
153    private Set<String> cacheRules( RepositorySystemSession session,
154                                    RemoteRepository remoteRepository )
155    {
156        Path filePath = filePath( getBasedir( session, false ), remoteRepository.getId() );
157        return rules.computeIfAbsent( filePath, r ->
158                {
159                    Set<String> rules = loadRepositoryRules( filePath );
160                    if ( rules != NOT_PRESENT )
161                    {
162                        LOGGER.info( "Loaded {} groupId for remote repository {}", rules.size(),
163                                remoteRepository.getId() );
164                    }
165                    return rules;
166                }
167        );
168    }
169
170    private Set<String> loadRepositoryRules( Path filePath )
171    {
172        if ( Files.isReadable( filePath ) )
173        {
174            try ( BufferedReader reader = Files.newBufferedReader( filePath, StandardCharsets.UTF_8 ) )
175            {
176                TreeSet<String> result = new TreeSet<>();
177                String groupId;
178                while ( ( groupId = reader.readLine() ) != null )
179                {
180                    if ( !groupId.startsWith( "#" ) && !groupId.trim().isEmpty() )
181                    {
182                        result.add( groupId );
183                    }
184                }
185                return Collections.unmodifiableSet( result );
186            }
187            catch ( IOException e )
188            {
189                throw new UncheckedIOException( e );
190            }
191        }
192        return NOT_PRESENT;
193    }
194
195    private static final TreeSet<String> NOT_PRESENT = new TreeSet<>();
196
197    private class GroupIdFilter implements RemoteRepositoryFilter
198    {
199        private final RepositorySystemSession session;
200
201        private GroupIdFilter( RepositorySystemSession session )
202        {
203            this.session = session;
204        }
205
206        @Override
207        public Result acceptArtifact( RemoteRepository remoteRepository, Artifact artifact )
208        {
209            return acceptGroupId( remoteRepository, artifact.getGroupId() );
210        }
211
212        @Override
213        public Result acceptMetadata( RemoteRepository remoteRepository, Metadata metadata )
214        {
215            return acceptGroupId( remoteRepository, metadata.getGroupId() );
216        }
217
218        private Result acceptGroupId( RemoteRepository remoteRepository, String groupId )
219        {
220            Set<String> groupIds = cacheRules( session, remoteRepository );
221            if ( NOT_PRESENT == groupIds )
222            {
223                return NOT_PRESENT_RESULT;
224            }
225
226            if ( groupIds.contains( groupId ) )
227            {
228                return new SimpleResult( true,
229                        "G:" + groupId + " allowed from " + remoteRepository );
230            }
231            else
232            {
233                return new SimpleResult( false,
234                        "G:" + groupId + " NOT allowed from " + remoteRepository );
235            }
236        }
237    }
238
239    private static final RemoteRepositoryFilter.Result NOT_PRESENT_RESULT = new SimpleResult(
240            true, "GroupId file not present" );
241
242    /**
243     * Returns {@code true} if given session is recording.
244     */
245    private boolean isRecord( RepositorySystemSession session )
246    {
247        return ConfigUtils.getBoolean( session, false, configPropKey( CONF_NAME_RECORD ) );
248    }
249
250    /**
251     * On-close handler that saves recorded rules, if any.
252     */
253    private void saveRecordedLines()
254    {
255        if ( changedRules.isEmpty() )
256        {
257            return;
258        }
259
260        ArrayList<Exception> exceptions = new ArrayList<>();
261        for ( Map.Entry<Path, Set<String>> entry : rules.entrySet() )
262        {
263            Path filePath = entry.getKey();
264            if ( changedRules.get( filePath ) != Boolean.TRUE )
265            {
266                continue;
267            }
268            Set<String> recordedLines = entry.getValue();
269            if ( !recordedLines.isEmpty() )
270            {
271                try
272                {
273                    TreeSet<String> result = new TreeSet<>();
274                    result.addAll( loadRepositoryRules( filePath ) );
275                    result.addAll( recordedLines );
276
277                    LOGGER.info( "Saving {} groupIds to '{}'", result.size(), filePath );
278                    FileUtils.writeFileWithBackup( filePath, p -> Files.write( p, result ) );
279                }
280                catch ( IOException e )
281                {
282                    exceptions.add( e );
283                }
284            }
285        }
286        MultiRuntimeException.mayThrow( "session save groupIds failure", exceptions );
287    }
288}