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}