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><groupId></li> 040 * <li><artifactId></li> 041 * <li><version></li> 042 * <li><name></li> 043 * <li><description></li> 044 * <li><url></li> 045 * <li><licenses></li> 046 * <li><scm></li> 047 * </ul> 048 * <p> 049 * All previous blocks may be supplied by a parent POM with the exception of <artifactId>. 050 * <p> 051 * The following blocks are forbidden if {@code strict = true} 052 * <ul> 053 * <li><repositories></li> 054 * <li><pluginRepositories></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 <repositories> and <pluginRepositories> 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}