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