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.ConcurrentMap;
037import java.util.concurrent.atomic.AtomicBoolean;
038import java.util.stream.Collectors;
039import java.util.stream.Stream;
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.internal.impl.filter.ruletree.GroupTree;
046import org.eclipse.aether.metadata.Metadata;
047import org.eclipse.aether.repository.RemoteRepository;
048import org.eclipse.aether.resolution.ArtifactResult;
049import org.eclipse.aether.spi.connector.filter.RemoteRepositoryFilter;
050import org.eclipse.aether.spi.io.PathProcessor;
051import org.eclipse.aether.spi.remoterepo.RepositoryKeyFunctionFactory;
052import org.eclipse.aether.spi.resolution.ArtifactResolverPostProcessor;
053import org.eclipse.aether.util.ConfigUtils;
054import org.slf4j.Logger;
055import org.slf4j.LoggerFactory;
056
057import static java.util.Objects.requireNonNull;
058
059/**
060 * Remote repository filter source filtering on G coordinate. It is backed by a file that is parsed into {@link GroupTree}.
061 * <p>
062 * The file can be authored manually. 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 recorded as {@code =groupId}. The recorded file should be authored afterward to fine tune it, as there is
065 * no optimization in place (ie to look for smallest common parent groupId and alike).
066 * <p>
067 * The groupId file is expected on path "${basedir}/groupId-${repository.id}.txt".
068 * <p>
069 * The groupId file once loaded are cached in component, so in-flight groupId file change during component existence
070 * are NOT noticed.
071 *
072 * @see GroupTree
073 *
074 * @since 1.9.0
075 */
076@Singleton
077@Named(GroupIdRemoteRepositoryFilterSource.NAME)
078public final class GroupIdRemoteRepositoryFilterSource extends RemoteRepositoryFilterSourceSupport
079        implements ArtifactResolverPostProcessor {
080    public static final String NAME = "groupId";
081
082    /**
083     * Configuration to enable the GroupId filter (enabled by default). Can be fine-tuned per repository using
084     * repository ID suffixes.
085     * <strong>Important:</strong> For this filter to take effect, you must provide configuration files. Without
086     * configuration files, the enabled filter remains dormant and does not interfere with resolution.
087     * <strong>Configuration Files:</strong>
088     * <ul>
089     * <li>Location: Directory specified by {@link #CONFIG_PROP_BASEDIR} (defaults to {@code $LOCAL_REPO/.remoteRepositoryFilters})</li>
090     * <li>Naming: {@code groupId-$(repository.id).txt}</li>
091     * <li>Content: One groupId per line to allow/block from the repository</li>
092     * </ul>
093     * <strong>Recommended Setup (Per-Project):</strong>
094     * Use project-specific configuration to avoid repository ID clashes. Add to {@code .mvn/maven.config}:
095     * <pre>
096     * -Daether.remoteRepositoryFilter.groupId=true
097     * -Daether.remoteRepositoryFilter.groupId.basedir=${session.rootDirectory}/.mvn/rrf/
098     * </pre>
099     * Then create {@code groupId-myrepoId.txt} files in the {@code .mvn/rrf/} directory and commit them to version control.
100     *
101     * @configurationSource {@link RepositorySystemSession#getConfigProperties()}
102     * @configurationType {@link java.lang.Boolean}
103     * @configurationRepoIdSuffix Yes
104     * @configurationDefaultValue {@link #DEFAULT_ENABLED}
105     */
106    public static final String CONFIG_PROP_ENABLED = RemoteRepositoryFilterSourceSupport.CONFIG_PROPS_PREFIX + NAME;
107
108    public static final boolean DEFAULT_ENABLED = true;
109
110    /**
111     * Configuration to skip the GroupId filter for given request. This configuration is evaluated and if {@code true}
112     * the GroupId remote filter will not kick in.
113     *
114     * @since 2.0.14
115     * @configurationSource {@link RepositorySystemSession#getConfigProperties()}
116     * @configurationType {@link java.lang.Boolean}
117     * @configurationRepoIdSuffix Yes
118     * @configurationDefaultValue {@link #DEFAULT_SKIPPED}
119     */
120    public static final String CONFIG_PROP_SKIPPED =
121            RemoteRepositoryFilterSourceSupport.CONFIG_PROPS_PREFIX + NAME + ".skipped";
122
123    public static final boolean DEFAULT_SKIPPED = false;
124
125    /**
126     * Determines what happens when the filter is enabled, but has no groupId file available for given remote repository
127     * to work with. When set to {@code true} (default), the filter allows all requests to proceed for given remote
128     * repository when no groupId file is available. When set to {@code false}, the filter blocks all requests toward
129     * given remote repository when no groupId file is available. This setting allows repoId suffix, hence, can
130     * determine "global" or "repository targeted" behaviors.
131     *
132     * @since 2.0.14
133     * @configurationSource {@link RepositorySystemSession#getConfigProperties()}
134     * @configurationType {@link java.lang.Boolean}
135     * @configurationRepoIdSuffix Yes
136     * @configurationDefaultValue {@link #DEFAULT_NO_INPUT_OUTCOME}
137     */
138    public static final String CONFIG_PROP_NO_INPUT_OUTCOME =
139            RemoteRepositoryFilterSourceSupport.CONFIG_PROPS_PREFIX + NAME + ".noInputOutcome";
140
141    public static final boolean DEFAULT_NO_INPUT_OUTCOME = true;
142
143    /**
144     * The basedir where to store filter files. If path is relative, it is resolved from local repository root.
145     *
146     * @configurationSource {@link RepositorySystemSession#getConfigProperties()}
147     * @configurationType {@link java.lang.String}
148     * @configurationDefaultValue {@link #LOCAL_REPO_PREFIX_DIR}
149     */
150    public static final String CONFIG_PROP_BASEDIR =
151            RemoteRepositoryFilterSourceSupport.CONFIG_PROPS_PREFIX + NAME + ".basedir";
152
153    public static final String LOCAL_REPO_PREFIX_DIR = ".remoteRepositoryFilters";
154
155    /**
156     * Should filter go into "record" mode (and collect encountered artifacts)?
157     *
158     * @configurationSource {@link RepositorySystemSession#getConfigProperties()}
159     * @configurationType {@link java.lang.Boolean}
160     * @configurationDefaultValue false
161     */
162    public static final String CONFIG_PROP_RECORD =
163            RemoteRepositoryFilterSourceSupport.CONFIG_PROPS_PREFIX + NAME + ".record";
164
165    static final String GROUP_ID_FILE_PREFIX = "groupId-";
166
167    static final String GROUP_ID_FILE_SUFFIX = ".txt";
168
169    private final Logger logger = LoggerFactory.getLogger(GroupIdRemoteRepositoryFilterSource.class);
170
171    private final RepositorySystemLifecycle repositorySystemLifecycle;
172
173    private final PathProcessor pathProcessor;
174
175    @Inject
176    public GroupIdRemoteRepositoryFilterSource(
177            RepositoryKeyFunctionFactory repositoryKeyFunctionFactory,
178            RepositorySystemLifecycle repositorySystemLifecycle,
179            PathProcessor pathProcessor) {
180        super(repositoryKeyFunctionFactory);
181        this.repositorySystemLifecycle = requireNonNull(repositorySystemLifecycle);
182        this.pathProcessor = requireNonNull(pathProcessor);
183    }
184
185    @SuppressWarnings("unchecked")
186    private ConcurrentMap<RemoteRepository, GroupTree> rules(RepositorySystemSession session) {
187        return (ConcurrentMap<RemoteRepository, GroupTree>)
188                session.getData().computeIfAbsent(getClass().getName() + ".rules", ConcurrentHashMap::new);
189    }
190
191    @SuppressWarnings("unchecked")
192    private ConcurrentMap<RemoteRepository, Path> ruleFiles(RepositorySystemSession session) {
193        return (ConcurrentMap<RemoteRepository, Path>)
194                session.getData().computeIfAbsent(getClass().getName() + ".ruleFiles", ConcurrentHashMap::new);
195    }
196
197    @SuppressWarnings("unchecked")
198    private ConcurrentMap<RemoteRepository, Set<String>> recordedRules(RepositorySystemSession session) {
199        return (ConcurrentMap<RemoteRepository, Set<String>>)
200                session.getData().computeIfAbsent(getClass().getName() + ".recordedRules", ConcurrentHashMap::new);
201    }
202
203    private AtomicBoolean onShutdownHandlerRegistered(RepositorySystemSession session) {
204        return (AtomicBoolean) session.getData()
205                .computeIfAbsent(getClass().getName() + ".onShutdownHandlerRegistered", AtomicBoolean::new);
206    }
207
208    @Override
209    protected boolean isEnabled(RepositorySystemSession session) {
210        return ConfigUtils.getBoolean(session, DEFAULT_ENABLED, CONFIG_PROP_ENABLED)
211                && !ConfigUtils.getBoolean(session, DEFAULT_SKIPPED, CONFIG_PROP_SKIPPED);
212    }
213
214    private boolean isRepositoryFilteringEnabled(RepositorySystemSession session, RemoteRepository remoteRepository) {
215        if (isEnabled(session)) {
216            return ConfigUtils.getBoolean(
217                            session,
218                            DEFAULT_ENABLED,
219                            CONFIG_PROP_ENABLED + "." + remoteRepository.getId(),
220                            CONFIG_PROP_ENABLED + ".*")
221                    && !ConfigUtils.getBoolean(
222                            session,
223                            DEFAULT_SKIPPED,
224                            CONFIG_PROP_SKIPPED + "." + remoteRepository.getId(),
225                            CONFIG_PROP_SKIPPED + ".*");
226        }
227        return false;
228    }
229
230    @Override
231    public RemoteRepositoryFilter getRemoteRepositoryFilter(RepositorySystemSession session) {
232        if (isEnabled(session) && !isRecord(session)) {
233            return new GroupIdFilter(session);
234        }
235        return null;
236    }
237
238    @Override
239    public void postProcess(RepositorySystemSession session, List<ArtifactResult> artifactResults) {
240        if (isEnabled(session) && isRecord(session)) {
241            if (onShutdownHandlerRegistered(session).compareAndSet(false, true)) {
242                repositorySystemLifecycle.addOnSystemEndedHandler(() -> saveRecordedLines(session));
243            }
244            for (ArtifactResult artifactResult : artifactResults) {
245                if (artifactResult.isResolved() && artifactResult.getRepository() instanceof RemoteRepository) {
246                    RemoteRepository remoteRepository = (RemoteRepository) artifactResult.getRepository();
247                    if (isRepositoryFilteringEnabled(session, remoteRepository)) {
248                        ruleFile(session, remoteRepository); // populate it; needed for save
249                        String line = "=" + artifactResult.getArtifact().getGroupId();
250                        RemoteRepository normalized = normalizeRemoteRepository(session, remoteRepository);
251                        recordedRules(session)
252                                .computeIfAbsent(normalized, k -> new TreeSet<>())
253                                .add(line);
254                        rules(session)
255                                .compute(normalized, (k, v) -> {
256                                    if (v == null || v == DISABLED || v == ENABLED_NO_INPUT) {
257                                        v = GroupTree.create("record");
258                                    }
259                                    return v;
260                                })
261                                .loadNode(line);
262                    }
263                }
264            }
265        }
266    }
267
268    private Path ruleFile(RepositorySystemSession session, RemoteRepository remoteRepository) {
269        return ruleFiles(session).computeIfAbsent(normalizeRemoteRepository(session, remoteRepository), r -> getBasedir(
270                        session, LOCAL_REPO_PREFIX_DIR, CONFIG_PROP_BASEDIR, false)
271                .resolve(GROUP_ID_FILE_PREFIX + repositoryKey(session, remoteRepository) + GROUP_ID_FILE_SUFFIX));
272    }
273
274    private GroupTree cacheRules(RepositorySystemSession session, RemoteRepository remoteRepository) {
275        return rules(session)
276                .computeIfAbsent(
277                        normalizeRemoteRepository(session, remoteRepository), r -> loadRepositoryRules(session, r));
278    }
279
280    private static final GroupTree DISABLED = GroupTree.create("disabled");
281    private static final GroupTree ENABLED_NO_INPUT = GroupTree.create("enabled-no-input");
282
283    private GroupTree loadRepositoryRules(RepositorySystemSession session, RemoteRepository remoteRepository) {
284        if (isRepositoryFilteringEnabled(session, remoteRepository)) {
285            Path filePath = ruleFile(session, remoteRepository);
286            if (Files.isReadable(filePath)) {
287                try (Stream<String> lines = Files.lines(filePath, StandardCharsets.UTF_8)) {
288                    GroupTree groupTree =
289                            GroupTree.create(filePath.getFileName().toString());
290                    int rules = groupTree.loadNodes(lines);
291                    logger.info("Loaded {} group rules for remote repository {}", rules, remoteRepository.getId());
292                    if (logger.isDebugEnabled()) {
293                        groupTree.dump("");
294                    }
295                    return groupTree;
296                } catch (IOException e) {
297                    throw new UncheckedIOException(e);
298                }
299            }
300            logger.debug("Group rules file for remote repository {} not available", remoteRepository);
301            return ENABLED_NO_INPUT;
302        }
303        logger.debug("Group rules file for remote repository {} disabled", remoteRepository);
304        return DISABLED;
305    }
306
307    private class GroupIdFilter implements RemoteRepositoryFilter {
308        private final RepositorySystemSession session;
309
310        private GroupIdFilter(RepositorySystemSession session) {
311            this.session = session;
312        }
313
314        @Override
315        public Result acceptArtifact(RemoteRepository repository, Artifact artifact) {
316            return acceptGroupId(repository, artifact.getGroupId());
317        }
318
319        @Override
320        public Result acceptMetadata(RemoteRepository repository, Metadata metadata) {
321            return acceptGroupId(repository, metadata.getGroupId());
322        }
323
324        private Result acceptGroupId(RemoteRepository repository, String groupId) {
325            GroupTree groupTree = cacheRules(session, repository);
326            if (groupTree == DISABLED) {
327                return result(true, NAME, "Disabled");
328            } else if (groupTree == ENABLED_NO_INPUT) {
329                return result(
330                        ConfigUtils.getBoolean(
331                                session,
332                                DEFAULT_NO_INPUT_OUTCOME,
333                                CONFIG_PROP_NO_INPUT_OUTCOME + "." + repository.getId(),
334                                CONFIG_PROP_NO_INPUT_OUTCOME),
335                        NAME,
336                        "No input available");
337            }
338
339            boolean accepted = groupTree.acceptedGroupId(groupId);
340            return result(
341                    accepted,
342                    NAME,
343                    accepted
344                            ? "G:" + groupId + " allowed from " + repository.getId()
345                            : "G:" + groupId + " NOT allowed from " + repository.getId());
346        }
347    }
348
349    /**
350     * Returns {@code true} if given session is recording.
351     */
352    private boolean isRecord(RepositorySystemSession session) {
353        return ConfigUtils.getBoolean(session, false, CONFIG_PROP_RECORD);
354    }
355
356    /**
357     * On-close handler that saves recorded rules, if any.
358     */
359    private void saveRecordedLines(RepositorySystemSession session) {
360        ArrayList<Exception> exceptions = new ArrayList<>();
361        for (Map.Entry<RemoteRepository, Path> entry : ruleFiles(session).entrySet()) {
362            Set<String> recorded = recordedRules(session).get(entry.getKey());
363            if (recorded != null && !recorded.isEmpty()) {
364                try {
365                    ArrayList<String> result = new ArrayList<>();
366                    if (Files.isReadable(entry.getValue())) {
367                        result.addAll(Files.readAllLines(entry.getValue()));
368                    }
369                    result.add("# Recorded entries");
370                    result.addAll(recorded);
371                    logger.info("Saving {} groupIds to '{}'", result.size(), entry.getValue());
372                    pathProcessor.writeWithBackup(
373                            entry.getValue(), result.stream().collect(Collectors.joining(System.lineSeparator())));
374                } catch (IOException e) {
375                    exceptions.add(e);
376                }
377            }
378        }
379        MultiRuntimeException.mayThrow("session save groupIds failure", exceptions);
380    }
381}