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