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