001/*
002 * SPDX-License-Identifier: Apache-2.0
003 *
004 * Copyright 2020-2024 Andres Almiray.
005 *
006 * Licensed under the Apache License, Version 2.0 (the "License");
007 * you may not use this file except in compliance with the License.
008 * You may obtain a copy of the License at
009 *
010 *     https://www.apache.org/licenses/LICENSE-2.0
011 *
012 * Unless required by applicable law or agreed to in writing, software
013 * distributed under the License is distributed on an "AS IS" BASIS,
014 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
015 * See the License for the specific language governing permissions and
016 * limitations under the License.
017 */
018package org.kordamp.maven.checker;
019
020import org.apache.maven.model.Developer;
021import org.apache.maven.model.License;
022import org.apache.maven.model.Model;
023import org.apache.maven.model.Parent;
024import org.apache.maven.model.io.xpp3.MavenXpp3Reader;
025import org.apache.maven.project.MavenProject;
026
027import java.io.File;
028import java.io.FileReader;
029import java.util.ArrayList;
030import java.util.List;
031
032import static java.lang.System.lineSeparator;
033
034/**
035 * Checks if a POM complies with the rules for uploading to Maven Central.
036 * <p>
037 * The following blocks are required:
038 * <ul>
039 *   <li>&lt;groupId&gt;</li>
040 *   <li>&lt;artifactId&gt;</li>
041 *   <li>&lt;version&gt;</li>
042 *   <li>&lt;name&gt;</li>
043 *   <li>&lt;description&gt;</li>
044 *   <li>&lt;url&gt;</li>
045 *   <li>&lt;licenses&gt;</li>
046 *   <li>&lt;scm&gt;</li>
047 * </ul>
048 * <p>
049 * All previous blocks may be supplied by a parent POM with the exception of &lt;artifactId&gt;.
050 * <p>
051 * The following blocks are forbidden if {@code strict = true}
052 * <ul>
053 *   <li>&lt;repositories&gt;</li>
054 *   <li>&lt;pluginRepositories&gt;</li>
055 * </ul>
056 *
057 * @author Andres Almiray
058 * @see <a href="http://maven.apache.org/repository/guide-central-repository-upload.html">http://maven.apache.org/repository/guide-central-repository-upload.html</a>
059 * @see <a href="https://central.sonatype.org/pages/requirements.html">https://central.sonatype.org/pages/requirements.html</a>
060 * @since 1.0.0
061 */
062public class MavenCentralChecker {
063    public static class Configuration {
064        private boolean release;
065        private boolean strict;
066        private boolean failOnError;
067        private boolean failOnWarning;
068
069        public boolean isRelease() {
070            return release;
071        }
072
073        /**
074         * Sets the value for {@code release}.
075         *
076         * @param release if {@code true} checks if version is not -SNAPSHOT.
077         */
078        public Configuration withRelease(boolean release) {
079            this.release = release;
080            return this;
081        }
082
083        public boolean isStrict() {
084            return strict;
085        }
086
087        /**
088         * Sets the value for {@code strict}.
089         *
090         * @param strict if {@code true} checks that &lt;repositories&gt; and &lt;pluginRepositories&gt; are not present
091         */
092        public Configuration withStrict(boolean strict) {
093            this.strict = strict;
094            return this;
095        }
096
097        public boolean isFailOnError() {
098            return failOnError;
099        }
100
101        /**
102         * Sets the value for {@code failOnError}.
103         *
104         * @param failOnError if {@code true} fails the build when an error is encountered.
105         */
106        public Configuration withFailOnError(boolean failOnError) {
107            this.failOnError = failOnError;
108            return this;
109        }
110
111        public boolean isFailOnWarning() {
112            return failOnWarning;
113        }
114
115        /**
116         * Sets the value for {@code failOnWarning}.
117         *
118         * @param failOnWarning if {@code true} fails the build when a warning is encountered.
119         */
120        public Configuration withFailOnWarning(boolean failOnWarning) {
121            this.failOnWarning = failOnWarning;
122            return this;
123        }
124    }
125
126    /**
127     * Checks the resolved model of the given MavenProject for compliance.
128     *
129     * @param log           the logger to use.
130     * @param project       the project to be checked.
131     * @param configuration configuration required for inspection.
132     * @throws PomCheckException if the POM is invalid
133     */
134    public static void check(Logger log, MavenProject project, Configuration configuration) throws PomCheckException {
135        Model fullModel = project.getModel();
136        Model originalModel = project.getOriginalModel();
137
138        List<String> errors = new ArrayList<>();
139        List<String> warnings = new ArrayList<>();
140
141        // sanity checks. redundant?
142        log.debug("Checking <groupId>");
143        if (isBlank(fullModel.getGroupId())) {
144            errors.add("<groupId> can not be blank.");
145        }
146        log.debug("Checking <artifactId>");
147        if (isBlank(fullModel.getArtifactId())) {
148            errors.add("<artifactId> can not be blank.");
149        }
150        log.debug("Checking <version>");
151        if (isBlank(fullModel.getVersion())) {
152            errors.add("<version> can not be blank.");
153        } else if (fullModel.getVersion().contains("${")) {
154            errors.add("<version> contains an unresolved expression: " + fullModel.getVersion());
155        }
156
157        log.debug("Checking <name>");
158        if (isBlank(fullModel.getName())) {
159            String parentName = resolveParentName(project.getFile().getParentFile(), fullModel);
160            if (isBlank(parentName)) {
161                errors.add("<name> can not be blank.");
162            } else {
163                warnings.add("<name> is not defined in POM. Will use value from parent: " +
164                    lineSeparator() + "\t" + parentName);
165            }
166        }
167
168        log.debug("Checking <description>");
169        if (isBlank(fullModel.getDescription())) {
170            errors.add("<description> can not be blank.");
171        }
172        if (isBlank(originalModel.getDescription())) {
173            warnings.add("<description> is not defined in POM. Will use value from parent: " +
174                lineSeparator() + "\t" + fullModel.getDescription());
175        }
176
177        log.debug("Checking <url>");
178        if (isBlank(fullModel.getUrl())) {
179            errors.add("<url> can not be blank.");
180        }
181        if (isBlank(originalModel.getUrl())) {
182            warnings.add("<url> is not defined in POM. Will use computed value from parent: " +
183                lineSeparator() + "\t" + fullModel.getUrl());
184        }
185
186        if (configuration.isRelease()) log.debug("Checking if version is not snapshot");
187        if (configuration.isRelease() && fullModel.getVersion().endsWith("-SNAPSHOT")) {
188            errors.add("<version> can not be -SNAPSHOT.");
189        }
190
191        log.debug("Checking <licenses>");
192        if (fullModel.getLicenses() != null) {
193            if (!fullModel.getLicenses().isEmpty()) {
194                // verify that all licenses have <name> & <url>
195                for (int i = 0; i < fullModel.getLicenses().size(); i++) {
196                    License license = fullModel.getLicenses().get(i);
197                    if (isBlank(license.getName()) && isBlank(license.getUrl())) {
198                        errors.add("License " + i + " must define <name> and <url>.");
199                    }
200                }
201            } else {
202                errors.add("<licenses> block is required but was left empty.");
203            }
204        } else {
205            errors.add("<licenses> block is required but was left undefined.");
206        }
207
208        log.debug("Checking <developers>");
209        if (fullModel.getDevelopers() != null) {
210            if (!fullModel.getDevelopers().isEmpty()) {
211                // verify that all developers have at least one of [id, name, organization, organizationUrl]
212                for (int i = 0; i < fullModel.getDevelopers().size(); i++) {
213                    Developer developer = fullModel.getDevelopers().get(i);
214                    if (isBlank(developer.getId()) &&
215                        isBlank(developer.getName()) &&
216                        isBlank(developer.getOrganization()) &&
217                        isBlank(developer.getOrganizationUrl())) {
218                        errors.add("Developer " + i + " must define at least one of <id>, <name>, <organization>, <organizationUrl>.");
219                    }
220                }
221            } else {
222                errors.add("<developers> block is required but was left empty.");
223            }
224        } else {
225            errors.add("<developers> block is required but was left undefined.");
226        }
227
228        log.debug("Checking <scm>");
229        if (fullModel.getScm() == null) {
230            errors.add("The <scm> block is required.");
231        }
232
233        log.debug("Checking <repositories>");
234        if (null != originalModel.getRepositories() && !originalModel.getRepositories().isEmpty()) {
235            if (configuration.isStrict()) {
236                errors.add("The <repositories> block should not be present.");
237            } else {
238                warnings.add("The <repositories> block should not be present.");
239            }
240        }
241
242        log.debug("Checking <pluginRepositories>");
243        if (null != originalModel.getPluginRepositories() && !originalModel.getPluginRepositories().isEmpty()) {
244            if (configuration.isStrict()) {
245                errors.add("The <pluginRepositories> block should not be present.");
246            } else {
247                warnings.add("The <pluginRepositories> block should not be present.");
248            }
249        }
250
251        if (!warnings.isEmpty()) {
252            if (configuration.isFailOnWarning()) {
253                throw new PomCheckException(String.join(lineSeparator(), warnings));
254            } else {
255                warnings.forEach(log::warn);
256            }
257        }
258
259        if (!errors.isEmpty()) {
260            StringBuilder b = new StringBuilder(lineSeparator())
261                .append("The POM file")
262                .append(lineSeparator())
263                .append(project.getFile().getAbsolutePath())
264                .append(lineSeparator())
265                .append("cannot be uploaded to Maven Central due to the following reasons:")
266                .append(lineSeparator());
267            for (String s : errors) {
268                b.append(" * ").append(s).append(lineSeparator());
269            }
270
271            if (configuration.isFailOnError()) {
272                throw new PomCheckException(b.toString());
273            } else {
274                log.warn(b.toString());
275            }
276        } else {
277            log.info("POM {} passes all checks. It can be uploaded to Maven Central.", project.getFile().getAbsolutePath());
278        }
279    }
280
281    private static String resolveParentName(File directory, Model fullModel) {
282        Parent parent = fullModel.getParent();
283
284        while (parent != null) {
285            if (isNotBlank(parent.getRelativePath())) {
286                File pomFile = new File(directory, parent.getRelativePath());
287                if (!pomFile.getName().endsWith("pom.xml")) {
288                    pomFile = new File(pomFile, "pom.xml");
289                }
290
291                if (pomFile.exists()) {
292                    MavenProject parentProject = readProject(pomFile);
293                    Model parentModel = parentProject.getModel();
294                    if (isNotBlank(parentModel.getName())) {
295                        return parentModel.getName();
296                    } else {
297                        directory = pomFile.getParentFile();
298                        parent = parentModel.getParent();
299                    }
300                } else {
301                    // parent should be available from a repository
302                    // TODO: resolve parent
303                    return null;
304                }
305            } else {
306                // check 1 level up
307                File pomFile = new File(directory, "../pom.xml");
308                if (pomFile.exists()) {
309                    MavenProject parentProject = readProject(pomFile);
310                    Model parentModel = parentProject.getModel();
311                    if (isNotBlank(parentModel.getName())) {
312                        return parentModel.getName();
313                    } else {
314                        directory = pomFile.getParentFile();
315                        parent = parentModel.getParent();
316                    }
317                } else {
318                    // parent should be available from a repository
319                    // TODO: resolve parent
320                    return null;
321                }
322            }
323        }
324
325        return null;
326    }
327
328    private static MavenProject readProject(File pom) {
329        try {
330            FileReader reader = new FileReader(pom);
331            MavenXpp3Reader mavenReader = new MavenXpp3Reader();
332            return new MavenProject(mavenReader.read(reader));
333        } catch (Exception e) {
334            throw new IllegalArgumentException(e);
335        }
336    }
337
338    private static boolean isBlank(String str) {
339        if (str == null || str.length() == 0) {
340            return true;
341        }
342
343        for (char c : str.toCharArray()) {
344            if (!Character.isWhitespace(c)) {
345                return false;
346            }
347        }
348
349        return true;
350    }
351
352    private static boolean isNotBlank(String str) {
353        return !isBlank(str);
354    }
355}