SarifLogger.java

///////////////////////////////////////////////////////////////////////////////////////////////
// checkstyle: Checks Java source code and other text files for adherence to a set of rules.
// Copyright (C) 2001-2025 the original author or authors.
//
// This library is free software; you can redistribute it and/or
// modify it under the terms of the GNU Lesser General Public
// License as published by the Free Software Foundation; either
// version 2.1 of the License, or (at your option) any later version.
//
// This library is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
// Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public
// License along with this library; if not, write to the Free Software
// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
///////////////////////////////////////////////////////////////////////////////////////////////

package com.puppycrawl.tools.checkstyle;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.MissingResourceException;
import java.util.ResourceBundle;
import java.util.regex.Pattern;

import com.puppycrawl.tools.checkstyle.api.AuditEvent;
import com.puppycrawl.tools.checkstyle.api.AuditListener;
import com.puppycrawl.tools.checkstyle.api.AutomaticBean;
import com.puppycrawl.tools.checkstyle.api.SeverityLevel;
import com.puppycrawl.tools.checkstyle.meta.ModuleDetails;
import com.puppycrawl.tools.checkstyle.meta.XmlMetaReader;
import com.puppycrawl.tools.checkstyle.utils.CommonUtil;

/**
 * Simple SARIF logger.
 * SARIF stands for the static analysis results interchange format.
 * See <a href="https://sarifweb.azurewebsites.net/">reference</a>
 */
public class SarifLogger extends AbstractAutomaticBean implements AuditListener {

    /** The length of unicode placeholder. */
    private static final int UNICODE_LENGTH = 4;

    /** Unicode escaping upper limit. */
    private static final int UNICODE_ESCAPE_UPPER_LIMIT = 0x1F;

    /** Input stream buffer size. */
    private static final int BUFFER_SIZE = 1024;

    /** The placeholder for message. */
    private static final String MESSAGE_PLACEHOLDER = "${message}";

    /** The placeholder for message text. */
    private static final String MESSAGE_TEXT_PLACEHOLDER = "${messageText}";

    /** The placeholder for message id. */
    private static final String MESSAGE_ID_PLACEHOLDER = "${messageId}";

    /** The placeholder for severity level. */
    private static final String SEVERITY_LEVEL_PLACEHOLDER = "${severityLevel}";

    /** The placeholder for uri. */
    private static final String URI_PLACEHOLDER = "${uri}";

    /** The placeholder for line. */
    private static final String LINE_PLACEHOLDER = "${line}";

    /** The placeholder for column. */
    private static final String COLUMN_PLACEHOLDER = "${column}";

    /** The placeholder for rule id. */
    private static final String RULE_ID_PLACEHOLDER = "${ruleId}";

    /** The placeholder for version. */
    private static final String VERSION_PLACEHOLDER = "${version}";

    /** The placeholder for results. */
    private static final String RESULTS_PLACEHOLDER = "${results}";

    /** The placeholder for rules. */
    private static final String RULES_PLACEHOLDER = "${rules}";

    /** Two backslashes to not duplicate strings. */
    private static final String TWO_BACKSLASHES = "\\\\";

    /** A pattern for two backslashes. */
    private static final Pattern A_SPACE_PATTERN = Pattern.compile(" ");

    /** A pattern for two backslashes. */
    private static final Pattern TWO_BACKSLASHES_PATTERN = Pattern.compile(TWO_BACKSLASHES);

    /** A pattern to match a file with a Windows drive letter. */
    private static final Pattern WINDOWS_DRIVE_LETTER_PATTERN =
            Pattern.compile("\\A[A-Z]:", Pattern.CASE_INSENSITIVE);

    /** Comma and line separator. */
    private static final String COMMA_LINE_SEPARATOR = ",\n";

    /** Helper writer that allows easy encoding and printing. */
    private final PrintWriter writer;

    /** Close output stream in auditFinished. */
    private final boolean closeStream;

    /** The results. */
    private final List<String> results = new ArrayList<>();

    /** Map of all available module metadata by fully qualified name. */
    private final Map<String, ModuleDetails> allModuleMetadata = new HashMap<>();

    /** Map to store rule metadata by composite key (sourceName, moduleId). */
    private final Map<RuleKey, ModuleDetails> ruleMetadata = new LinkedHashMap<>();

    /** Content for the entire report. */
    private final String report;

    /** Content for result representing an error with source line and column. */
    private final String resultLineColumn;

    /** Content for result representing an error with source line only. */
    private final String resultLineOnly;

    /** Content for result representing an error with filename only and without source location. */
    private final String resultFileOnly;

    /** Content for result representing an error without filename or location. */
    private final String resultErrorOnly;

    /** Content for rule. */
    private final String rule;

    /** Content for messageStrings. */
    private final String messageStrings;

