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.ConcurrentMap;
039import java.util.concurrent.atomic.AtomicBoolean;
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.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.resolution.ArtifactResolverPostProcessor;
050import org.eclipse.aether.util.ConfigUtils;
051import org.eclipse.aether.util.FileUtils;
052import org.slf4j.Logger;
053import org.slf4j.LoggerFactory;
054
055import static java.util.Objects.requireNonNull;
056
057/**
058 * Remote repository filter source filtering on G coordinate. It is backed by a file that lists all allowed groupIds
059 * and groupId not present in this file are filtered out.
060 * <p>
061 * The file can be authored manually: format is one groupId per line, comments starting with "#" (hash) amd empty lines
062 * for structuring are supported. 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.
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 * @since 1.9.0
072 */
073@Singleton
074@Named(GroupIdRemoteRepositoryFilterSource.NAME)
075public final class GroupIdRemoteRepositoryFilterSource extends RemoteRepositoryFilterSourceSupport
076        implements ArtifactResolverPostProcessor {
077    public static final String NAME = "groupId";
078
079    private static final String CONF_NAME_RECORD = "record";
080
081    static final String GROUP_ID_FILE_PREFIX = "groupId-";
082
083    static final String GROUP_ID_FILE_SUFFIX = ".txt";
084
085    private static final Logger LOGGER = LoggerFactory.getLogger(GroupIdRemoteRepositoryFilterSource.class);
086
087    private final RepositorySystemLifecycle repositorySystemLifecycle;
088
089    @Inject
090    public GroupIdRemoteRepositoryFilterSource(RepositorySystemLifecycle repositorySystemLifecycle) {
091        super(NAME);
092        this.repositorySystemLifecycle = requireNonNull(repositorySystemLifecycle);
093    }
094
095    @SuppressWarnings("unchecked")
096    private ConcurrentMap<Path, Set<String>> rules(RepositorySystemSession session) {
097        return (ConcurrentMap<Path, Set<String>>)
098                session.getData().computeIfAbsent(getClass().getName() + ".rules", ConcurrentHashMap::new);
099    }
100
101    @SuppressWarnings("unchecked")
102    private ConcurrentMap<Path, Boolean> changedRules(RepositorySystemSession session) {
103        return (ConcurrentMap<Path, Boolean>)
104                session.getData().computeIfAbsent(getClass().getName() + ".changedRules", ConcurrentHashMap::new);
105    }
106
107    private AtomicBoolean onShutdownHandlerRegistered(RepositorySystemSession session) {
108        return (AtomicBoolean) session.getData()
109                .computeIfAbsent(getClass().getName() + ".onShutdownHandlerRegistered", AtomicBoolean::new);
110    }
111
112    @Override
113    public RemoteRepositoryFilter getRemoteRepositoryFilter(RepositorySystemSession session) {
114        if (isEnabled(session) && !isRecord(session)) {
115            return new GroupIdFilter(session);
116        }
117        return null;
118    }
119
120    @Override
121    public void postProcess(RepositorySystemSession session, List<ArtifactResult> artifactResults) {
122        if (isEnabled(session) && isRecord(session)) {
123            if (onShutdownHandlerRegistered(session).compareAndSet(false, true)) {
124                repositorySystemLifecycle.addOnSystemEndedHandler(() -> saveRecordedLines(session));
125            }
126            for (ArtifactResult artifactResult : artifactResults) {
127                if (artifactResult.isResolved() && artifactResult.getRepository() instanceof RemoteRepository) {
128                    Path filePath = filePath(
129                            getBasedir(session, false),
130                            artifactResult.getRepository().getId());
131                    boolean newGroupId = rules(session)
132                            .computeIfAbsent(filePath, f -> Collections.synchronizedSet(new TreeSet<>()))
133                            .add(artifactResult.getArtifact().getGroupId());
134                    if (newGroupId) {
135                        changedRules(session).put(filePath, Boolean.TRUE);
136                    }
137                }
138            }
139        }
140    }
141
142    /**
143     * Returns the groupId path. The file and parents may not exist, this method merely calculate the path.
144     */
145    private Path filePath(Path basedir, String remoteRepositoryId) {
146        return basedir.resolve(GROUP_ID_FILE_PREFIX + remoteRepositoryId + GROUP_ID_FILE_SUFFIX);
147    }
148
149    private Set<String> cacheRules(RepositorySystemSession session, RemoteRepository remoteRepository) {
150        Path filePath = filePath(getBasedir(session, false), remoteRepository.getId());
151        return rules(session).computeIfAbsent(filePath, r -> {
152            Set<String> rules = loadRepositoryRules(filePath);
153            if (rules != NOT_PRESENT) {
154                LOGGER.info("Loaded {} groupId for remote repository {}", rules.size(), remoteRepository.getId());
155            }
156            return rules;
157        });
158    }
159
160    private Set<String> loadRepositoryRules(Path filePath) {
161        if (Files.isReadable(filePath)) {
162            try (BufferedReader reader = Files.newBufferedReader(filePath, StandardCharsets.UTF_8)) {
163                TreeSet<String> result = new TreeSet<>();
164                String groupId;
165                while ((groupId = reader.readLine()) != null) {
166                    if (!groupId.startsWith("#") && !groupId.trim().isEmpty()) {
167                        result.add(groupId);
168                    }
169                }
170                return Collections.unmodifiableSet(result);
171            } catch (IOException e) {
172                throw new UncheckedIOException(e);
173            }
174        }
175        return NOT_PRESENT;
176    }
177
178    private static final TreeSet<String> NOT_PRESENT = new TreeSet<>();
179
180    private class GroupIdFilter implements RemoteRepositoryFilter {
181        private final RepositorySystemSession session;
182
183        private GroupIdFilter(RepositorySystemSession session) {
184            this.session = session;
185        }
186
187        @Override
188        public Result acceptArtifact(RemoteRepository remoteRepository, Artifact artifact) {
189            return acceptGroupId(remoteRepository, artifact.getGroupId());
190        }
191
192        @Override
193        public Result acceptMetadata(RemoteRepository remoteRepository, Metadata metadata) {
194            return acceptGroupId(remoteRepository, metadata.getGroupId());
195        }
196
197        private Result acceptGroupId(RemoteRepository remoteRepository, String groupId) {
198            Set<String> groupIds = cacheRules(session, remoteRepository);
199            if (NOT_PRESENT == groupIds) {
200                return NOT_PRESENT_RESULT;
201            }
202
203            if (groupIds.contains(groupId)) {
204                return new SimpleResult(true, "G:" + groupId + " allowed from " + remoteRepository);
205            } else {
206                return new SimpleResult(false, "G:" + groupId + " NOT allowed from " + remoteRepository);
207            }
208        }
209    }
210
211    private static final RemoteRepositoryFilter.Result NOT_PRESENT_RESULT =
212            new SimpleResult(true, "GroupId file not present");
213
214    /**
215     * Returns {@code true} if given session is recording.
216     */
217    private boolean isRecord(RepositorySystemSession session) {
218        return ConfigUtils.getBoolean(session, false, configPropKey(CONF_NAME_RECORD));
219    }
220
221    /**
222     * On-close handler that saves recorded rules, if any.
223     */
224    private void saveRecordedLines(RepositorySystemSession session) {
225        if (changedRules(session).isEmpty()) {
226            return;
227        }
228
229        ArrayList<Exception> exceptions = new ArrayList<>();
230        for (Map.Entry<Path, Set<String>> entry : rules(session).entrySet()) {
231            Path filePath = entry.getKey();
232            if (changedRules(session).get(filePath) != Boolean.TRUE) {
233                continue;
234            }
235            Set<String> recordedLines = entry.getValue();
236            if (!recordedLines.isEmpty()) {
237                try {
238                    TreeSet<String> result = new TreeSet<>();
239                    result.addAll(loadRepositoryRules(filePath));
240                    result.addAll(recordedLines);
241
242                    LOGGER.info("Saving {} groupIds to '{}'", result.size(), filePath);
243                    FileUtils.writeFileWithBackup(filePath, p -> Files.write(p, result));
244                } catch (IOException e) {
245                    exceptions.add(e);
246                }
247            }
248        }
249        MultiRuntimeException.mayThrow("session save groupIds failure", exceptions);
250    }
251}