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.IOException;
026import java.io.UncheckedIOException;
027import java.nio.charset.StandardCharsets;
028import java.nio.file.Files;
029import java.nio.file.Path;
030import java.util.ArrayList;
031import java.util.List;
032import java.util.Map;
033import java.util.Set;
034import java.util.TreeSet;
035import java.util.concurrent.ConcurrentHashMap;
036import java.util.concurrent.atomic.AtomicBoolean;
037import java.util.stream.Collectors;
038import java.util.stream.Stream;
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.filter.ruletree.GroupTree;
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.io.PathProcessor;
050import org.eclipse.aether.spi.resolution.ArtifactResolverPostProcessor;
051import org.eclipse.aether.util.ConfigUtils;
052import org.eclipse.aether.util.repository.RepositoryIdHelper;
053import org.slf4j.Logger;
054import org.slf4j.LoggerFactory;
055
056import static java.util.Objects.requireNonNull;
057
058/**
059 * Remote repository filter source filtering on G coordinate. It is backed by a file that is parsed into {@link GroupTree}.
060 * <p>
061 * The file can be authored manually. 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 recorded as {@code =groupId}. The recorded file should be authored afterward to fine tune it, as there is
064 * no optimization in place (ie to look for smallest common parent groupId and alike).
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 * @see GroupTree
072 *
073 * @since 1.9.0
074 */
075@Singleton
076@Named(GroupIdRemoteRepositoryFilterSource.NAME)
077public final class GroupIdRemoteRepositoryFilterSource extends RemoteRepositoryFilterSourceSupport
078        implements ArtifactResolverPostProcessor {
079    public static final String NAME = "groupId";
080
081    private static final String CONFIG_PROPS_PREFIX =
082            RemoteRepositoryFilterSourceSupport.CONFIG_PROPS_PREFIX + NAME + ".";
083
084    /**
085     * Configuration to enable the GroupId filter (enabled by default). Can be fine-tuned per repository using
086     * repository ID suffixes.
087     * <strong>Important:</strong> For this filter to take effect, you must provide configuration files. Without
088     * configuration files, the enabled filter remains dormant and does not interfere with resolution.
089     * <strong>Configuration Files:</strong>
090     * <ul>
091     * <li>Location: Directory specified by {@link #CONFIG_PROP_BASEDIR} (defaults to {@code $LOCAL_REPO/.remoteRepositoryFilters})</li>
092     * <li>Naming: {@code groupId-$(repository.id).txt}</li>
093     * <li>Content: One groupId per line to allow/block from the repository</li>
094     * </ul>
095     * <strong>Recommended Setup (Per-Project):</strong>
096     * Use project-specific configuration to avoid repository ID clashes. Add to {@code .mvn/maven.config}:
097     * <pre>
098     * -Daether.remoteRepositoryFilter.groupId=true
099     * -Daether.remoteRepositoryFilter.groupId.basedir=${session.rootDirectory}/.mvn/rrf/
100     * </pre>
101     * Then create {@code groupId-myrepoId.txt} files in the {@code .mvn/rrf/} directory and commit them to version control.
102     *
103     * @configurationSource {@link RepositorySystemSession#getConfigProperties()}
104     * @configurationType {@link java.lang.Boolean}
105     * @configurationRepoIdSuffix Yes
106     * @configurationDefaultValue {@link #DEFAULT_ENABLED}
107     */
108    public static final String CONFIG_PROP_ENABLED = RemoteRepositoryFilterSourceSupport.CONFIG_PROPS_PREFIX + NAME;
109
110    public static final boolean DEFAULT_ENABLED = true;
111
112    /**
113     * The basedir where to store filter files. If path is relative, it is resolved from local repository root.
114     *
115     * @configurationSource {@link RepositorySystemSession#getConfigProperties()}
116     * @configurationType {@link java.lang.String}
117     * @configurationDefaultValue {@link #LOCAL_REPO_PREFIX_DIR}
118     */
119    public static final String CONFIG_PROP_BASEDIR = CONFIG_PROPS_PREFIX + "basedir";
120
121    public static final String LOCAL_REPO_PREFIX_DIR = ".remoteRepositoryFilters";
122
123    /**
124     * Should filter go into "record" mode (and collect encountered artifacts)?
125     *
126     * @configurationSource {@link RepositorySystemSession#getConfigProperties()}
127     * @configurationType {@link java.lang.Boolean}
128     * @configurationDefaultValue false
129     */
130    public static final String CONFIG_PROP_RECORD = CONFIG_PROPS_PREFIX + "record";
131
132    static final String GROUP_ID_FILE_PREFIX = "groupId-";
133
134    static final String GROUP_ID_FILE_SUFFIX = ".txt";
135
136    private final Logger logger = LoggerFactory.getLogger(GroupIdRemoteRepositoryFilterSource.class);
137
138    private final RepositorySystemLifecycle repositorySystemLifecycle;
139
140    private final PathProcessor pathProcessor;
141
142    private final ConcurrentHashMap<RemoteRepository, GroupTree> rules;
143
144    private final ConcurrentHashMap<RemoteRepository, Path> ruleFiles;
145
146    private final ConcurrentHashMap<RemoteRepository, Set<String>> recordedRules;
147
148    private final AtomicBoolean onShutdownHandlerRegistered;
149
150    @Inject
151    public GroupIdRemoteRepositoryFilterSource(
152            RepositorySystemLifecycle repositorySystemLifecycle, PathProcessor pathProcessor) {
153        this.repositorySystemLifecycle = requireNonNull(repositorySystemLifecycle);
154        this.pathProcessor = requireNonNull(pathProcessor);
155        this.rules = new ConcurrentHashMap<>();
156        this.ruleFiles = new ConcurrentHashMap<>();
157        this.recordedRules = new ConcurrentHashMap<>();
158        this.onShutdownHandlerRegistered = new AtomicBoolean(false);
159    }
160
161    @Override
162    protected boolean isEnabled(RepositorySystemSession session) {
163        return ConfigUtils.getBoolean(session, DEFAULT_ENABLED, CONFIG_PROP_ENABLED);
164    }
165
166    private boolean isRepositoryFilteringEnabled(RepositorySystemSession session, RemoteRepository remoteRepository) {
167        if (isEnabled(session)) {
168            return ConfigUtils.getBoolean(
169                    session,
170                    ConfigUtils.getBoolean(session, true, CONFIG_PROP_ENABLED + ".*"),
171                    CONFIG_PROP_ENABLED + "." + remoteRepository.getId());
172        }
173        return false;
174    }
175
176    @Override
177    public RemoteRepositoryFilter getRemoteRepositoryFilter(RepositorySystemSession session) {
178        if (isEnabled(session) && !isRecord(session)) {
179            return new GroupIdFilter(session);
180        }
181        return null;
182    }
183
184    @Override
185    public void postProcess(RepositorySystemSession session, List<ArtifactResult> artifactResults) {
186        if (isEnabled(session) && isRecord(session)) {
187            if (onShutdownHandlerRegistered.compareAndSet(false, true)) {
188                repositorySystemLifecycle.addOnSystemEndedHandler(this::saveRecordedLines);
189            }
190            for (ArtifactResult artifactResult : artifactResults) {
191                if (artifactResult.isResolved() && artifactResult.getRepository() instanceof RemoteRepository) {
192                    RemoteRepository remoteRepository = (RemoteRepository) artifactResult.getRepository();
193                    if (isRepositoryFilteringEnabled(session, remoteRepository)) {
194                        ruleFile(session, remoteRepository); // populate it; needed for save
195                        String line = "=" + artifactResult.getArtifact().getGroupId();
196                        recordedRules
197                                .computeIfAbsent(remoteRepository, k -> new TreeSet<>())
198                                .add(line);
199                        rules.compute(remoteRepository, (k, v) -> {
200                                    if (v == null || v == GroupTree.SENTINEL) {
201                                        v = new GroupTree("");
202                                    }
203                                    return v;
204                                })
205                                .loadNode(line);
206                    }
207                }
208            }
209        }
210    }
211
212    private Path ruleFile(RepositorySystemSession session, RemoteRepository remoteRepository) {
213        return ruleFiles.computeIfAbsent(remoteRepository, r -> getBasedir(
214                        session, LOCAL_REPO_PREFIX_DIR, CONFIG_PROP_BASEDIR, false)
215                .resolve(GROUP_ID_FILE_PREFIX
216                        + RepositoryIdHelper.cachedIdToPathSegment(session).apply(remoteRepository)
217                        + GROUP_ID_FILE_SUFFIX));
218    }
219
220    private GroupTree cacheRules(RepositorySystemSession session, RemoteRepository remoteRepository) {
221        return rules.computeIfAbsent(remoteRepository, r -> loadRepositoryRules(session, r));
222    }
223
224    private GroupTree loadRepositoryRules(RepositorySystemSession session, RemoteRepository remoteRepository) {
225        if (isRepositoryFilteringEnabled(session, remoteRepository)) {
226            Path filePath = ruleFile(session, remoteRepository);
227            if (Files.isReadable(filePath)) {
228                try (Stream<String> lines = Files.lines(filePath, StandardCharsets.UTF_8)) {
229                    GroupTree groupTree = new GroupTree("");
230                    int rules = groupTree.loadNodes(lines);
231                    logger.info("Loaded {} group rules for remote repository {}", rules, remoteRepository.getId());
232                    if (logger.isDebugEnabled()) {
233                        groupTree.dump("");
234                    }
235                    return groupTree;
236                } catch (IOException e) {
237                    throw new UncheckedIOException(e);
238                }
239            }
240            logger.debug("Group rules file for remote repository {} not available", remoteRepository);
241            return GroupTree.SENTINEL;
242        }
243        logger.debug("Group rules file for remote repository {} disabled", remoteRepository);
244        return GroupTree.SENTINEL;
245    }
246
247    private class GroupIdFilter implements RemoteRepositoryFilter {
248        private final RepositorySystemSession session;
249
250        private GroupIdFilter(RepositorySystemSession session) {
251            this.session = session;
252        }
253
254        @Override
255        public Result acceptArtifact(RemoteRepository remoteRepository, Artifact artifact) {
256            return acceptGroupId(remoteRepository, artifact.getGroupId());
257        }
258
259        @Override
260        public Result acceptMetadata(RemoteRepository remoteRepository, Metadata metadata) {
261            return acceptGroupId(remoteRepository, metadata.getGroupId());
262        }
263
264        private Result acceptGroupId(RemoteRepository remoteRepository, String groupId) {
265            GroupTree groupTree = cacheRules(session, remoteRepository);
266            if (GroupTree.SENTINEL == groupTree) {
267                return NOT_PRESENT_RESULT;
268            }
269
270            if (groupTree.acceptedGroupId(groupId)) {
271                return new SimpleResult(true, "G:" + groupId + " allowed from " + remoteRepository);
272            } else {
273                return new SimpleResult(false, "G:" + groupId + " NOT allowed from " + remoteRepository);
274            }
275        }
276    }
277
278    /**
279     * Filter result when filter "stands aside" as it had no input.
280     */
281    private static final RemoteRepositoryFilter.Result NOT_PRESENT_RESULT =
282            new SimpleResult(true, "GroupId file not present");
283
284    /**
285     * Returns {@code true} if given session is recording.
286     */
287    private boolean isRecord(RepositorySystemSession session) {
288        return ConfigUtils.getBoolean(session, false, CONFIG_PROP_RECORD);
289    }
290
291    /**
292     * On-close handler that saves recorded rules, if any.
293     */
294    private void saveRecordedLines() {
295        ArrayList<Exception> exceptions = new ArrayList<>();
296        for (Map.Entry<RemoteRepository, Path> entry : ruleFiles.entrySet()) {
297            Set<String> recorded = recordedRules.get(entry.getKey());
298            if (recorded != null && !recorded.isEmpty()) {
299                try {
300                    ArrayList<String> result = new ArrayList<>();
301                    if (Files.isReadable(entry.getValue())) {
302                        result.addAll(Files.readAllLines(entry.getValue()));
303                    }
304                    result.add("# Recorded entries");
305                    result.addAll(recorded);
306                    logger.info("Saving {} groupIds to '{}'", result.size(), entry.getValue());
307                    pathProcessor.writeWithBackup(
308                            entry.getValue(), result.stream().collect(Collectors.joining(System.lineSeparator())));
309                } catch (IOException e) {
310                    exceptions.add(e);
311                }
312            }
313        }
314        MultiRuntimeException.mayThrow("session save groupIds failure", exceptions);
315    }
316}