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.HashMap;
032import java.util.LinkedHashMap;
033import java.util.List;
034import java.util.Locale;
035import java.util.Map;
036import java.util.MissingResourceException;
037import java.util.ResourceBundle;
038import java.util.regex.Pattern;
039
040import com.puppycrawl.tools.checkstyle.api.AuditEvent;
041import com.puppycrawl.tools.checkstyle.api.AuditListener;
042import com.puppycrawl.tools.checkstyle.api.AutomaticBean;
043import com.puppycrawl.tools.checkstyle.api.SeverityLevel;
044import com.puppycrawl.tools.checkstyle.meta.ModuleDetails;
045import com.puppycrawl.tools.checkstyle.meta.XmlMetaReader;
046import com.puppycrawl.tools.checkstyle.utils.CommonUtil;
047
048/**
049 * Simple SARIF logger.
050 * SARIF stands for the static analysis results interchange format.
051 * See <a href="https://sarifweb.azurewebsites.net/">reference</a>
052 */
053public final class SarifLogger extends AbstractAutomaticBean implements AuditListener {
054
055 /** The length of unicode placeholder. */
056 private static final int UNICODE_LENGTH = 4;
057
058 /** Unicode escaping upper limit. */
059 private static final int UNICODE_ESCAPE_UPPER_LIMIT = 0x1F;
060
061 /** Input stream buffer size. */
062 private static final int BUFFER_SIZE = 1024;
063
064 /** The placeholder for message. */
065 private static final String MESSAGE_PLACEHOLDER = "${message}";
066
067 /** The placeholder for message text. */
068 private static final String MESSAGE_TEXT_PLACEHOLDER = "${messageText}";
069
070 /** The placeholder for message id. */
071 private static final String MESSAGE_ID_PLACEHOLDER = "${messageId}";
072
073 /** The placeholder for severity level. */
074 private static final String SEVERITY_LEVEL_PLACEHOLDER = "${severityLevel}";
075
076 /** The placeholder for uri. */
077 private static final String URI_PLACEHOLDER = "${uri}";
078
079 /** The placeholder for line. */
080 private static final String LINE_PLACEHOLDER = "${line}";
081
082 /** The placeholder for column. */
083 private static final String COLUMN_PLACEHOLDER = "${column}";
084
085 /** The placeholder for rule id. */
086 private static final String RULE_ID_PLACEHOLDER = "${ruleId}";
087
088 /** The placeholder for version. */
089 private static final String VERSION_PLACEHOLDER = "${version}";
090
091 /** The placeholder for results. */
092 private static final String RESULTS_PLACEHOLDER = "${results}";
093
094 /** The placeholder for rules. */
095 private static final String RULES_PLACEHOLDER = "${rules}";
096
097 /** Two backslashes to not duplicate strings. */
098 private static final String TWO_BACKSLASHES = "\\\\";
099
100 /** A pattern for two backslashes. */
101 private static final Pattern A_SPACE_PATTERN = Pattern.compile(" ");
102
103 /** A pattern for two backslashes. */
104 private static final Pattern TWO_BACKSLASHES_PATTERN = Pattern.compile(TWO_BACKSLASHES);
105
106 /** A pattern to match a file with a Windows drive letter. */
107 private static final Pattern WINDOWS_DRIVE_LETTER_PATTERN =
108 Pattern.compile("\\A[A-Z]:", Pattern.CASE_INSENSITIVE);
109
110 /** Comma and line separator. */
111 private static final String COMMA_LINE_SEPARATOR = ",\n";
112
113 /** Helper writer that allows easy encoding and printing. */
114 private final PrintWriter writer;
115
116 /** Close output stream in auditFinished. */
117 private final boolean closeStream;
118
119 /** The results. */
120 private final List<String> results = new ArrayList<>();
121
122 /** Map of all available module metadata by fully qualified name. */
123 private final Map<String, ModuleDetails> allModuleMetadata = new HashMap<>();
124
125 /** Map to store rule metadata by composite key (sourceName, moduleId). */
126 private final Map<RuleKey, ModuleDetails> ruleMetadata = new LinkedHashMap<>();
127
128 /** Content for the entire report. */
129 private final String report;
130
131 /** Content for result representing an error with source line and column. */
132 private final String resultLineColumn;
133
134 /** Content for result representing an error with source line only. */
135 private final String resultLineOnly;
136
137 /** Content for result representing an error with filename only and without source location. */
138 private final String resultFileOnly;
139
140 /** Content for result representing an error without filename or location. */
141 private final String resultErrorOnly;
142
143 /** Content for rule. */
144 private final String rule;
145
146 /** Content for messageStrings. */
147 private final String messageStrings;
148
149 /** Content for message with text only. */
150 private final String messageTextOnly;
151
152 /** Content for message with id. */
153 private final String messageWithId;
154
155 /**
156 * Creates a new {@code SarifLogger} instance.
157 *
158 * @param outputStream where to log audit events
159 * @param outputStreamOptions if {@code CLOSE} that should be closed in auditFinished()
160 * @throws IllegalArgumentException if outputStreamOptions is null
161 * @throws IOException if there is reading errors.
162 * @noinspection deprecation
163 * @noinspectionreason We are forced to keep AutomaticBean compatability
164 * because of maven-checkstyle-plugin. Until #12873.
165 */
166 public SarifLogger(
167 OutputStream outputStream,
168 AutomaticBean.OutputStreamOptions outputStreamOptions) throws IOException {
169 this(outputStream, OutputStreamOptions.valueOf(outputStreamOptions.name()));
170 }
171
172 /**
173 * Creates a new {@code SarifLogger} instance.
174 *
175 * @param outputStream where to log audit events
176 * @param outputStreamOptions if {@code CLOSE} that should be closed in auditFinished()
177 * @throws IllegalArgumentException if outputStreamOptions is null
178 * @throws IOException if there is reading errors.
179 */
180 public SarifLogger(
181 OutputStream outputStream,
182 OutputStreamOptions outputStreamOptions) throws IOException {
183 if (outputStreamOptions == null) {
184 throw new IllegalArgumentException("Parameter outputStreamOptions can not be null");
185 }
186 writer = new PrintWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8));
187 closeStream = outputStreamOptions == OutputStreamOptions.CLOSE;
188 loadModuleMetadata();
189 report = readResource("/com/puppycrawl/tools/checkstyle/sarif/SarifReport.template");
190 resultLineColumn =
191 readResource("/com/puppycrawl/tools/checkstyle/sarif/ResultLineColumn.template");
192 resultLineOnly =
193 readResource("/com/puppycrawl/tools/checkstyle/sarif/ResultLineOnly.template");
194 resultFileOnly =
195 readResource("/com/puppycrawl/tools/checkstyle/sarif/ResultFileOnly.template");
196 resultErrorOnly =
197 readResource("/com/puppycrawl/tools/checkstyle/sarif/ResultErrorOnly.template");
198 rule = readResource("/com/puppycrawl/tools/checkstyle/sarif/Rule.template");
199 messageStrings =
200 readResource("/com/puppycrawl/tools/checkstyle/sarif/MessageStrings.template");
201 messageTextOnly =
202 readResource("/com/puppycrawl/tools/checkstyle/sarif/MessageTextOnly.template");
203 messageWithId =
204 readResource("/com/puppycrawl/tools/checkstyle/sarif/MessageWithId.template");
205 }
206
207 /**
208 * Loads all available module metadata from XML files.
209 */
210 private void loadModuleMetadata() {
211 final List<ModuleDetails> allModules =
212 XmlMetaReader.readAllModulesIncludingThirdPartyIfAny();
213 for (ModuleDetails module : allModules) {
214 allModuleMetadata.put(module.getFullQualifiedName(), module);
215 }
216 }
217
218 @Override
219 protected void finishLocalSetup() {
220 // No code by default
221 }
222
223 @Override
224 public void auditStarted(AuditEvent event) {
225 // No code by default
226 }
227
228 @Override
229 public void auditFinished(AuditEvent event) {
230 String rendered = replaceVersionString(report);
231 rendered = rendered
232 .replace(RESULTS_PLACEHOLDER, String.join(COMMA_LINE_SEPARATOR, results))
233 .replace(RULES_PLACEHOLDER, String.join(COMMA_LINE_SEPARATOR, generateRules()));
234 writer.print(rendered);
235 if (closeStream) {
236 writer.close();
237 }
238 else {
239 writer.flush();
240 }
241 }
242
243 /**
244 * Generates rules from cached rule metadata.
245 *
246 * @return list of rules
247 */
248 private List<String> generateRules() {
249 final List<String> result = new ArrayList<>();
250 for (Map.Entry<RuleKey, ModuleDetails> entry : ruleMetadata.entrySet()) {
251 final RuleKey ruleKey = entry.getKey();
252 final ModuleDetails module = entry.getValue();
253 final String shortDescription;
254 final String fullDescription;
255 final String messageStringsFragment;
256 if (module == null) {
257 shortDescription = CommonUtil.baseClassName(ruleKey.sourceName());
258 fullDescription = "No description available";
259 messageStringsFragment = "";
260 }
261 else {
262 shortDescription = module.getName();
263 fullDescription = module.getDescription();
264 messageStringsFragment = String.join(COMMA_LINE_SEPARATOR,
265 generateMessageStrings(module));
266 }
267 result.add(rule
268 .replace(RULE_ID_PLACEHOLDER, ruleKey.toRuleId())
269 .replace("${shortDescription}", shortDescription)
270 .replace("${fullDescription}", escape(fullDescription))
271 .replace("${messageStrings}", messageStringsFragment));
272 }
273 return result;
274 }
275
276 /**
277 * Generates message strings for a given module.
278 *
279 * @param module the module
280 * @return the generated message strings
281 */
282 private List<String> generateMessageStrings(ModuleDetails module) {
283 final Map<String, String> messages = getMessages(module);
284 return module.getViolationMessageKeys().stream()
285 .filter(messages::containsKey).map(key -> {
286 final String message = messages.get(key);
287 return messageStrings
288 .replace("${key}", key)
289 .replace("${text}", escape(message));
290 }).toList();
291 }
292
293 /**
294 * Gets a map of message keys to their message strings for a module.
295 *
296 * @param moduleDetails the module details
297 * @return map of message keys to message strings
298 */
299 private static Map<String, String> getMessages(ModuleDetails moduleDetails) {
300 final String fullQualifiedName = moduleDetails.getFullQualifiedName();
301 final Map<String, String> result = new LinkedHashMap<>();
302 try {
303 final int lastDot = fullQualifiedName.lastIndexOf('.');
304 final String packageName = fullQualifiedName.substring(0, lastDot);
305 final String bundleName = packageName + ".messages";
306 final Class<?> moduleClass = Class.forName(fullQualifiedName);
307 final ResourceBundle bundle = ResourceBundle.getBundle(
308 bundleName,
309 Locale.ROOT,
310 moduleClass.getClassLoader(),
311 new LocalizedMessage.Utf8Control()
312 );
313 for (String key : moduleDetails.getViolationMessageKeys()) {
314 result.put(key, bundle.getString(key));
315 }
316 }
317 catch (ClassNotFoundException | MissingResourceException ignored) {
318 // Return empty map when module class or resource bundle is not on classpath.
319 // Occurs with third-party modules that have XML metadata but missing implementation.
320 }
321 return result;
322 }
323
324 /**
325 * Returns the version string.
326 *
327 * @param report report content where replace should happen
328 * @return a version string based on the package implementation version
329 */
330 private static String replaceVersionString(String report) {
331 final String version = SarifLogger.class.getPackage().getImplementationVersion();
332 return report.replace(VERSION_PLACEHOLDER, String.valueOf(version));
333 }
334
335 @Override
336 public void addError(AuditEvent event) {
337 final RuleKey ruleKey = cacheRuleMetadata(event);
338 final String message = generateMessage(ruleKey, event);
339 if (event.getColumn() > 0) {
340 results.add(resultLineColumn
341 .replace(SEVERITY_LEVEL_PLACEHOLDER, renderSeverityLevel(event.getSeverityLevel()))
342 .replace(URI_PLACEHOLDER, renderFileNameUri(event.getFileName()))
343 .replace(COLUMN_PLACEHOLDER, Integer.toString(event.getColumn()))
344 .replace(LINE_PLACEHOLDER, Integer.toString(event.getLine()))
345 .replace(MESSAGE_PLACEHOLDER, message)
346 .replace(RULE_ID_PLACEHOLDER, ruleKey.toRuleId())
347 );
348 }
349 else {
350 results.add(resultLineOnly
351 .replace(SEVERITY_LEVEL_PLACEHOLDER, renderSeverityLevel(event.getSeverityLevel()))
352 .replace(URI_PLACEHOLDER, renderFileNameUri(event.getFileName()))
353 .replace(LINE_PLACEHOLDER, Integer.toString(event.getLine()))
354 .replace(MESSAGE_PLACEHOLDER, message)
355 .replace(RULE_ID_PLACEHOLDER, ruleKey.toRuleId())
356 );
357 }
358 }
359
360 /**
361 * Caches rule metadata for a given audit event.
362 *
363 * @param event the audit event
364 * @return the composite key for the rule
365 */
366 private RuleKey cacheRuleMetadata(AuditEvent event) {
367 final String sourceName = event.getSourceName();
368 final RuleKey key = new RuleKey(sourceName, event.getModuleId());
369 final ModuleDetails module = allModuleMetadata.get(sourceName);
370 ruleMetadata.putIfAbsent(key, module);
371 return key;
372 }
373
374 /**
375 * Generate message for the given rule key and audit event.
376 *
377 * @param ruleKey the rule key
378 * @param event the audit event
379 * @return the generated message
380 */
381 private String generateMessage(RuleKey ruleKey, AuditEvent event) {
382 final String violationKey = event.getViolation().getKey();
383 final ModuleDetails module = ruleMetadata.get(ruleKey);
384 final String result;
385 if (module != null && module.getViolationMessageKeys().contains(violationKey)) {
386 result = messageWithId
387 .replace(MESSAGE_ID_PLACEHOLDER, violationKey)
388 .replace(MESSAGE_TEXT_PLACEHOLDER, escape(event.getMessage()));
389 }
390 else {
391 result = messageTextOnly
392 .replace(MESSAGE_TEXT_PLACEHOLDER, escape(event.getMessage()));
393 }
394 return result;
395 }
396
397 @Override
398 public void addException(AuditEvent event, Throwable throwable) {
399 final StringWriter stringWriter = new StringWriter();
400 final PrintWriter printer = new PrintWriter(stringWriter);
401 throwable.printStackTrace(printer);
402 final String message = messageTextOnly
403 .replace(MESSAGE_TEXT_PLACEHOLDER, escape(stringWriter.toString()));
404 if (event.getFileName() == null) {
405 results.add(resultErrorOnly
406 .replace(SEVERITY_LEVEL_PLACEHOLDER, renderSeverityLevel(event.getSeverityLevel()))
407 .replace(MESSAGE_PLACEHOLDER, message)
408 );
409 }
410 else {
411 results.add(resultFileOnly
412 .replace(SEVERITY_LEVEL_PLACEHOLDER, renderSeverityLevel(event.getSeverityLevel()))
413 .replace(URI_PLACEHOLDER, renderFileNameUri(event.getFileName()))
414 .replace(MESSAGE_PLACEHOLDER, message)
415 );
416 }
417 }
418
419 @Override
420 public void fileStarted(AuditEvent event) {
421 // No need to implement this method in this class
422 }
423
424 @Override
425 public void fileFinished(AuditEvent event) {
426 // No need to implement this method in this class
427 }
428
429 /**
430 * Render the file name URI for the given file name.
431 *
432 * @param fileName the file name to render the URI for
433 * @return the rendered URI for the given file name
434 */
435 private static String renderFileNameUri(final String fileName) {
436 String normalized =
437 A_SPACE_PATTERN
438 .matcher(TWO_BACKSLASHES_PATTERN.matcher(fileName).replaceAll("/"))
439 .replaceAll("%20");
440 if (WINDOWS_DRIVE_LETTER_PATTERN.matcher(normalized).find()) {
441 normalized = '/' + normalized;
442 }
443 return "file:" + normalized;
444 }
445
446 /**
447 * Render the severity level into SARIF severity level.
448 *
449 * @param severityLevel the Severity level.
450 * @return the rendered severity level in string.
451 */
452 private static String renderSeverityLevel(SeverityLevel severityLevel) {
453 return switch (severityLevel) {
454 case IGNORE -> "none";
455 case INFO -> "note";
456 case WARNING -> "warning";
457 case ERROR -> "error";
458 };
459 }
460
461 /**
462 * Escape \b, \f, \n, \r, \t, \", \\ and U+0000 through U+001F.
463 * See <a href="https://www.ietf.org/rfc/rfc4627.txt">reference</a> - 2.5. Strings
464 *
465 * @param value the value to escape.
466 * @return the escaped value if necessary.
467 */
468 public static String escape(String value) {
469 final int length = value.length();
470 final StringBuilder sb = new StringBuilder(length);
471 for (int i = 0; i < length; i++) {
472 final char chr = value.charAt(i);
473 final String replacement = switch (chr) {
474 case '"' -> "\\\"";
475 case '\\' -> TWO_BACKSLASHES;
476 case '\b' -> "\\b";
477 case '\f' -> "\\f";
478 case '\n' -> "\\n";
479 case '\r' -> "\\r";
480 case '\t' -> "\\t";
481 case '/' -> "\\/";
482 default -> {
483 if (chr <= UNICODE_ESCAPE_UPPER_LIMIT) {
484 yield escapeUnicode1F(chr);
485 }
486 yield Character.toString(chr);
487 }
488 };
489 sb.append(replacement);
490 }
491
492 return sb.toString();
493 }
494
495 /**
496 * Escape the character between 0x00 to 0x1F in JSON.
497 *
498 * @param chr the character to be escaped.
499 * @return the escaped string.
500 */
501 private static String escapeUnicode1F(char chr) {
502 final String hexString = Integer.toHexString(chr);
503 return "\\u"
504 + "0".repeat(UNICODE_LENGTH - hexString.length())
505 + hexString.toUpperCase(Locale.US);
506 }
507
508 /**
509 * Read string from given resource.
510 *
511 * @param name name of the desired resource
512 * @return the string content from the give resource
513 * @throws IOException if there is reading errors
514 */
515 public static String readResource(String name) throws IOException {
516 try (InputStream inputStream = SarifLogger.class.getResourceAsStream(name);
517 ByteArrayOutputStream result = new ByteArrayOutputStream()) {
518 if (inputStream == null) {
519 throw new IOException("Cannot find the resource " + name);
520 }
521 final byte[] buffer = new byte[BUFFER_SIZE];
522 int length = 0;
523 while (length != -1) {
524 result.write(buffer, 0, length);
525 length = inputStream.read(buffer);
526 }
527 return result.toString(StandardCharsets.UTF_8);
528 }
529 }
530
531 /**
532 * Composite key for uniquely identifying a rule by source name and module ID.
533 *
534 * @param sourceName The fully qualified source class name.
535 * @param moduleId The module ID from configuration (can be null).
536 */
537 private record RuleKey(String sourceName, String moduleId) {
538 /**
539 * Converts this key to a SARIF rule ID string.
540 *
541 * @return rule ID in format: sourceName[#moduleId]
542 */
543 private String toRuleId() {
544 final String result;
545 if (moduleId == null) {
546 result = sourceName;
547 }
548 else {
549 result = sourceName + '#' + moduleId;
550 }
551 return result;
552 }
553 }
554}