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.apache.maven.enforcer.rules.dependency;
020
021import javax.inject.Inject;
022import javax.inject.Named;
023
024import java.text.ChoiceFormat;
025import java.util.ArrayList;
026import java.util.Collection;
027import java.util.Collections;
028import java.util.List;
029import java.util.Objects;
030import java.util.function.Predicate;
031import java.util.stream.Collectors;
032
033import org.apache.maven.enforcer.rule.api.EnforcerRuleException;
034import org.apache.maven.enforcer.rules.AbstractStandardEnforcerRule;
035import org.apache.maven.enforcer.rules.utils.ArtifactMatcher;
036import org.apache.maven.enforcer.rules.utils.ArtifactUtils;
037import org.apache.maven.execution.MavenSession;
038import org.apache.maven.project.MavenProject;
039import org.eclipse.aether.RepositorySystem;
040import org.eclipse.aether.graph.DependencyFilter;
041import org.eclipse.aether.graph.DependencyNode;
042import org.eclipse.aether.util.graph.manager.DependencyManagerUtils;
043import org.eclipse.aether.util.graph.visitor.PathRecordingDependencyVisitor;
044import org.eclipse.aether.util.version.GenericVersionScheme;
045import org.eclipse.aether.version.InvalidVersionSpecificationException;
046import org.eclipse.aether.version.VersionConstraint;
047
048/**
049 * This rule bans dependencies having a version which requires resolution (i.e. dynamic versions which might change with
050 * each build). Dynamic versions are either
051 * <ul>
052 * <li>version ranges,</li>
053 * <li>the special placeholders {@code LATEST} or {@code RELEASE} or</li>
054 * <li>versions ending with {@code -SNAPSHOT}.
055 * </ul>
056 *
057 * @since 3.2.0
058 */
059@Named("banDynamicVersions")
060public final class BanDynamicVersions extends AbstractStandardEnforcerRule {
061
062    private static final String RELEASE = "RELEASE";
063
064    private static final String LATEST = "LATEST";
065
066    private static final String SNAPSHOT_SUFFIX = "-SNAPSHOT";
067
068    /**
069     * {@code true} if versions ending with {@code -SNAPSHOT} should be allowed
070     */
071    private boolean allowSnapshots;
072
073    /**
074     * {@code true} if versions using {@code LATEST} should be allowed
075     */
076    private boolean allowLatest;
077
078    /**
079     * {@code true} if versions using {@code RELEASE} should be allowed
080     */
081    private boolean allowRelease;
082
083    /**
084     * {@code true} if version ranges should be allowed
085     */
086    private boolean allowRanges;
087
088    /**
089     * {@code true} if ranges having the same upper and lower bound like {@code [1.0]} should be allowed.
090     * Only applicable if {@link #allowRanges} is not set to {@code true}.
091     */
092    private boolean allowRangesWithIdenticalBounds;
093
094    /**
095     * {@code true} if optional dependencies should not be checked
096     */
097    private boolean excludeOptionals;
098
099    /**
100     * the scopes of dependencies which should be excluded from this rule
101     */
102    private List<String> excludedScopes = Collections.emptyList();
103
104    /**
105     * Specify the ignored dependencies. This can be a list of artifacts in the format
106     * <code>groupId[:artifactId[:version[:type[:scope:[classifier]]]]]</code>.
107     * Any of the sections can be a wildcard by using '*' (e.g. {@code group:*:1.0}).
108     * <br>
109     * Any of the ignored dependencies may have dynamic versions.
110     */
111    private List<String> ignores = null;
112
113    /**
114     * {@code true} if dependencies should be checked before Maven computes the final
115     * dependency tree. Setting this property will make the rule check dependencies
116     * before any conflicts are resolved. This is similar to the {@code verbose}
117     * parameter for the {@code tree} goal for {@code maven-dependency-plugin}.
118     */
119    private boolean verbose;
120
121    private final ResolverUtil resolverUtil;
122
123    @Inject
124    public BanDynamicVersions(
125            MavenProject project, RepositorySystem repoSystem, MavenSession mavenSession, ResolverUtil resolverUtil) {
126        this.resolverUtil = Objects.requireNonNull(resolverUtil);
127    }
128
129    private final class BannedDynamicVersionCollector implements DependencyFilter {
130
131        private boolean isRoot = true;
132
133        private List<String> violations;
134
135        private final Predicate<DependencyNode> predicate;
136
137        private GenericVersionScheme versionScheme;
138
139        public List<String> getViolations() {
140            return violations;
141        }
142
143        BannedDynamicVersionCollector(Predicate<DependencyNode> predicate) {
144            this.predicate = predicate;
145            this.violations = new ArrayList<>();
146            this.versionScheme = new GenericVersionScheme();
147        }
148
149        private boolean isBannedDynamicVersion(VersionConstraint versionConstraint) {
150            if (versionConstraint.getVersion() != null) {
151                if (versionConstraint.getVersion().toString().equals(LATEST)) {
152                    return !allowLatest;
153                } else if (versionConstraint.getVersion().toString().equals(RELEASE)) {
154                    return !allowRelease;
155                } else if (versionConstraint.getVersion().toString().endsWith(SNAPSHOT_SUFFIX)) {
156                    return !allowSnapshots;
157                }
158            } else if (versionConstraint.getRange() != null) {
159                if (allowRangesWithIdenticalBounds
160                        && Objects.equals(
161                                versionConstraint.getRange().getLowerBound(),
162                                versionConstraint.getRange().getUpperBound())) {
163                    return false;
164                }
165                return !allowRanges;
166            } else {
167                getLog().warn("Unexpected version constraint found: " + versionConstraint);
168            }
169            return false;
170        }
171
172        @Override
173        public boolean accept(DependencyNode node, List<DependencyNode> parents) {
174            if (isRoot) {
175                isRoot = false;
176                return false;
177            }
178            getLog().debug("Found node " + node + " with version constraint " + node.getVersionConstraint());
179            if (!predicate.test(node)) {
180                return false;
181            }
182            VersionConstraint versionConstraint = node.getVersionConstraint();
183            if (isBannedDynamicVersion(versionConstraint)) {
184                addViolation(versionConstraint, node, parents);
185                return true;
186            }
187            try {
188                if (verbose) {
189                    String premanagedVersion = DependencyManagerUtils.getPremanagedVersion(node);
190                    if (premanagedVersion != null) {
191                        VersionConstraint premanagedContraint = versionScheme.parseVersionConstraint(premanagedVersion);
192                        if (isBannedDynamicVersion(premanagedContraint)) {
193                            addViolation(premanagedContraint, node, parents);
194                            return true;
195                        }
196                    }
197                }
198            } catch (InvalidVersionSpecificationException ex) {
199                // This should never happen.
200                throw new RuntimeException("Failed to parse version for " + node, ex);
201            }
202            return false;
203        }
204
205        private void addViolation(
206                VersionConstraint versionContraint, DependencyNode node, List<DependencyNode> parents) {
207            List<DependencyNode> intermediatePath = new ArrayList<>(parents);
208            if (!intermediatePath.isEmpty()) {
209                // This project is also included in the path, but we do
210                // not want that in the report.
211                intermediatePath.remove(intermediatePath.size() - 1);
212            }
213            violations.add("Dependency "
214                    + node.getDependency()
215                    + dumpIntermediatePath(intermediatePath)
216                    + " is referenced with a banned dynamic version "
217                    + versionContraint);
218        }
219    }
220
221    @Override
222    public void execute() throws EnforcerRuleException {
223        DependencyNode rootDependency =
224                resolverUtil.resolveTransitiveDependencies(verbose, excludeOptionals, excludedScopes);
225
226        List<String> violations = collectDependenciesWithBannedDynamicVersions(rootDependency);
227        if (!violations.isEmpty()) {
228            ChoiceFormat dependenciesFormat = new ChoiceFormat("1#dependency|1<dependencies");
229            throw new EnforcerRuleException("Found " + violations.size() + " "
230                    + dependenciesFormat.format(violations.size())
231                    + " with dynamic versions." + System.lineSeparator()
232                    + String.join(System.lineSeparator(), violations));
233        }
234    }
235
236    private static String dumpIntermediatePath(Collection<DependencyNode> path) {
237        if (path.isEmpty()) {
238            return "";
239        }
240        return " via " + path.stream().map(n -> n.getArtifact().toString()).collect(Collectors.joining(" -> "));
241    }
242
243    private static final class ExcludeArtifactPatternsPredicate implements Predicate<DependencyNode> {
244
245        private final ArtifactMatcher artifactMatcher;
246
247        ExcludeArtifactPatternsPredicate(List<String> excludes) {
248            this.artifactMatcher = new ArtifactMatcher(excludes, Collections.emptyList());
249        }
250
251        @Override
252        public boolean test(DependencyNode depNode) {
253            return !artifactMatcher.match(ArtifactUtils.toArtifact(depNode));
254        }
255    }
256
257    private List<String> collectDependenciesWithBannedDynamicVersions(DependencyNode rootDependency) {
258        Predicate<DependencyNode> predicate;
259        if (ignores != null && !ignores.isEmpty()) {
260            predicate = new ExcludeArtifactPatternsPredicate(ignores);
261        } else {
262            predicate = d -> true;
263        }
264        BannedDynamicVersionCollector collector = new BannedDynamicVersionCollector(predicate);
265        rootDependency.accept(new PathRecordingDependencyVisitor(collector));
266        return collector.getViolations();
267    }
268
269    public void setVerbose(boolean verbose) {
270        this.verbose = verbose;
271    }
272
273    @Override
274    public String toString() {
275        return String.format(
276                "BanDynamicVersions[allowSnapshots=%b, allowLatest=%b, allowRelease=%b, allowRanges=%b, allowRangesWithIdenticalBounds=%b, excludeOptionals=%b, excludedScopes=%s, ignores=%s, verbose=%b]",
277                allowSnapshots,
278                allowLatest,
279                allowRelease,
280                allowRanges,
281                allowRangesWithIdenticalBounds,
282                excludeOptionals,
283                excludedScopes,
284                ignores,
285                verbose);
286    }
287}