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 IllegalArgumentException if outputStreamOptions is null
169     * @throws IOException if there is reading errors.
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) throws IOException {
177        this(outputStream, OutputStreamOptions.valueOf(outputStreamOptions.name()));
178    }
179
180    /**
181     * Creates a new {@code SarifLogger} instance.
182     *
183     * @param outputStream where to log audit events
184     * @param outputStreamOptions if {@code CLOSE} that should be closed in auditFinished()
185     * @throws IllegalArgumentException if outputStreamOptions is null
186     * @throws IOException if there is reading errors.
187     */
188    public SarifLogger(
189        OutputStream outputStream,
190        OutputStreamOptions outputStreamOptions) throws IOException {
191        if (outputStreamOptions == null) {
192            throw new IllegalArgumentException("Parameter outputStreamOptions can not be null");
193        }
194        writer = new PrintWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8));
195        closeStream = outputStreamOptions == OutputStreamOptions.CLOSE;
196        loadModuleMetadata();
197        report = readResource("/com/puppycrawl/tools/checkstyle/sarif/SarifReport.template");
198        resultLineColumn =
199            readResource("/com/puppycrawl/tools/checkstyle/sarif/ResultLineColumn.template");
200        resultLineOnly =
201            readResource("/com/puppycrawl/tools/checkstyle/sarif/ResultLineOnly.template");
202        resultFileOnly =
203            readResource("/com/puppycrawl/tools/checkstyle/sarif/ResultFileOnly.template");
204        resultErrorOnly =
205            readResource("/com/puppycrawl/tools/checkstyle/sarif/ResultErrorOnly.template");
206        rule = readResource("/com/puppycrawl/tools/checkstyle/sarif/Rule.template");
207        messageStrings =
208            readResource("/com/puppycrawl/tools/checkstyle/sarif/MessageStrings.template");
209        messageTextOnly =
210            readResource("/com/puppycrawl/tools/checkstyle/sarif/MessageTextOnly.template");
211        messageWithId =
212            readResource("/com/puppycrawl/tools/checkstyle/sarif/MessageWithId.template");
213    }
214
215    /**
216     * Loads all available module metadata from XML files.
217     */
218    private void loadModuleMetadata() {
219        final List<ModuleDetails> allModules =
220                XmlMetaReader.readAllModulesIncludingThirdPartyIfAny();
221        for (ModuleDetails module : allModules) {
222            allModuleMetadata.put(module.getFullQualifiedName(), module);
223        }
224    }
225
226    @Override
227    protected void finishLocalSetup() {
228        // No code by default
229    }
230
231    @Override
232    public void auditStarted(AuditEvent event) {
233        // No code by default
234    }
235
236    @Override
237    public void auditFinished(AuditEvent event) {
238        String rendered = replaceVersionString(report);
239        rendered = rendered
240                .replace(RESULTS_PLACEHOLDER, String.join(COMMA_LINE_SEPARATOR, results))
241                .replace(RULES_PLACEHOLDER, String.join(COMMA_LINE_SEPARATOR, generateRules()));
242        writer.print(rendered);
243        if (closeStream) {
244            writer.close();
245        }
246        else {
247            writer.flush();
248        }
249    }
250
251    /**
252     * Generates rules from cached rule metadata.
253     *
254     * @return list of rules
255     */
256    private List<String> generateRules() {
257        final List<String> result = new ArrayList<>();
258        for (Map.Entry<RuleKey, ModuleDetails> entry : ruleMetadata.entrySet()) {
259            final RuleKey ruleKey = entry.getKey();
260            final ModuleDetails module = entry.getValue();
261            final String shortDescription;
262            final String fullDescription;
263            final String messageStringsFragment;
264            if (module == null) {
265                shortDescription = CommonUtil.baseClassName(ruleKey.sourceName());
266                fullDescription = "No description available";
267                messageStringsFragment = "";
268            }
269            else {
270                shortDescription = module.getName();
271                fullDescription = module.getDescription();
272                messageStringsFragment = String.join(COMMA_LINE_SEPARATOR,
273                        generateMessageStrings(module));
274            }
275            result.add(rule
276                    .replace(RULE_ID_PLACEHOLDER, ruleKey.toRuleId())
277                    .replace("${shortDescription}", shortDescription)
278                    .replace("${fullDescription}", escape(fullDescription))
279                    .replace("${messageStrings}", messageStringsFragment));
280        }
281        return result;
282    }
283
284    /**
285     * Generates message strings for a given module.
286     *
287     * @param module the module
288     * @return the generated message strings
289     */
290    private List<String> generateMessageStrings(ModuleDetails module) {
291        final Map<String, String> messages = getMessages(module);
292        return module.getViolationMessageKeys().stream()
293                .filter(messages::containsKey)
294                .map(key -> {
295                    final String message = messages.get(key);
296                    return messageStrings
297                            .replace("${key}", key)
298                            .replace("${text}", escape(message));
299                })
300                .toList();
301    }
302
303    /**
304     * Gets a map of message keys to their message strings for a module.
305     *
306     * @param moduleDetails the module details
307     * @return map of message keys to message strings
308     */
309    private static Map<String, String> getMessages(ModuleDetails moduleDetails) {
310        final String fullQualifiedName = moduleDetails.getFullQualifiedName();
311        final Map<String, String> result = new LinkedHashMap<>();
312        try {
313            final int lastDot = fullQualifiedName.lastIndexOf('.');
314            final String packageName = fullQualifiedName.substring(0, lastDot);
315            final String bundleName = packageName + ".messages";
316            final Class<?> moduleClass = Class.forName(fullQualifiedName);
317            final ResourceBundle bundle = ResourceBundle.getBundle(
318                    bundleName,
319                    Locale.ROOT,
320                    moduleClass.getClassLoader(),
321                    new LocalizedMessage.Utf8Control()
322            );
323            for (String key : moduleDetails.getViolationMessageKeys()) {
324                result.put(key, bundle.getString(key));
325            }
326        }
327        catch (ClassNotFoundException | MissingResourceException ignored) {
328            // Return empty map when module class or resource bundle is not on classpath.
329            // Occurs with third-party modules that have XML metadata but missing implementation.
330        }
331        return result;
332    }
333
334    /**
335     * Returns the version string.
336     *
337     * @param report report content where replace should happen
338     * @return a version string based on the package implementation version
339     */
340    private static String replaceVersionString(String report) {
341        final String version = SarifLogger.class.getPackage().getImplementationVersion();
342        return report.replace(VERSION_PLACEHOLDER, Objects.toString(version, "null"));
343    }
344
345    @Override
346    public void addError(AuditEvent event) {
347        final RuleKey ruleKey = cacheRuleMetadata(event);
348        final String message = generateMessage(ruleKey, event);
349        if (event.getColumn() > 0) {
350            results.add(fillTemplate(resultLineColumn, Map.of(
351                SEVERITY_LEVEL_PLACEHOLDER, renderSeverityLevel(event.getSeverityLevel()),
352                URI_PLACEHOLDER, renderFileNameUri(event.getFileName()),
353                COLUMN_PLACEHOLDER, Integer.toString(event.getColumn()),
354                LINE_PLACEHOLDER, Integer.toString(event.getLine()),
355                MESSAGE_PLACEHOLDER, message,
356                RULE_ID_PLACEHOLDER, ruleKey.toRuleId())));
357        }
358        else {
359            results.add(fillTemplate(resultLineOnly, Map.of(
360                SEVERITY_LEVEL_PLACEHOLDER, renderSeverityLevel(event.getSeverityLevel()),
361                URI_PLACEHOLDER, renderFileNameUri(event.getFileName()),
362                LINE_PLACEHOLDER, Integer.toString(event.getLine()),
363                MESSAGE_PLACEHOLDER, message,
364                RULE_ID_PLACEHOLDER, ruleKey.toRuleId())));
365        }
366    }
367
368    /**
369     * Caches rule metadata for a given audit event.
370     *
371     * @param event the audit event
372     * @return the composite key for the rule
373     */
374    private RuleKey cacheRuleMetadata(AuditEvent event) {
375        final String sourceName = event.getSourceName();
376        final RuleKey key = new RuleKey(sourceName, event.getModuleId());
377        final ModuleDetails module = allModuleMetadata.get(sourceName);
378        ruleMetadata.putIfAbsent(key, module);
379        return key;
380    }
381
382    /**
383     * Generate message for the given rule key and audit event.
384     *
385     * @param ruleKey the rule key
386     * @param event the audit event
387     * @return the generated message
388     */
389    private String generateMessage(RuleKey ruleKey, AuditEvent event) {
390        final String violationKey = event.getViolation().getKey();
391        final ModuleDetails module = ruleMetadata.get(ruleKey);
392        final String result;
393        if (module != null && module.getViolationMessageKeys().contains(violationKey)) {
394            result = messageWithId
395                    .replace(MESSAGE_ID_PLACEHOLDER, violationKey)
396                    .replace(MESSAGE_TEXT_PLACEHOLDER, escape(event.getMessage()));
397        }
398        else {
399            result = messageTextOnly
400                    .replace(MESSAGE_TEXT_PLACEHOLDER, escape(event.getMessage()));
401        }
402        return result;
403    }
404
405    @Override
406    public void addException(AuditEvent event, Throwable throwable) {
407        final StringWriter stringWriter = new StringWriter();
408        final PrintWriter printer = new PrintWriter(stringWriter);
409        throwable.printStackTrace(printer);
410        final String message = messageTextOnly
411                .replace(MESSAGE_TEXT_PLACEHOLDER, escape(stringWriter.toString()));
412        if (event.getFileName() == null) {
413            results.add(fillTemplate(resultErrorOnly, Map.of(
414                SEVERITY_LEVEL_PLACEHOLDER, renderSeverityLevel(event.getSeverityLevel()),
415                MESSAGE_PLACEHOLDER, message)));
416        }
417        else {
418            results.add(fillTemplate(resultFileOnly, Map.of(
419                SEVERITY_LEVEL_PLACEHOLDER, renderSeverityLevel(event.getSeverityLevel()),
420                URI_PLACEHOLDER, renderFileNameUri(event.getFileName()),
421                MESSAGE_PLACEHOLDER, message)));
422        }
423    }
424
425    @Override
426    public void fileStarted(AuditEvent event) {
427        // No need to implement this method in this class
428    }
429
430    @Override
431    public void fileFinished(AuditEvent event) {
432        // No need to implement this method in this class
433    }
434
435    /**
436     * Fill a template with its values in a single pass, so a value substituted for one
437     * placeholder is never scanned again and taken for another. A file name or message that
438     * happens to carry placeholder text is therefore kept verbatim instead of pulling
439     * another value into it.
440     *
441     * @param template the template to fill
442     * @param values the value to substitute for each placeholder
443     * @return the filled template
444     */
445    private static String fillTemplate(String template, Map<String, String> values) {
446        final Matcher matcher = PLACEHOLDER_PATTERN.matcher(template);
447        final StringBuilder result = new StringBuilder(256);
448        while (matcher.find()) {
449            final String placeholder = matcher.group();
450            final String value = values.getOrDefault(placeholder, placeholder);
451            matcher.appendReplacement(result, Matcher.quoteReplacement(value));
452        }
453        matcher.appendTail(result);
454        return result.toString();
455    }
456
457    /**
458     * Render the file name URI for the given file name.
459     *
460     * @param fileName the file name to render the URI for
461     * @return the rendered URI for the given file name
462     */
463    private static String renderFileNameUri(final String fileName) {
464        final String withoutSpaces =
465                A_SPACE_PATTERN
466                        .matcher(TWO_BACKSLASHES_PATTERN.matcher(fileName).replaceAll("/"))
467                        .replaceAll("%20");
468        String normalized = A_QUOTE_PATTERN.matcher(withoutSpaces).replaceAll("%22");
469        if (WINDOWS_DRIVE_LETTER_PATTERN.matcher(normalized).find()) {
470            normalized = '/' + normalized;
471        }
472        return "file:" + normalized;
473    }
474
475    /**
476     * Render the severity level into SARIF severity level.
477     *
478     * @param severityLevel the Severity level.
479     * @return the rendered severity level in string.
480     */
481    private static String renderSeverityLevel(SeverityLevel severityLevel) {
482        return switch (severityLevel) {
483            case IGNORE -> "none";
484            case INFO -> "note";
485            case WARNING -> "warning";
486            case ERROR -> "error";
487        };
488    }
489
490    /**
491     * Escape \b, \f, \n, \r, \t, \", \\ and U+0000 through U+001F.
492     * See <a href="https://www.ietf.org/rfc/rfc4627.txt">reference</a> - 2.5. Strings
493     *
494     * @param value the value to escape.
495     * @return the escaped value if necessary.
496     */
497    public static String escape(String value) {
498        final int length = value.length();
499        final StringBuilder sb = new StringBuilder(length);
500        for (int i = 0; i < length; i++) {
501            final char chr = value.charAt(i);
502            final String replacement = switch (chr) {
503                case '"' -> "\\\"";
504                case '\\' -> TWO_BACKSLASHES;
505                case '\b' -> "\\b";
506                case '\f' -> "\\f";
507                case '\n' -> "\\n";
508                case '\r' -> "\\r";
509                case '\t' -> "\\t";
510                case '/' -> "\\/";
511                default -> {
512                    if (chr <= UNICODE_ESCAPE_UPPER_LIMIT) {
513                        yield escapeUnicode1F(chr);
514                    }
515                    yield Character.toString(chr);
516                }
517            };
518            sb.append(replacement);
519        }
520
521        return sb.toString();
522    }
523
524    /**
525     * Escape the character between 0x00 to 0x1F in JSON.
526     *
527     * @param chr the character to be escaped.
528     * @return the escaped string.
529     */
530    private static String escapeUnicode1F(char chr) {
531        final String hexString = Integer.toHexString(chr);
532        return "\\u"
533                + "0".repeat(UNICODE_LENGTH - hexString.length())
534                + hexString.toUpperCase(Locale.US);
535    }
536
537    /**
538     * Read string from given resource.
539     *
540     * @param name name of the desired resource
541     * @return the string content from the give resource
542     * @throws IOException if there is reading errors
543     */
544    public static String readResource(String name) throws IOException {
545        try (InputStream inputStream = SarifLogger.class.getResourceAsStream(name);
546             ByteArrayOutputStream result = new ByteArrayOutputStream()) {
547            if (inputStream == null) {
548                throw new IOException("Cannot find the resource " + name);
549            }
550            final byte[] buffer = new byte[BUFFER_SIZE];
551            int length = 0;
552            while (length != -1) {
553                result.write(buffer, 0, length);
554                length = inputStream.read(buffer);
555            }
556            return result.toString(StandardCharsets.UTF_8);
557        }
558    }
559
560    /**
561     * Composite key for uniquely identifying a rule by source name and module ID.
562     *
563     * @param sourceName  The fully qualified source class name.
564     * @param moduleId  The module ID from configuration (can be null).
565     */
566    private record RuleKey(String sourceName, String moduleId) {
567        /**
568         * Converts this key to a SARIF rule ID string.
569         *
570         * @return rule ID in format: sourceName[#moduleId]
571         */
572        private String toRuleId() {
573            final String result;
574            if (moduleId == null) {
575                result = sourceName;
576            }
577            else {
578                result = sourceName + '#' + moduleId;
579            }
580            return result;
581        }
582    }
583
584}