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