001/////////////////////////////////////////////////////////////////////////////////////////////// 002// checkstyle: Checks Java source code and other text files for adherence to a set of rules. 003// Copyright (C) 2001-2025 the original author or authors. 004// 005// This library is free software; you can redistribute it and/or 006// modify it under the terms of the GNU Lesser General Public 007// License as published by the Free Software Foundation; either 008// version 2.1 of the License, or (at your option) any later version. 009// 010// This library is distributed in the hope that it will be useful, 011// but WITHOUT ANY WARRANTY; without even the implied warranty of 012// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 013// Lesser General Public License for more details. 014// 015// You should have received a copy of the GNU Lesser General Public 016// License along with this library; if not, write to the Free Software 017// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA 018/////////////////////////////////////////////////////////////////////////////////////////////// 019 020package com.puppycrawl.tools.checkstyle; 021 022import java.io.ByteArrayOutputStream; 023import java.io.IOException; 024import java.io.InputStream; 025import java.io.OutputStream; 026import java.io.OutputStreamWriter; 027import java.io.PrintWriter; 028import java.io.StringWriter; 029import java.nio.charset.StandardCharsets; 030import java.util.ArrayList; 031import java.util.List; 032import java.util.Locale; 033import java.util.regex.Pattern; 034 035import com.puppycrawl.tools.checkstyle.api.AuditEvent; 036import com.puppycrawl.tools.checkstyle.api.AuditListener; 037import com.puppycrawl.tools.checkstyle.api.AutomaticBean; 038import com.puppycrawl.tools.checkstyle.api.SeverityLevel; 039 040/** 041 * Simple SARIF logger. 042 * SARIF stands for the static analysis results interchange format. 043 * See <a href="https://sarifweb.azurewebsites.net/">reference</a> 044 */ 045public class SarifLogger extends AbstractAutomaticBean implements AuditListener { 046 047 /** The length of unicode placeholder. */ 048 private static final int UNICODE_LENGTH = 4; 049 050 /** Unicode escaping upper limit. */ 051 private static final int UNICODE_ESCAPE_UPPER_LIMIT = 0x1F; 052 053 /** Input stream buffer size. */ 054 private static final int BUFFER_SIZE = 1024; 055 056 /** The placeholder for message. */ 057 private static final String MESSAGE_PLACEHOLDER = "${message}"; 058 059 /** The placeholder for severity level. */ 060 private static final String SEVERITY_LEVEL_PLACEHOLDER = "${severityLevel}"; 061 062 /** The placeholder for uri. */ 063 private static final String URI_PLACEHOLDER = "${uri}"; 064 065 /** The placeholder for line. */ 066 private static final String LINE_PLACEHOLDER = "${line}"; 067 068 /** The placeholder for column. */ 069 private static final String COLUMN_PLACEHOLDER = "${column}"; 070 071 /** The placeholder for rule id. */ 072 private static final String RULE_ID_PLACEHOLDER = "${ruleId}"; 073 074 /** The placeholder for version. */ 075 private static final String VERSION_PLACEHOLDER = "${version}"; 076 077 /** The placeholder for results. */ 078 private static final String RESULTS_PLACEHOLDER = "${results}"; 079 080 /** Two backslashes to not duplicate strings. */ 081 private static final String TWO_BACKSLASHES = "\\\\"; 082 083 /** A pattern for two backslashes. */ 084 private static final Pattern A_SPACE_PATTERN = Pattern.compile(" "); 085 086 /** A pattern for two backslashes. */ 087 private static final Pattern TWO_BACKSLASHES_PATTERN = Pattern.compile(TWO_BACKSLASHES); 088 089 /** A pattern to match a file with a Windows drive letter. */ 090 private static final Pattern WINDOWS_DRIVE_LETTER_PATTERN = 091 Pattern.compile("\\A[A-Z]:", Pattern.CASE_INSENSITIVE); 092 093 /** Helper writer that allows easy encoding and printing. */ 094 private final PrintWriter writer; 095 096 /** Close output stream in auditFinished. */ 097 private final boolean closeStream; 098 099 /** The results. */ 100 private final List<String> results = new ArrayList<>(); 101 102 /** Content for the entire report. */ 103 private final String report; 104 105 /** Content for result representing an error with source line and column. */ 106 private final String resultLineColumn; 107 108 /** Content for result representing an error with source line only. */ 109 private final String resultLineOnly; 110 111 /** Content for result representing an error with filename only and without source location. */ 112 private final String resultFileOnly; 113 114 /** Content for result representing an error without filename or location. */ 115 private final String resultErrorOnly; 116 117 /** 118 * Creates a new {@code SarifLogger} instance. 119 * 120 * @param outputStream where to log audit events 121 * @param outputStreamOptions if {@code CLOSE} that should be closed in auditFinished() 122 * @throws IllegalArgumentException if outputStreamOptions is null 123 * @throws IOException if there is reading errors. 124 * @noinspection deprecation 125 * @noinspectionreason We are forced to keep AutomaticBean compatability 126 * because of maven-checkstyle-plugin. Until #12873. 127 */ 128 public SarifLogger( 129 OutputStream outputStream, 130 AutomaticBean.OutputStreamOptions outputStreamOptions) throws IOException { 131 this(outputStream, OutputStreamOptions.valueOf(outputStreamOptions.name())); 132 } 133 134 /** 135 * Creates a new {@code SarifLogger} instance. 136 * 137 * @param outputStream where to log audit events 138 * @param outputStreamOptions if {@code CLOSE} that should be closed in auditFinished() 139 * @throws IllegalArgumentException if outputStreamOptions is null 140 * @throws IOException if there is reading errors. 141 */ 142 public SarifLogger( 143 OutputStream outputStream, 144 OutputStreamOptions outputStreamOptions) throws IOException { 145 if (outputStreamOptions == null) { 146 throw new IllegalArgumentException("Parameter outputStreamOptions can not be null"); 147 } 148 writer = new PrintWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8)); 149 closeStream = outputStreamOptions == OutputStreamOptions.CLOSE; 150 report = readResource("/com/puppycrawl/tools/checkstyle/sarif/SarifReport.template"); 151 resultLineColumn = 152 readResource("/com/puppycrawl/tools/checkstyle/sarif/ResultLineColumn.template"); 153 resultLineOnly = 154 readResource("/com/puppycrawl/tools/checkstyle/sarif/ResultLineOnly.template"); 155 resultFileOnly = 156 readResource("/com/puppycrawl/tools/checkstyle/sarif/ResultFileOnly.template"); 157 resultErrorOnly = 158 readResource("/com/puppycrawl/tools/checkstyle/sarif/ResultErrorOnly.template"); 159 } 160 161 @Override 162 protected void finishLocalSetup() { 163 // No code by default 164 } 165 166 @Override 167 public void auditStarted(AuditEvent event) { 168 // No code by default 169 } 170 171 @Override 172 public void auditFinished(AuditEvent event) { 173 String rendered = replaceVersionString(report); 174 rendered = rendered.replace(RESULTS_PLACEHOLDER, String.join(",\n", results)); 175 writer.print(rendered); 176 if (closeStream) { 177 writer.close(); 178 } 179 else { 180 writer.flush(); 181 } 182 } 183 184 /** 185 * Returns the version string. 186 * 187 * @param report report content where replace should happen 188 * @return a version string based on the package implementation version 189 */ 190 private static String replaceVersionString(String report) { 191 final String version = SarifLogger.class.getPackage().getImplementationVersion(); 192 return report.replace(VERSION_PLACEHOLDER, String.valueOf(version)); 193 } 194 195 @Override 196 public void addError(AuditEvent event) { 197 if (event.getColumn() > 0) { 198 results.add(resultLineColumn 199 .replace(SEVERITY_LEVEL_PLACEHOLDER, renderSeverityLevel(event.getSeverityLevel())) 200 .replace(URI_PLACEHOLDER, renderFileNameUri(event.getFileName())) 201 .replace(COLUMN_PLACEHOLDER, Integer.toString(event.getColumn())) 202 .replace(LINE_PLACEHOLDER, Integer.toString(event.getLine())) 203 .replace(MESSAGE_PLACEHOLDER, escape(event.getMessage())) 204 .replace(RULE_ID_PLACEHOLDER, event.getViolation().getKey()) 205 ); 206 } 207 else { 208 results.add(resultLineOnly 209 .replace(SEVERITY_LEVEL_PLACEHOLDER, renderSeverityLevel(event.getSeverityLevel())) 210 .replace(URI_PLACEHOLDER, renderFileNameUri(event.getFileName())) 211 .replace(LINE_PLACEHOLDER, Integer.toString(event.getLine())) 212 .replace(MESSAGE_PLACEHOLDER, escape(event.getMessage())) 213 .replace(RULE_ID_PLACEHOLDER, event.getViolation().getKey()) 214 ); 215 } 216 } 217 218 @Override 219 public void addException(AuditEvent event, Throwable throwable) { 220 final StringWriter stringWriter = new StringWriter(); 221 final PrintWriter printer = new PrintWriter(stringWriter); 222 throwable.printStackTrace(printer); 223 if (event.getFileName() == null) { 224 results.add(resultErrorOnly 225 .replace(SEVERITY_LEVEL_PLACEHOLDER, renderSeverityLevel(event.getSeverityLevel())) 226 .replace(MESSAGE_PLACEHOLDER, escape(stringWriter.toString())) 227 ); 228 } 229 else { 230 results.add(resultFileOnly 231 .replace(SEVERITY_LEVEL_PLACEHOLDER, renderSeverityLevel(event.getSeverityLevel())) 232 .replace(URI_PLACEHOLDER, renderFileNameUri(event.getFileName())) 233 .replace(MESSAGE_PLACEHOLDER, escape(stringWriter.toString())) 234 ); 235 } 236 } 237 238 @Override 239 public void fileStarted(AuditEvent event) { 240 // No need to implement this method in this class 241 } 242 243 @Override 244 public void fileFinished(AuditEvent event) { 245 // No need to implement this method in this class 246 } 247 248 /** 249 * Render the file name URI for the given file name. 250 * 251 * @param fileName the file name to render the URI for 252 * @return the rendered URI for the given file name 253 */ 254 private static String renderFileNameUri(final String fileName) { 255 String normalized = 256 A_SPACE_PATTERN 257 .matcher(TWO_BACKSLASHES_PATTERN.matcher(fileName).replaceAll("/")) 258 .replaceAll("%20"); 259 if (WINDOWS_DRIVE_LETTER_PATTERN.matcher(normalized).find()) { 260 normalized = '/' + normalized; 261 } 262 return "file:" + normalized; 263 } 264 265 /** 266 * Render the severity level into SARIF severity level. 267 * 268 * @param severityLevel the Severity level. 269 * @return the rendered severity level in string. 270 */ 271 private static String renderSeverityLevel(SeverityLevel severityLevel) { 272 final String renderedSeverityLevel; 273 switch (severityLevel) { 274 case IGNORE: 275 renderedSeverityLevel = "none"; 276 break; 277 case INFO: 278 renderedSeverityLevel = "note"; 279 break; 280 case WARNING: 281 renderedSeverityLevel = "warning"; 282 break; 283 case ERROR: 284 default: 285 renderedSeverityLevel = "error"; 286 break; 287 } 288 return renderedSeverityLevel; 289 } 290 291 /** 292 * Escape \b, \f, \n, \r, \t, \", \\ and U+0000 through U+001F. 293 * See <a href="https://www.ietf.org/rfc/rfc4627.txt">reference</a> - 2.5. Strings 294 * 295 * @param value the value to escape. 296 * @return the escaped value if necessary. 297 */ 298 public static String escape(String value) { 299 final int length = value.length(); 300 final StringBuilder sb = new StringBuilder(length); 301 for (int i = 0; i < length; i++) { 302 final char chr = value.charAt(i); 303 switch (chr) { 304 case '"': 305 sb.append("\\\""); 306 break; 307 case '\\': 308 sb.append(TWO_BACKSLASHES); 309 break; 310 case '\b': 311 sb.append("\\b"); 312 break; 313 case '\f': 314 sb.append("\\f"); 315 break; 316 case '\n': 317 sb.append("\\n"); 318 break; 319 case '\r': 320 sb.append("\\r"); 321 break; 322 case '\t': 323 sb.append("\\t"); 324 break; 325 case '/': 326 sb.append("\\/"); 327 break; 328 default: 329 if (chr <= UNICODE_ESCAPE_UPPER_LIMIT) { 330 sb.append(escapeUnicode1F(chr)); 331 } 332 else { 333 sb.append(chr); 334 } 335 break; 336 } 337 } 338 return sb.toString(); 339 } 340 341 /** 342 * Escape the character between 0x00 to 0x1F in JSON. 343 * 344 * @param chr the character to be escaped. 345 * @return the escaped string. 346 */ 347 private static String escapeUnicode1F(char chr) { 348 final String hexString = Integer.toHexString(chr); 349 return "\\u" 350 + "0".repeat(UNICODE_LENGTH - hexString.length()) 351 + hexString.toUpperCase(Locale.US); 352 } 353 354 /** 355 * Read string from given resource. 356 * 357 * @param name name of the desired resource 358 * @return the string content from the give resource 359 * @throws IOException if there is reading errors 360 */ 361 public static String readResource(String name) throws IOException { 362 try (InputStream inputStream = SarifLogger.class.getResourceAsStream(name); 363 ByteArrayOutputStream result = new ByteArrayOutputStream()) { 364 if (inputStream == null) { 365 throw new IOException("Cannot find the resource " + name); 366 } 367 final byte[] buffer = new byte[BUFFER_SIZE]; 368 int length = 0; 369 while (length != -1) { 370 result.write(buffer, 0, length); 371 length = inputStream.read(buffer); 372 } 373 return result.toString(StandardCharsets.UTF_8); 374 } 375 } 376}