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