    /** Content for message with text only. */
    private final String messageTextOnly;

    /** Content for message with id. */
    private final String messageWithId;

    /**
     * Creates a new {@code SarifLogger} instance.
     *
     * @param outputStream where to log audit events
     * @param outputStreamOptions if {@code CLOSE} that should be closed in auditFinished()
     * @throws IllegalArgumentException if outputStreamOptions is null
     * @throws IOException if there is reading errors.
     * @noinspection deprecation
     * @noinspectionreason We are forced to keep AutomaticBean compatability
     *     because of maven-checkstyle-plugin. Until #12873.
     */
    public SarifLogger(
        OutputStream outputStream,
        AutomaticBean.OutputStreamOptions outputStreamOptions) throws IOException {
        this(outputStream, OutputStreamOptions.valueOf(outputStreamOptions.name()));
    }

    /**
     * Creates a new {@code SarifLogger} instance.
     *
     * @param outputStream where to log audit events
     * @param outputStreamOptions if {@code CLOSE} that should be closed in auditFinished()
     * @throws IllegalArgumentException if outputStreamOptions is null
     * @throws IOException if there is reading errors.
     */
    public SarifLogger(
        OutputStream outputStream,
        OutputStreamOptions outputStreamOptions) throws IOException {
        if (outputStreamOptions == null) {
            throw new IllegalArgumentException("Parameter outputStreamOptions can not be null");
        }
        writer = new PrintWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8));
        closeStream = outputStreamOptions == OutputStreamOptions.CLOSE;
        loadModuleMetadata();
        report = readResource("/com/puppycrawl/tools/checkstyle/sarif/SarifReport.template");
        resultLineColumn =
            readResource("/com/puppycrawl/tools/checkstyle/sarif/ResultLineColumn.template");
        resultLineOnly =
            readResource("/com/puppycrawl/tools/checkstyle/sarif/ResultLineOnly.template");
        resultFileOnly =
            readResource("/com/puppycrawl/tools/checkstyle/sarif/ResultFileOnly.template");
        resultErrorOnly =
            readResource("/com/puppycrawl/tools/checkstyle/sarif/ResultErrorOnly.template");
        rule = readResource("/com/puppycrawl/tools/checkstyle/sarif/Rule.template");
        messageStrings =
            readResource("/com/puppycrawl/tools/checkstyle/sarif/MessageStrings.template");
        messageTextOnly =
            readResource("/com/puppycrawl/tools/checkstyle/sarif/MessageTextOnly.template");
        messageWithId =
            readResource("/com/puppycrawl/tools/checkstyle/sarif/MessageWithId.template");
    }

    /**
     * Loads all available module metadata from XML files.
     */
    private void loadModuleMetadata() {
        final List<ModuleDetails> allModules =
                XmlMetaReader.readAllModulesIncludingThirdPartyIfAny();
        for (ModuleDetails module : allModules) {
            allModuleMetadata.put(module.getFullQualifiedName(), module);
        }
    }

    @Override
    protected void finishLocalSetup() {
        // No code by default
    }

    @Override
    public void auditStarted(AuditEvent event) {
        // No code by default
    }

    @Override
    public void auditFinished(AuditEvent event) {
        String rendered = replaceVersionString(report);
        rendered = rendered
                .replace(RESULTS_PLACEHOLDER, String.join(COMMA_LINE_SEPARATOR, results))
                .replace(RULES_PLACEHOLDER, String.join(COMMA_LINE_SEPARATOR, generateRules()));
        writer.print(rendered);
        if (closeStream) {
            writer.close();
        }
        else {
            writer.flush();
        }
    }

    /**
     * Generates rules from cached rule metadata.
     *
     * @return list of rules
     */
    private List<String> generateRules() {
        final List<String> result = new ArrayList<>();
        for (Map.Entry<RuleKey, ModuleDetails> entry : ruleMetadata.entrySet()) {
            final RuleKey ruleKey = entry.getKey();
            final ModuleDetails module = entry.getValue();
            final String shortDescription;
            final String fullDescription;
            final String messageStringsFragment;
            if (module == null) {
                shortDescription = CommonUtil.baseClassName(ruleKey.sourceName());
                fullDescription = "No description available";
                messageStringsFragment = "";
            }
            else {
                shortDescription = module.getName();
                fullDescription = module.getDescription();
                messageStringsFragment = String.join(COMMA_LINE_SEPARATOR,
                        generateMessageStrings(module));
            }
            result.add(rule
                    .replace(RULE_ID_PLACEHOLDER, ruleKey.toRuleId())
                    .replace("${shortDescription}", shortDescription)
                    .replace("${fullDescription}", escape(fullDescription))
                    .replace("${messageStrings}", messageStringsFragment));
        }
        return result;
    }

    /**
     * Generates message strings for a given module.
     *
     * @param module the module
     * @return the generated message strings
     */
    private List<String> generateMessageStrings(ModuleDetails module) {
        final Map<String, String> messages = getMessages(module);
        return module.getViolationMessageKeys().stream()
                .filter(messages::containsKey).map(key -> {
                    final String message = messages.get(key);
                    return messageStrings
                            .replace("${key}", key)
                            .replace("${text}", escape(message));
                }).toList();
    }

    /**
     * Gets a map of message keys to their message strings for a module.
     *
     * @param moduleDetails the module details
     * @return map of message keys to message strings
     */
    private static Map<String, String> getMessages(ModuleDetails moduleDetails) {
        final String fullQualifiedName = moduleDetails.getFullQualifiedName();
        final Map<String, String> result = new LinkedHashMap<>();
        try {
            final int lastDot = fullQualifiedName.lastIndexOf('.');
            final String packageName = fullQualifiedName.substring(0, lastDot);
            final String bundleName = packageName + ".messages";
            final Class<?> moduleClass = Class.forName(fullQualifiedName);
            final ResourceBundle bundle = ResourceBundle.getBundle(
                    bundleName,
                    Locale.ROOT,
                    moduleClass.getClassLoader(),
                    new LocalizedMessage.Utf8Control()
            );
            for (String key : moduleDetails.getViolationMessageKeys()) {
                result.put(key, bundle.getString(key));
            }
        }
        catch (ClassNotFoundException | MissingResourceException ignored) {
            // Return empty map when module class or resource bundle is not on classpath.
            // Occurs with third-party modules that have XML metadata but missing implementation.
        }
        return result;
    }

    /**
     * Returns the version string.
     *
     * @param report report content where replace should happen
     * @return a version string based on the package implementation version
     */
    private static String replaceVersionString(String report) {
        final String version = SarifLogger.class.getPackage().getImplementationVersion();
        return report.replace(VERSION_PLACEHOLDER, String.valueOf(version));
    }

    @Override
    public void addError(AuditEvent event) {
        final RuleKey ruleKey = cacheRuleMetadata(event);
        final String message = generateMessage(ruleKey, event);
        if (event.getColumn() > 0) {
            results.add(resultLineColumn
                .replace(SEVERITY_LEVEL_PLACEHOLDER, renderSeverityLevel(event.getSeverityLevel()))
                .replace(URI_PLACEHOLDER, renderFileNameUri(event.getFileName()))
                .replace(COLUMN_PLACEHOLDER, Integer.toString(event.getColumn()))
                .replace(LINE_PLACEHOLDER, Integer.toString(event.getLine()))
                .replace(MESSAGE_PLACEHOLDER, message)
                .replace(RULE_ID_PLACEHOLDER, ruleKey.toRuleId())
            );
        }
        else {
            results.add(resultLineOnly
                .replace(SEVERITY_LEVEL_PLACEHOLDER, renderSeverityLevel(event.getSeverityLevel()))
                .replace(URI_PLACEHOLDER, renderFileNameUri(event.getFileName()))
                .replace(LINE_PLACEHOLDER, Integer.toString(event.getLine()))
                .replace(MESSAGE_PLACEHOLDER, message)
                .replace(RULE_ID_PLACEHOLDER, ruleKey.toRuleId())
            );
        }
    }

    /**
     * Caches rule metadata for a given audit event.
     *
     * @param event the audit event
     * @return the composite key for the rule
     */
    private RuleKey cacheRuleMetadata(AuditEvent event) {
        final String sourceName = event.getSourceName();
        final RuleKey key = new RuleKey(sourceName, event.getModuleId());
        final ModuleDetails module = allModuleMetadata.get(sourceName);
        ruleMetadata.putIfAbsent(key, module);
        return key;
    }

    /**
     * Generate message for the given rule key and audit event.
     *
     * @param ruleKey the rule key
     * @param event the audit event
     * @return the generated message
     */
    private String generateMessage(RuleKey ruleKey, AuditEvent event) {
        final String violationKey = event.getViolation().getKey();
        final ModuleDetails module = ruleMetadata.get(ruleKey);
        final String result;
        if (module != null && module.getViolationMessageKeys().contains(violationKey)) {
            result = messageWithId
                    .replace(MESSAGE_ID_PLACEHOLDER, violationKey)
                    .replace(MESSAGE_TEXT_PLACEHOLDER, escape(event.getMessage()));
        }
        else {
            result = messageTextOnly
                    .replace(MESSAGE_TEXT_PLACEHOLDER, escape(event.getMessage()));
        }
        return result;
    }

    @Override
    public void addException(AuditEvent event, Throwable throwable) {
        final StringWriter stringWriter = new StringWriter();
        final PrintWriter printer = new PrintWriter(stringWriter);
        throwable.printStackTrace(printer);
        final String message = messageTextOnly
                .replace(MESSAGE_TEXT_PLACEHOLDER, escape(stringWriter.toString()));
        if (event.getFileName() == null) {
            results.add(resultErrorOnly
                .replace(SEVERITY_LEVEL_PLACEHOLDER, renderSeverityLevel(event.getSeverityLevel()))
                .replace(MESSAGE_PLACEHOLDER, message)
            );
        }
        else {
            results.add(resultFileOnly
                .replace(SEVERITY_LEVEL_PLACEHOLDER, renderSeverityLevel(event.getSeverityLevel()))
                .replace(URI_PLACEHOLDER, renderFileNameUri(event.getFileName()))
                .replace(MESSAGE_PLACEHOLDER, message)
            );
        }
    }

    @Override
    public void fileStarted(AuditEvent event) {
        // No need to implement this method in this class
    }

    @Override
    public void fileFinished(AuditEvent event) {
        // No need to implement this method in this class
    }

    /**
     * Render the file name URI for the given file name.
     *
     * @param fileName the file name to render the URI for
     * @return the rendered URI for the given file name
     */
    private static String renderFileNameUri(final String fileName) {
        String normalized =
                A_SPACE_PATTERN
                        .matcher(TWO_BACKSLASHES_PATTERN.matcher(fileName).replaceAll("/"))
                        .replaceAll("%20");
        if (WINDOWS_DRIVE_LETTER_PATTERN.matcher(normalized).find()) {
            normalized = '/' + normalized;
        }
        return "file:" + normalized;
    }

    /**
     * Render the severity level into SARIF severity level.
     *
     * @param severityLevel the Severity level.
     * @return the rendered severity level in string.
     */
    private static String renderSeverityLevel(SeverityLevel severityLevel) {
        return switch (severityLevel) {
            case IGNORE -> "none";
            case INFO -> "note";
            case WARNING -> "warning";
            case ERROR -> "error";
        };
    }

    /**
     * Escape \b, \f, \n, \r, \t, \", \\ and U+0000 through U+001F.
     * See <a href="https://www.ietf.org/rfc/rfc4627.txt">reference</a> - 2.5. Strings
     *
     * @param value the value to escape.
     * @return the escaped value if necessary.
     */
    public static String escape(String value) {
        final int length = value.length();
        final StringBuilder sb = new StringBuilder(length);
        for (int i = 0; i < length; i++) {
            final char chr = value.charAt(i);
            final String replacement = switch (chr) {
                case '"' -> "\\\"";
                case '\\' -> TWO_BACKSLASHES;
                case '\b' -> "\\b";
                case '\f' -> "\\f";
                case '\n' -> "\\n";
                case '\r' -> "\\r";
                case '\t' -> "\\t";
                case '/' -> "\\/";
                default -> {
                    if (chr <= UNICODE_ESCAPE_UPPER_LIMIT) {
                        yield escapeUnicode1F(chr);
                    }
                    yield Character.toString(chr);
                }
            };
            sb.append(replacement);
        }

        return sb.toString();
    }

    /**
     * Escape the character between 0x00 to 0x1F in JSON.
     *
     * @param chr the character to be escaped.
     * @return the escaped string.
     */
    private static String escapeUnicode1F(char chr) {
        final String hexString = Integer.toHexString(chr);
        return "\\u"
                + "0".repeat(UNICODE_LENGTH - hexString.length())
                + hexString.toUpperCase(Locale.US);
    }

    /**
     * Read string from given resource.
     *
     * @param name name of the desired resource
     * @return the string content from the give resource
     * @throws IOException if there is reading errors
     */
    public static String readResource(String name) throws IOException {
        try (InputStream inputStream = SarifLogger.class.getResourceAsStream(name);
             ByteArrayOutputStream result = new ByteArrayOutputStream()) {
            if (inputStream == null) {
                throw new IOException("Cannot find the resource " + name);
            }
            final byte[] buffer = new byte[BUFFER_SIZE];
            int length = 0;
            while (length != -1) {
                result.write(buffer, 0, length);
                length = inputStream.read(buffer);
            }
            return result.toString(StandardCharsets.UTF_8);
        }
    }

    /**
     * Composite key for uniquely identifying a rule by source name and module ID.
     *
     * @param sourceName  The fully qualified source class name.
     * @param moduleId  The module ID from configuration (can be null).
     */
    private record RuleKey(String sourceName, String moduleId) {
        /**
         * Converts this key to a SARIF rule ID string.
         *
         * @return rule ID in format: sourceName[#moduleId]
         */
        private String toRuleId() {
            final String result;
            if (moduleId == null) {
                result = sourceName;
            }
            else {
                result = sourceName + '#' + moduleId;
            }
            return result;
        }
    }
}