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.Pattern;
040
041import com.puppycrawl.tools.checkstyle.api.AuditEvent;
042import com.puppycrawl.tools.checkstyle.api.AuditListener;
043import com.puppycrawl.tools.checkstyle.api.AutomaticBean;
044import com.puppycrawl.tools.checkstyle.api.SeverityLevel;
045import com.puppycrawl.tools.checkstyle.meta.ModuleDetails;
046import com.puppycrawl.tools.checkstyle.meta.XmlMetaReader;
047import com.puppycrawl.tools.checkstyle.utils.CommonUtil;
048
049/**
050 * Simple SARIF logger.
051 * SARIF stands for the static analysis results interchange format.
052 * See <a href="https://sarifweb.azurewebsites.net/">reference</a>
053 */
054public final class SarifLogger extends AbstractAutomaticBean implements AuditListener {
055
056    /** The length of unicode placeholder. */
057    private static final int UNICODE_LENGTH = 4;
058
059    /** Unicode escaping upper limit. */
060    private static final int UNICODE_ESCAPE_UPPER_LIMIT = 0x1F;
061
062    /** Input stream buffer size. */
063    private static final int BUFFER_SIZE = 1024;
064
065    /** The placeholder for message. */
066    private static final String MESSAGE_PLACEHOLDER = "${message}";
067
068    /** The placeholder for message text. */
069    private static final String MESSAGE_TEXT_PLACEHOLDER = "${messageText}";
070
071    /** The placeholder for message id. */
072    private static final String MESSAGE_ID_PLACEHOLDER = "${messageId}";
073
074    /** The placeholder for severity level. */
075    private static final String SEVERITY_LEVEL_PLACEHOLDER = "${severityLevel}";
076
077    /** The placeholder for uri. */
078    private static final String URI_PLACEHOLDER = "${uri}";
079
080    /** The placeholder for line. */
081    private static final String LINE_PLACEHOLDER = "${line}";
082
083    /** The placeholder for column. */
084    private static final String COLUMN_PLACEHOLDER = "${column}";
085
086    /** The placeholder for rule id. */
087    private static final String RULE_ID_PLACEHOLDER = "${ruleId}";
088
089    /** The placeholder for version. */
090    private static final String VERSION_PLACEHOLDER = "${version}";
091
092    /** The placeholder for results. */
093    private static final String RESULTS_PLACEHOLDER = "${results}";
094
095    /** The placeholder for rules. */
096    private static final String RULES_PLACEHOLDER = "${rules}";
097
098    /** Two backslashes to not duplicate strings. */
099    private static final String TWO_BACKSLASHES = "\\\\";
100
101    /** A pattern for two backslashes. */
102    private static final Pattern A_SPACE_PATTERN = Pattern.compile(" ");
103
104    /** A pattern for a double quote. */
105    private static final Pattern A_QUOTE_PATTERN = Pattern.compile("\"");
106
107    /** A pattern for two backslashes. */
108    private static final Pattern TWO_BACKSLASHES_PATTERN = Pattern.compile(TWO_BACKSLASHES);
109
110    /** A pattern to match a file with a Windows drive letter. */
111    private static final Pattern WINDOWS_DRIVE_LETTER_PATTERN =
112            Pattern.compile("\\A[A-Z]:", Pattern.CASE_INSENSITIVE);
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 IllegalArgumentException if outputStreamOptions is null
165     * @throws IOException if there is reading errors.
166     * @noinspection deprecation
167     * @noinspectionreason We are forced to keep AutomaticBean compatability
168     *     because of maven-checkstyle-plugin. Until #12873.
169     */
170    public SarifLogger(
171        OutputStream outputStream,
172        AutomaticBean.OutputStreamOptions outputStreamOptions) throws IOException {
173        this(outputStream, OutputStreamOptions.valueOf(outputStreamOptions.name()));
174    }
175
176    /**
177     * Creates a new {@code SarifLogger} instance.
178     *
179     * @param outputStream where to log audit events
180     * @param outputStreamOptions if {@code CLOSE} that should be closed in auditFinished()
181     * @throws IllegalArgumentException if outputStreamOptions is null
182     * @throws IOException if there is reading errors.
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     * Loads all available module metadata from XML files.
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        // No code by default
225    }
226
227    @Override
228    public void auditStarted(AuditEvent event) {
229        // No code by default
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     * Generates rules from cached rule metadata.
249     *
250     * @return list of rules
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     * Generates message strings for a given module.
282     *
283     * @param module the module
284     * @return the generated message strings
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     * Gets a map of message keys to their message strings for a module.
301     *
302     * @param moduleDetails the module details
303     * @return map of message keys to message strings
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            // Return empty map when module class or resource bundle is not on classpath.
325            // Occurs with third-party modules that have XML metadata but missing implementation.
326        }
327        return result;
328    }
329
330    /**
331     * Returns the version string.
332     *
333     * @param report report content where replace should happen
334     * @return a version string based on the package implementation version
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     * Caches rule metadata for a given audit event.
368     *
369     * @param event the audit event
370     * @return the composite key for the rule
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     * Generate message for the given rule key and audit event.
382     *
383     * @param ruleKey the rule key
384     * @param event the audit event
385     * @return the generated message
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        // 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     * Render the file name URI for the given file name.
437     *
438     * @param fileName the file name to render the URI for
439     * @return the rendered URI for the given file name
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     * Render the severity level into SARIF severity level.
455     *
456     * @param severityLevel the Severity level.
457     * @return the rendered severity level in string.
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     * Escape \b, \f, \n, \r, \t, \", \\ and U+0000 through U+001F.
470     * See <a href="https://www.ietf.org/rfc/rfc4627.txt">reference</a> - 2.5. Strings
471     *
472     * @param value the value to escape.
473     * @return the escaped value if necessary.
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     * Escape the character between 0x00 to 0x1F in JSON.
504     *
505     * @param chr the character to be escaped.
506     * @return the escaped string.
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     * Read string from given resource.
517     *
518     * @param name name of the desired resource
519     * @return the string content from the give resource
520     * @throws IOException if there is reading errors
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     * Composite key for uniquely identifying a rule by source name and module ID.
540     *
541     * @param sourceName  The fully qualified source class name.
542     * @param moduleId  The module ID from configuration (can be null).
543     */
544    private record RuleKey(String sourceName, String moduleId) {
545        /**
546         * Converts this key to a SARIF rule ID string.
547         *
548         * @return rule ID in format: sourceName[#moduleId]
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}