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.List;
032import java.util.Locale;
033import java.util.regex.Pattern;
034
035import com.puppycrawl.tools.checkstyle.api.AuditEvent;
036import com.puppycrawl.tools.checkstyle.api.AuditListener;
037import com.puppycrawl.tools.checkstyle.api.AutomaticBean;
038import com.puppycrawl.tools.checkstyle.api.SeverityLevel;
039
040/**
041 * Simple SARIF logger.
042 * SARIF stands for the static analysis results interchange format.
043 * See <a href="https://sarifweb.azurewebsites.net/">reference</a>
044 */
045public class SarifLogger extends AbstractAutomaticBean implements AuditListener {
046
047    /** The length of unicode placeholder. */
048    private static final int UNICODE_LENGTH = 4;
049
050    /** Unicode escaping upper limit. */
051    private static final int UNICODE_ESCAPE_UPPER_LIMIT = 0x1F;
052
053    /** Input stream buffer size. */
054    private static final int BUFFER_SIZE = 1024;
055
056    /** The placeholder for message. */
057    private static final String MESSAGE_PLACEHOLDER = "${message}";
058
059    /** The placeholder for severity level. */
060    private static final String SEVERITY_LEVEL_PLACEHOLDER = "${severityLevel}";
061
062    /** The placeholder for uri. */
063    private static final String URI_PLACEHOLDER = "${uri}";
064
065    /** The placeholder for line. */
066    private static final String LINE_PLACEHOLDER = "${line}";
067
068    /** The placeholder for column. */
069    private static final String COLUMN_PLACEHOLDER = "${column}";
070
071    /** The placeholder for rule id. */
072    private static final String RULE_ID_PLACEHOLDER = "${ruleId}";
073
074    /** The placeholder for version. */
075    private static final String VERSION_PLACEHOLDER = "${version}";
076
077    /** The placeholder for results. */
078    private static final String RESULTS_PLACEHOLDER = "${results}";
079
080    /** Two backslashes to not duplicate strings. */
081    private static final String TWO_BACKSLASHES = "\\\\";
082
083    /** A pattern for two backslashes. */
084    private static final Pattern A_SPACE_PATTERN = Pattern.compile(" ");
085
086    /** A pattern for two backslashes. */
087    private static final Pattern TWO_BACKSLASHES_PATTERN = Pattern.compile(TWO_BACKSLASHES);
088
089    /** A pattern to match a file with a Windows drive letter. */
090    private static final Pattern WINDOWS_DRIVE_LETTER_PATTERN =
091            Pattern.compile("\\A[A-Z]:", Pattern.CASE_INSENSITIVE);
092
093    /** Helper writer that allows easy encoding and printing. */
094    private final PrintWriter writer;
095
096    /** Close output stream in auditFinished. */
097    private final boolean closeStream;
098
099    /** The results. */
100    private final List<String> results = new ArrayList<>();
101
102    /** Content for the entire report. */
103    private final String report;
104
105    /** Content for result representing an error with source line and column. */
106    private final String resultLineColumn;
107
108    /** Content for result representing an error with source line only. */
109    private final String resultLineOnly;
110
111    /** Content for result representing an error with filename only and without source location. */
112    private final String resultFileOnly;
113
114    /** Content for result representing an error without filename or location. */
115    private final String resultErrorOnly;
116
117    /**
118     * Creates a new {@code SarifLogger} instance.
119     *
120     * @param outputStream where to log audit events
121     * @param outputStreamOptions if {@code CLOSE} that should be closed in auditFinished()
122     * @throws IllegalArgumentException if outputStreamOptions is null
123     * @throws IOException if there is reading errors.
124     * @noinspection deprecation
125     * @noinspectionreason We are forced to keep AutomaticBean compatability
126     *     because of maven-checkstyle-plugin. Until #12873.
127     */
128    public SarifLogger(
129        OutputStream outputStream,
130        AutomaticBean.OutputStreamOptions outputStreamOptions) throws IOException {
131        this(outputStream, OutputStreamOptions.valueOf(outputStreamOptions.name()));
132    }
133
134    /**
135     * Creates a new {@code SarifLogger} instance.
136     *
137     * @param outputStream where to log audit events
138     * @param outputStreamOptions if {@code CLOSE} that should be closed in auditFinished()
139     * @throws IllegalArgumentException if outputStreamOptions is null
140     * @throws IOException if there is reading errors.
141     */
142    public SarifLogger(
143        OutputStream outputStream,
144        OutputStreamOptions outputStreamOptions) throws IOException {
145        if (outputStreamOptions == null) {
146            throw new IllegalArgumentException("Parameter outputStreamOptions can not be null");
147        }
148        writer = new PrintWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8));
149        closeStream = outputStreamOptions == OutputStreamOptions.CLOSE;
150        report = readResource("/com/puppycrawl/tools/checkstyle/sarif/SarifReport.template");
151        resultLineColumn =
152            readResource("/com/puppycrawl/tools/checkstyle/sarif/ResultLineColumn.template");
153        resultLineOnly =
154            readResource("/com/puppycrawl/tools/checkstyle/sarif/ResultLineOnly.template");
155        resultFileOnly =
156            readResource("/com/puppycrawl/tools/checkstyle/sarif/ResultFileOnly.template");
157        resultErrorOnly =
158            readResource("/com/puppycrawl/tools/checkstyle/sarif/ResultErrorOnly.template");
159    }
160
161    @Override
162    protected void finishLocalSetup() {
163        // No code by default
164    }
165
166    @Override
167    public void auditStarted(AuditEvent event) {
168        // No code by default
169    }
170
171    @Override
172    public void auditFinished(AuditEvent event) {
173        String rendered = replaceVersionString(report);
174        rendered = rendered.replace(RESULTS_PLACEHOLDER, String.join(",\n", results));
175        writer.print(rendered);
176        if (closeStream) {
177            writer.close();
178        }
179        else {
180            writer.flush();
181        }
182    }
183
184    /**
185     * Returns the version string.
186     *
187     * @param report report content where replace should happen
188     * @return a version string based on the package implementation version
189     */
190    private static String replaceVersionString(String report) {
191        final String version = SarifLogger.class.getPackage().getImplementationVersion();
192        return report.replace(VERSION_PLACEHOLDER, String.valueOf(version));
193    }
194
195    @Override
196    public void addError(AuditEvent event) {
197        if (event.getColumn() > 0) {
198            results.add(resultLineColumn
199                .replace(SEVERITY_LEVEL_PLACEHOLDER, renderSeverityLevel(event.getSeverityLevel()))
200                .replace(URI_PLACEHOLDER, renderFileNameUri(event.getFileName()))
201                .replace(COLUMN_PLACEHOLDER, Integer.toString(event.getColumn()))
202                .replace(LINE_PLACEHOLDER, Integer.toString(event.getLine()))
203                .replace(MESSAGE_PLACEHOLDER, escape(event.getMessage()))
204                .replace(RULE_ID_PLACEHOLDER, event.getViolation().getKey())
205            );
206        }
207        else {
208            results.add(resultLineOnly
209                .replace(SEVERITY_LEVEL_PLACEHOLDER, renderSeverityLevel(event.getSeverityLevel()))
210                .replace(URI_PLACEHOLDER, renderFileNameUri(event.getFileName()))
211                .replace(LINE_PLACEHOLDER, Integer.toString(event.getLine()))
212                .replace(MESSAGE_PLACEHOLDER, escape(event.getMessage()))
213                .replace(RULE_ID_PLACEHOLDER, event.getViolation().getKey())
214            );
215        }
216    }
217
218    @Override
219    public void addException(AuditEvent event, Throwable throwable) {
220        final StringWriter stringWriter = new StringWriter();
221        final PrintWriter printer = new PrintWriter(stringWriter);
222        throwable.printStackTrace(printer);
223        if (event.getFileName() == null) {
224            results.add(resultErrorOnly
225                .replace(SEVERITY_LEVEL_PLACEHOLDER, renderSeverityLevel(event.getSeverityLevel()))
226                .replace(MESSAGE_PLACEHOLDER, escape(stringWriter.toString()))
227            );
228        }
229        else {
230            results.add(resultFileOnly
231                .replace(SEVERITY_LEVEL_PLACEHOLDER, renderSeverityLevel(event.getSeverityLevel()))
232                .replace(URI_PLACEHOLDER, renderFileNameUri(event.getFileName()))
233                .replace(MESSAGE_PLACEHOLDER, escape(stringWriter.toString()))
234            );
235        }
236    }
237
238    @Override
239    public void fileStarted(AuditEvent event) {
240        // No need to implement this method in this class
241    }
242
243    @Override
244    public void fileFinished(AuditEvent event) {
245        // No need to implement this method in this class
246    }
247
248    /**
249     * Render the file name URI for the given file name.
250     *
251     * @param fileName the file name to render the URI for
252     * @return the rendered URI for the given file name
253     */
254    private static String renderFileNameUri(final String fileName) {
255        String normalized =
256                A_SPACE_PATTERN
257                        .matcher(TWO_BACKSLASHES_PATTERN.matcher(fileName).replaceAll("/"))
258                        .replaceAll("%20");
259        if (WINDOWS_DRIVE_LETTER_PATTERN.matcher(normalized).find()) {
260            normalized = '/' + normalized;
261        }
262        return "file:" + normalized;
263    }
264
265    /**
266     * Render the severity level into SARIF severity level.
267     *
268     * @param severityLevel the Severity level.
269     * @return the rendered severity level in string.
270     */
271    private static String renderSeverityLevel(SeverityLevel severityLevel) {
272        final String renderedSeverityLevel;
273        switch (severityLevel) {
274            case IGNORE:
275                renderedSeverityLevel = "none";
276                break;
277            case INFO:
278                renderedSeverityLevel = "note";
279                break;
280            case WARNING:
281                renderedSeverityLevel = "warning";
282                break;
283            case ERROR:
284            default:
285                renderedSeverityLevel = "error";
286                break;
287        }
288        return renderedSeverityLevel;
289    }
290
291    /**
292     * Escape \b, \f, \n, \r, \t, \", \\ and U+0000 through U+001F.
293     * See <a href="https://www.ietf.org/rfc/rfc4627.txt">reference</a> - 2.5. Strings
294     *
295     * @param value the value to escape.
296     * @return the escaped value if necessary.
297     */
298    public static String escape(String value) {
299        final int length = value.length();
300        final StringBuilder sb = new StringBuilder(length);
301        for (int i = 0; i < length; i++) {
302            final char chr = value.charAt(i);
303            switch (chr) {
304                case '"':
305                    sb.append("\\\"");
306                    break;
307                case '\\':
308                    sb.append(TWO_BACKSLASHES);
309                    break;
310                case '\b':
311                    sb.append("\\b");
312                    break;
313                case '\f':
314                    sb.append("\\f");
315                    break;
316                case '\n':
317                    sb.append("\\n");
318                    break;
319                case '\r':
320                    sb.append("\\r");
321                    break;
322                case '\t':
323                    sb.append("\\t");
324                    break;
325                case '/':
326                    sb.append("\\/");
327                    break;
328                default:
329                    if (chr <= UNICODE_ESCAPE_UPPER_LIMIT) {
330                        sb.append(escapeUnicode1F(chr));
331                    }
332                    else {
333                        sb.append(chr);
334                    }
335                    break;
336            }
337        }
338        return sb.toString();
339    }
340
341    /**
342     * Escape the character between 0x00 to 0x1F in JSON.
343     *
344     * @param chr the character to be escaped.
345     * @return the escaped string.
346     */
347    private static String escapeUnicode1F(char chr) {
348        final String hexString = Integer.toHexString(chr);
349        return "\\u"
350                + "0".repeat(UNICODE_LENGTH - hexString.length())
351                + hexString.toUpperCase(Locale.US);
352    }
353
354    /**
355     * Read string from given resource.
356     *
357     * @param name name of the desired resource
358     * @return the string content from the give resource
359     * @throws IOException if there is reading errors
360     */
361    public static String readResource(String name) throws IOException {
362        try (InputStream inputStream = SarifLogger.class.getResourceAsStream(name);
363             ByteArrayOutputStream result = new ByteArrayOutputStream()) {
364            if (inputStream == null) {
365                throw new IOException("Cannot find the resource " + name);
366            }
367            final byte[] buffer = new byte[BUFFER_SIZE];
368            int length = 0;
369            while (length != -1) {
370                result.write(buffer, 0, length);
371                length = inputStream.read(buffer);
372            }
373            return result.toString(StandardCharsets.UTF_8);
374        }
375    }
376}