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