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