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