1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20 package com.puppycrawl.tools.checkstyle;
21
22 import java.io.ByteArrayOutputStream;
23 import java.io.IOException;
24 import java.io.InputStream;
25 import java.io.OutputStream;
26 import java.io.OutputStreamWriter;
27 import java.io.PrintWriter;
28 import java.io.StringWriter;
29 import java.nio.charset.StandardCharsets;
30 import java.util.ArrayList;
31 import java.util.HashMap;
32 import java.util.LinkedHashMap;
33 import java.util.List;
34 import java.util.Locale;
35 import java.util.Map;
36 import java.util.MissingResourceException;
37 import java.util.Objects;
38 import java.util.ResourceBundle;
39 import java.util.regex.Pattern;
40
41 import com.puppycrawl.tools.checkstyle.api.AuditEvent;
42 import com.puppycrawl.tools.checkstyle.api.AuditListener;
43 import com.puppycrawl.tools.checkstyle.api.AutomaticBean;
44 import com.puppycrawl.tools.checkstyle.api.SeverityLevel;
45 import com.puppycrawl.tools.checkstyle.meta.ModuleDetails;
46 import com.puppycrawl.tools.checkstyle.meta.XmlMetaReader;
47 import com.puppycrawl.tools.checkstyle.utils.CommonUtil;
48
49
50
51
52
53
54 public final class SarifLogger extends AbstractAutomaticBean implements AuditListener {
55
56
57 private static final int UNICODE_LENGTH = 4;
58
59
60 private static final int UNICODE_ESCAPE_UPPER_LIMIT = 0x1F;
61
62
63 private static final int BUFFER_SIZE = 1024;
64
65
66 private static final String MESSAGE_PLACEHOLDER = "${message}";
67
68
69 private static final String MESSAGE_TEXT_PLACEHOLDER = "${messageText}";
70
71
72 private static final String MESSAGE_ID_PLACEHOLDER = "${messageId}";
73
74
75 private static final String SEVERITY_LEVEL_PLACEHOLDER = "${severityLevel}";
76
77
78 private static final String URI_PLACEHOLDER = "${uri}";
79
80
81 private static final String LINE_PLACEHOLDER = "${line}";
82
83
84 private static final String COLUMN_PLACEHOLDER = "${column}";
85
86
87 private static final String RULE_ID_PLACEHOLDER = "${ruleId}";
88
89
90 private static final String VERSION_PLACEHOLDER = "${version}";
91
92
93 private static final String RESULTS_PLACEHOLDER = "${results}";
94
95
96 private static final String RULES_PLACEHOLDER = "${rules}";
97
98
99 private static final String TWO_BACKSLASHES = "\\\\";
100
101
102 private static final Pattern A_SPACE_PATTERN = Pattern.compile(" ");
103
104
105 private static final Pattern TWO_BACKSLASHES_PATTERN = Pattern.compile(TWO_BACKSLASHES);
106
107
108 private static final Pattern WINDOWS_DRIVE_LETTER_PATTERN =
109 Pattern.compile("\\A[A-Z]:", Pattern.CASE_INSENSITIVE);
110
111
112 private static final String COMMA_LINE_SEPARATOR = ",\n";
113
114
115 private final PrintWriter writer;
116
117
118 private final boolean closeStream;
119
120
121 private final List<String> results = new ArrayList<>();
122
123
124 private final Map<String, ModuleDetails> allModuleMetadata = new HashMap<>();
125
126
127 private final Map<RuleKey, ModuleDetails> ruleMetadata = new LinkedHashMap<>();
128
129
130 private final String report;
131
132
133 private final String resultLineColumn;
134
135
136 private final String resultLineOnly;
137
138
139 private final String resultFileOnly;
140
141
142 private final String resultErrorOnly;
143
144
145 private final String rule;
146
147
148 private final String messageStrings;
149
150
151 private final String messageTextOnly;
152
153
154 private final String messageWithId;
155
156
157
158
159
160
161
162
163
164
165
166
167 public SarifLogger(
168 OutputStream outputStream,
169 AutomaticBean.OutputStreamOptions outputStreamOptions) throws IOException {
170 this(outputStream, OutputStreamOptions.valueOf(outputStreamOptions.name()));
171 }
172
173
174
175
176
177
178
179
180
181 public SarifLogger(
182 OutputStream outputStream,
183 OutputStreamOptions outputStreamOptions) throws IOException {
184 if (outputStreamOptions == null) {
185 throw new IllegalArgumentException("Parameter outputStreamOptions can not be null");
186 }
187 writer = new PrintWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8));
188 closeStream = outputStreamOptions == OutputStreamOptions.CLOSE;
189 loadModuleMetadata();
190 report = readResource("/com/puppycrawl/tools/checkstyle/sarif/SarifReport.template");
191 resultLineColumn =
192 readResource("/com/puppycrawl/tools/checkstyle/sarif/ResultLineColumn.template");
193 resultLineOnly =
194 readResource("/com/puppycrawl/tools/checkstyle/sarif/ResultLineOnly.template");
195 resultFileOnly =
196 readResource("/com/puppycrawl/tools/checkstyle/sarif/ResultFileOnly.template");
197 resultErrorOnly =
198 readResource("/com/puppycrawl/tools/checkstyle/sarif/ResultErrorOnly.template");
199 rule = readResource("/com/puppycrawl/tools/checkstyle/sarif/Rule.template");
200 messageStrings =
201 readResource("/com/puppycrawl/tools/checkstyle/sarif/MessageStrings.template");
202 messageTextOnly =
203 readResource("/com/puppycrawl/tools/checkstyle/sarif/MessageTextOnly.template");
204 messageWithId =
205 readResource("/com/puppycrawl/tools/checkstyle/sarif/MessageWithId.template");
206 }
207
208
209
210
211 private void loadModuleMetadata() {
212 final List<ModuleDetails> allModules =
213 XmlMetaReader.readAllModulesIncludingThirdPartyIfAny();
214 for (ModuleDetails module : allModules) {
215 allModuleMetadata.put(module.getFullQualifiedName(), module);
216 }
217 }
218
219 @Override
220 protected void finishLocalSetup() {
221
222 }
223
224 @Override
225 public void auditStarted(AuditEvent event) {
226
227 }
228
229 @Override
230 public void auditFinished(AuditEvent event) {
231 String rendered = replaceVersionString(report);
232 rendered = rendered
233 .replace(RESULTS_PLACEHOLDER, String.join(COMMA_LINE_SEPARATOR, results))
234 .replace(RULES_PLACEHOLDER, String.join(COMMA_LINE_SEPARATOR, generateRules()));
235 writer.print(rendered);
236 if (closeStream) {
237 writer.close();
238 }
239 else {
240 writer.flush();
241 }
242 }
243
244
245
246
247
248
249 private List<String> generateRules() {
250 final List<String> result = new ArrayList<>();
251 for (Map.Entry<RuleKey, ModuleDetails> entry : ruleMetadata.entrySet()) {
252 final RuleKey ruleKey = entry.getKey();
253 final ModuleDetails module = entry.getValue();
254 final String shortDescription;
255 final String fullDescription;
256 final String messageStringsFragment;
257 if (module == null) {
258 shortDescription = CommonUtil.baseClassName(ruleKey.sourceName());
259 fullDescription = "No description available";
260 messageStringsFragment = "";
261 }
262 else {
263 shortDescription = module.getName();
264 fullDescription = module.getDescription();
265 messageStringsFragment = String.join(COMMA_LINE_SEPARATOR,
266 generateMessageStrings(module));
267 }
268 result.add(rule
269 .replace(RULE_ID_PLACEHOLDER, ruleKey.toRuleId())
270 .replace("${shortDescription}", shortDescription)
271 .replace("${fullDescription}", escape(fullDescription))
272 .replace("${messageStrings}", messageStringsFragment));
273 }
274 return result;
275 }
276
277
278
279
280
281
282
283 private List<String> generateMessageStrings(ModuleDetails module) {
284 final Map<String, String> messages = getMessages(module);
285 return module.getViolationMessageKeys().stream()
286 .filter(messages::containsKey).map(key -> {
287 final String message = messages.get(key);
288 return messageStrings
289 .replace("${key}", key)
290 .replace("${text}", escape(message));
291 }).toList();
292 }
293
294
295
296
297
298
299
300 private static Map<String, String> getMessages(ModuleDetails moduleDetails) {
301 final String fullQualifiedName = moduleDetails.getFullQualifiedName();
302 final Map<String, String> result = new LinkedHashMap<>();
303 try {
304 final int lastDot = fullQualifiedName.lastIndexOf('.');
305 final String packageName = fullQualifiedName.substring(0, lastDot);
306 final String bundleName = packageName + ".messages";
307 final Class<?> moduleClass = Class.forName(fullQualifiedName);
308 final ResourceBundle bundle = ResourceBundle.getBundle(
309 bundleName,
310 Locale.ROOT,
311 moduleClass.getClassLoader(),
312 new LocalizedMessage.Utf8Control()
313 );
314 for (String key : moduleDetails.getViolationMessageKeys()) {
315 result.put(key, bundle.getString(key));
316 }
317 }
318 catch (ClassNotFoundException | MissingResourceException ignored) {
319
320
321 }
322 return result;
323 }
324
325
326
327
328
329
330
331 private static String replaceVersionString(String report) {
332 final String version = SarifLogger.class.getPackage().getImplementationVersion();
333 return report.replace(VERSION_PLACEHOLDER, Objects.toString(version, "null"));
334 }
335
336 @Override
337 public void addError(AuditEvent event) {
338 final RuleKey ruleKey = cacheRuleMetadata(event);
339 final String message = generateMessage(ruleKey, event);
340 if (event.getColumn() > 0) {
341 results.add(resultLineColumn
342 .replace(SEVERITY_LEVEL_PLACEHOLDER, renderSeverityLevel(event.getSeverityLevel()))
343 .replace(URI_PLACEHOLDER, renderFileNameUri(event.getFileName()))
344 .replace(COLUMN_PLACEHOLDER, Integer.toString(event.getColumn()))
345 .replace(LINE_PLACEHOLDER, Integer.toString(event.getLine()))
346 .replace(MESSAGE_PLACEHOLDER, message)
347 .replace(RULE_ID_PLACEHOLDER, ruleKey.toRuleId())
348 );
349 }
350 else {
351 results.add(resultLineOnly
352 .replace(SEVERITY_LEVEL_PLACEHOLDER, renderSeverityLevel(event.getSeverityLevel()))
353 .replace(URI_PLACEHOLDER, renderFileNameUri(event.getFileName()))
354 .replace(LINE_PLACEHOLDER, Integer.toString(event.getLine()))
355 .replace(MESSAGE_PLACEHOLDER, message)
356 .replace(RULE_ID_PLACEHOLDER, ruleKey.toRuleId())
357 );
358 }
359 }
360
361
362
363
364
365
366
367 private RuleKey cacheRuleMetadata(AuditEvent event) {
368 final String sourceName = event.getSourceName();
369 final RuleKey key = new RuleKey(sourceName, event.getModuleId());
370 final ModuleDetails module = allModuleMetadata.get(sourceName);
371 ruleMetadata.putIfAbsent(key, module);
372 return key;
373 }
374
375
376
377
378
379
380
381
382 private String generateMessage(RuleKey ruleKey, AuditEvent event) {
383 final String violationKey = event.getViolation().getKey();
384 final ModuleDetails module = ruleMetadata.get(ruleKey);
385 final String result;
386 if (module != null && module.getViolationMessageKeys().contains(violationKey)) {
387 result = messageWithId
388 .replace(MESSAGE_ID_PLACEHOLDER, violationKey)
389 .replace(MESSAGE_TEXT_PLACEHOLDER, escape(event.getMessage()));
390 }
391 else {
392 result = messageTextOnly
393 .replace(MESSAGE_TEXT_PLACEHOLDER, escape(event.getMessage()));
394 }
395 return result;
396 }
397
398 @Override
399 public void addException(AuditEvent event, Throwable throwable) {
400 final StringWriter stringWriter = new StringWriter();
401 final PrintWriter printer = new PrintWriter(stringWriter);
402 throwable.printStackTrace(printer);
403 final String message = messageTextOnly
404 .replace(MESSAGE_TEXT_PLACEHOLDER, escape(stringWriter.toString()));
405 if (event.getFileName() == null) {
406 results.add(resultErrorOnly
407 .replace(SEVERITY_LEVEL_PLACEHOLDER, renderSeverityLevel(event.getSeverityLevel()))
408 .replace(MESSAGE_PLACEHOLDER, message)
409 );
410 }
411 else {
412 results.add(resultFileOnly
413 .replace(SEVERITY_LEVEL_PLACEHOLDER, renderSeverityLevel(event.getSeverityLevel()))
414 .replace(URI_PLACEHOLDER, renderFileNameUri(event.getFileName()))
415 .replace(MESSAGE_PLACEHOLDER, message)
416 );
417 }
418 }
419
420 @Override
421 public void fileStarted(AuditEvent event) {
422
423 }
424
425 @Override
426 public void fileFinished(AuditEvent event) {
427
428 }
429
430
431
432
433
434
435
436 private static String renderFileNameUri(final String fileName) {
437 String normalized =
438 A_SPACE_PATTERN
439 .matcher(TWO_BACKSLASHES_PATTERN.matcher(fileName).replaceAll("/"))
440 .replaceAll("%20");
441 if (WINDOWS_DRIVE_LETTER_PATTERN.matcher(normalized).find()) {
442 normalized = '/' + normalized;
443 }
444 return "file:" + normalized;
445 }
446
447
448
449
450
451
452
453 private static String renderSeverityLevel(SeverityLevel severityLevel) {
454 return switch (severityLevel) {
455 case IGNORE -> "none";
456 case INFO -> "note";
457 case WARNING -> "warning";
458 case ERROR -> "error";
459 };
460 }
461
462
463
464
465
466
467
468
469 public static String escape(String value) {
470 final int length = value.length();
471 final StringBuilder sb = new StringBuilder(length);
472 for (int i = 0; i < length; i++) {
473 final char chr = value.charAt(i);
474 final String replacement = switch (chr) {
475 case '"' -> "\\\"";
476 case '\\' -> TWO_BACKSLASHES;
477 case '\b' -> "\\b";
478 case '\f' -> "\\f";
479 case '\n' -> "\\n";
480 case '\r' -> "\\r";
481 case '\t' -> "\\t";
482 case '/' -> "\\/";
483 default -> {
484 if (chr <= UNICODE_ESCAPE_UPPER_LIMIT) {
485 yield escapeUnicode1F(chr);
486 }
487 yield Character.toString(chr);
488 }
489 };
490 sb.append(replacement);
491 }
492
493 return sb.toString();
494 }
495
496
497
498
499
500
501
502 private static String escapeUnicode1F(char chr) {
503 final String hexString = Integer.toHexString(chr);
504 return "\\u"
505 + "0".repeat(UNICODE_LENGTH - hexString.length())
506 + hexString.toUpperCase(Locale.US);
507 }
508
509
510
511
512
513
514
515
516 public static String readResource(String name) throws IOException {
517 try (InputStream inputStream = SarifLogger.class.getResourceAsStream(name);
518 ByteArrayOutputStream result = new ByteArrayOutputStream()) {
519 if (inputStream == null) {
520 throw new IOException("Cannot find the resource " + name);
521 }
522 final byte[] buffer = new byte[BUFFER_SIZE];
523 int length = 0;
524 while (length != -1) {
525 result.write(buffer, 0, length);
526 length = inputStream.read(buffer);
527 }
528 return result.toString(StandardCharsets.UTF_8);
529 }
530 }
531
532
533
534
535
536
537
538 private record RuleKey(String sourceName, String moduleId) {
539
540
541
542
543
544 private String toRuleId() {
545 final String result;
546 if (moduleId == null) {
547 result = sourceName;
548 }
549 else {
550 result = sourceName + '#' + moduleId;
551 }
552 return result;
553 }
554 }
555 }