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