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