// Copyright Vespa.ai. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. package com.yahoo.vespa.maven.plugin.enforcer; import org.apache.maven.artifact.Artifact; import org.apache.maven.enforcer.rule.api.AbstractEnforcerRule; import org.apache.maven.enforcer.rule.api.EnforcerRule; import org.apache.maven.enforcer.rule.api.EnforcerRuleException; import org.apache.maven.enforcer.rule.api.EnforcerRuleHelper; import org.apache.maven.execution.MavenSession; import org.apache.maven.project.DefaultProjectBuildingRequest; import org.apache.maven.project.MavenProject; import org.apache.maven.shared.dependency.graph.DependencyGraphBuilder; import org.apache.maven.shared.dependency.graph.DependencyGraphBuilderException; import org.apache.maven.shared.dependency.graph.DependencyNode; import org.codehaus.plexus.component.configurator.expression.ExpressionEvaluationException; import org.codehaus.plexus.component.repository.exception.ComponentLookupException; import javax.inject.Inject; import javax.inject.Named; import java.io.File; import java.io.IOException; import java.io.UncheckedIOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Comparator; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Properties; import java.util.Set; import java.util.TreeMap; import java.util.TreeSet; import java.util.regex.Pattern; import java.util.stream.Collectors; import java.util.stream.Stream; /** * @author bjorncs */ @Named("allowedDependencies") @SuppressWarnings("deprecation") public class AllowedDependencies extends AbstractEnforcerRule implements EnforcerRule { private static final String WRITE_SPEC_PROP = "dependencyEnforcer.writeSpec"; private static final String GUESS_VERSION = "dependencyEnforcer.guessProperty"; @Inject private MavenProject project; @Inject private MavenSession session; @Inject private DependencyGraphBuilder graphBuilder; // Injected parameters public List ignored; public String rootProjectId; public String specFile; @Override public void execute(EnforcerRuleHelper helper) throws EnforcerRuleException { try { project = (MavenProject) helper.evaluate("${project}"); session = (MavenSession) helper.evaluate("${session}"); graphBuilder = helper.getComponent(DependencyGraphBuilder.class); } catch (ExpressionEvaluationException | ComponentLookupException e) { throw new RuntimeException(e); } execute(); } public void execute() throws EnforcerRuleException { var dependencies = getDependenciesOfAllProjects(); getLog().info("Found %d unique dependencies ".formatted(dependencies.size())); var specFile = Paths.get(project.getBasedir() + File.separator + this.specFile).normalize(); var spec = loadDependencySpec(specFile); var resolved = resolve(spec, dependencies); if (System.getProperties().containsKey(WRITE_SPEC_PROP)) { // Guess property for version by default, can be disabled with =false var guessProperty = Optional.ofNullable(System.getProperty(GUESS_VERSION)) .map(p -> p.isEmpty() || Boolean.parseBoolean(p)) .orElse(true); writeDependencySpec(specFile, resolved, guessProperty); getLog().info("Updated spec file '%s'".formatted(specFile.toString())); } else { warnOnDuplicateVersions(resolved); validateDependencies(resolved, session.getRequest().getPom().toPath(), project.getArtifactId()); } getLog().info("The dependency enforcer completed successfully"); } private static void validateDependencies(Resolved resolved, Path aggregatorPomRoot, String moduleName) throws EnforcerRuleException { if (!resolved.unmatchedRules().isEmpty() || !resolved.unmatchedDeps().isEmpty()) { var errorMsg = new StringBuilder("The dependency enforcer failed:\n"); if (!resolved.unmatchedRules().isEmpty()) { errorMsg.append("Rules not matching any dependency:\n"); resolved.unmatchedRules().forEach(r -> errorMsg.append(" - ").append(r.asString()).append('\n')); } if (!resolved.unmatchedDeps().isEmpty()) { errorMsg.append("Dependencies not matching any rule:\n"); resolved.unmatchedDeps().forEach(d -> errorMsg.append(" - ").append(d.asString(null)).append('\n')); } throw new EnforcerRuleException( errorMsg.append("Maven dependency validation failed. ") .append("If this change was intentional, update the dependency spec by running:\n") .append("$ mvn validate -D").append(WRITE_SPEC_PROP).append(" -pl :").append(moduleName) .append(" -f ").append(aggregatorPomRoot).append("\n").toString()); } } private Set getDependenciesOfAllProjects() throws EnforcerRuleException { try { Pattern depIgnorePattern = Pattern.compile( ignored.stream() .map(s -> s.replace(".", "\\.").replace("*", ".*").replace(":", "\\:").replace('?', '.')) .collect(Collectors.joining(")|(", "^(", ")$"))); List projects = getAllProjects(session, rootProjectId); Set dependencies = new HashSet<>(); for (MavenProject project : projects) { var req = new DefaultProjectBuildingRequest(session.getProjectBuildingRequest()); req.setProject(project); var root = graphBuilder.buildDependencyGraph(req, null); addDependenciesRecursive(root, dependencies, depIgnorePattern); } return Set.copyOf(dependencies); } catch (DependencyGraphBuilderException e) { throw new RuntimeException(e.getMessage(), e); } } private static void addDependenciesRecursive(DependencyNode node, Set dependencies, Pattern ignored) { if (node.getChildren() != null) { for (DependencyNode dep : node.getChildren()) { Artifact a = dep.getArtifact(); Dependency dependency = Dependency.fromArtifact(a); if (!ignored.matcher(dependency.asString(null)).matches()) { dependencies.add(dependency); } addDependenciesRecursive(dep, dependencies, ignored); } } } /** Only return the projects we'd like to enforce dependencies for: the root project, its modules, their modules, etc. */ private static List getAllProjects(MavenSession session, String rootProjectId) throws EnforcerRuleException { if (rootProjectId == null) throw new EnforcerRuleException("Missing required in in pom.xml"); List allProjects = session.getAllProjects(); if (allProjects.size() == 1) { throw new EnforcerRuleException( "Only a single Maven module detected. Enforcer must be executed from root of aggregator pom."); } MavenProject rootProject = allProjects .stream() .filter(project -> rootProjectId.equals(projectIdOf(project))) .findAny() .orElseThrow(() -> new EnforcerRuleException("Root project not found: " + rootProjectId)); Map projectsByBaseDir = allProjects .stream() .collect(Collectors.toMap(project -> project.getBasedir().toPath().normalize(), project -> project)); var projects = new ArrayList(); var pendingProjects = new ArrayDeque(); pendingProjects.add(rootProject); while (!pendingProjects.isEmpty()) { MavenProject project = pendingProjects.pop(); projects.add(project); for (var module : project.getModules()) { // Assumption: The module is a relative path to a project base directory. Path moduleBaseDir = project.getBasedir().toPath().resolve(module).normalize(); MavenProject moduleProject = projectsByBaseDir.get(moduleBaseDir); if (moduleProject == null) throw new EnforcerRuleException("Failed to find module '" + module + "' in project " + project.getBasedir()); pendingProjects.add(moduleProject); } } projects.sort(Comparator.comparing(AllowedDependencies::projectIdOf)); return projects; } private List loadDependencySpec(Path specFile) { try (Stream s = Files.lines(specFile)) { return s.map(String::trim) .filter(l -> !l.isEmpty() && !l.startsWith("#")) .map(Rule::fromString) .toList(); } catch (IOException e) { throw new UncheckedIOException(e); } } private Resolved resolve(List spec, Set dependencies) { var resolvedDeps = new HashSet(); var resolveRules = new HashSet(); var unmatchedDeps = new HashSet(); var unmatchedRules = new HashSet(); for (var rule : spec) { var requiredDependency = rule.resolveToDependency(project.getProperties()); if (dependencies.contains(requiredDependency)) { resolvedDeps.add(requiredDependency); resolveRules.add(rule); } else { unmatchedRules.add(rule); } } for (var dependency : dependencies) { if (!resolvedDeps.contains(dependency)) { unmatchedDeps.add(dependency); } } return new Resolved(resolvedDeps, resolveRules, unmatchedDeps, unmatchedRules); } void writeDependencySpec(Path specFile, Resolved resolved, boolean guessVersion) { var content = new TreeSet(); resolved.matchedRules().forEach(r -> content.add(r.asString())); resolved.unmatchedDeps().forEach(d -> content.add(d.asString(guessVersion ? project.getProperties() : null))); try (var out = Files.newBufferedWriter(specFile)) { out.write("# Copyright Vespa.ai. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.\n\n"); for (var line : content) { out.write(line); out.write('\n'); } } catch (IOException e) { throw new UncheckedIOException(e); } } private void warnOnDuplicateVersions(Resolved resolved) { Map> versionsForDependency = new TreeMap<>(); Set allDeps = new HashSet<>(resolved.matchedDeps()); allDeps.addAll(resolved.unmatchedDeps()); for (Dependency d : allDeps) { String id = "%s:%s".formatted(d.groupId(), d.artifactId()); versionsForDependency.computeIfAbsent(id, __ -> new TreeSet<>()).add(d.version()); } versionsForDependency.forEach((dependency, versions) -> { if (versions.size() > 1) { getLog().warn("'%s' has multiple versions %s".formatted(dependency, versions)); } }); } private static String projectIdOf(MavenProject project) { return "%s:%s".formatted(project.getGroupId(), project.getArtifactId()); } private record Rule(String groupId, String artifactId, String version, Optional classifier){ static final Pattern PROPERTY_PATTERN = Pattern.compile("\\$\\{(.+?)}"); static Rule fromString(String s) { String[] splits = s.split(":"); return splits.length == 3 ? new Rule(splits[0], splits[1], splits[2], Optional.empty()) : new Rule(splits[0], splits[1], splits[2], Optional.of(splits[3])); } Dependency resolveToDependency(Properties props) { // Replace expressions on form ${property} in 'version' field with value from properties var matcher = PROPERTY_PATTERN.matcher(version); var resolvedVersion = version; while (matcher.find()) { String property = matcher.group(1); String value = props.getProperty(property); if (value == null) throw new IllegalArgumentException("Missing property: " + property); resolvedVersion = version.replace(matcher.group(), value); } return new Dependency(groupId, artifactId, resolvedVersion, classifier); } String asString() { var b = new StringBuilder(groupId).append(':').append(artifactId).append(':').append(version); classifier.ifPresent(c -> b.append(':').append(c)); return b.toString(); } } record Dependency(String groupId, String artifactId, String version, Optional classifier) { static Dependency fromArtifact(Artifact a) { return new Dependency( a.getGroupId(), a.getArtifactId(), a.getVersion(), Optional.ofNullable(a.getClassifier())); } String asString(Properties props) { String versionStr = version; if (props != null) { // Guess property name if properties are provided var matchingProps = props.entrySet().stream() .filter(e -> e.getValue().equals(version)) .map(v -> "${%s}".formatted(v.getKey())) .collect(Collectors.joining("|")); if (!matchingProps.isEmpty()) versionStr = matchingProps; } var b = new StringBuilder(groupId).append(':').append(artifactId).append(':').append(versionStr); classifier.ifPresent(c -> b.append(':').append(c)); return b.toString(); } } record Resolved(Set matchedDeps, Set matchedRules, Set unmatchedDeps, Set unmatchedRules) {} // Mark rule as not cachable @Override public boolean isCacheable() { return false; } @Override public boolean isResultValid(EnforcerRule r) { return false; } @Override public String getCacheId() { return ""; } }