View Javadoc
1   ///////////////////////////////////////////////////////////////////////////////////////////////
2   // checkstyle: Checks Java source code and other text files for adherence to a set of rules.
3   // Copyright (C) 2001-2025 the original author or authors.
4   //
5   // This library is free software; you can redistribute it and/or
6   // modify it under the terms of the GNU Lesser General Public
7   // License as published by the Free Software Foundation; either
8   // version 2.1 of the License, or (at your option) any later version.
9   //
10  // This library is distributed in the hope that it will be useful,
11  // but WITHOUT ANY WARRANTY; without even the implied warranty of
12  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
13  // Lesser General Public License for more details.
14  //
15  // You should have received a copy of the GNU Lesser General Public
16  // License along with this library; if not, write to the Free Software
17  // Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
18  ///////////////////////////////////////////////////////////////////////////////////////////////
19  
20  package com.puppycrawl.tools.checkstyle;
21  
22  import java.io.ByteArrayOutputStream;
23  import java.io.IOException;
24  import java.io.InputStream;
25  import java.io.OutputStream;
26  import java.io.OutputStreamWriter;
27  import java.io.PrintWriter;
28  import java.io.StringWriter;
29  import java.nio.charset.StandardCharsets;
30  import java.util.ArrayList;
31  import java.util.List;
32  import java.util.Locale;
33  import java.util.regex.Pattern;
34  
35  import com.puppycrawl.tools.checkstyle.api.AuditEvent;
36  import com.puppycrawl.tools.checkstyle.api.AuditListener;
37  import com.puppycrawl.tools.checkstyle.api.AutomaticBean;
38  import com.puppycrawl.tools.checkstyle.api.SeverityLevel;
39  
40  /**
41   * Simple SARIF logger.
42   * SARIF stands for the static analysis results interchange format.
43   * See <a href="https://sarifweb.azurewebsites.net/">reference</a>
44   */
45  public class SarifLogger extends AbstractAutomaticBean implements AuditListener {
46  
47      /** The length of unicode placeholder. */
48      private static final int UNICODE_LENGTH = 4;
49  
50      /** Unicode escaping upper limit. */
51      private static final int UNICODE_ESCAPE_UPPER_LIMIT = 0x1F;
52  
53      /** Input stream buffer size. */
54      private static final int BUFFER_SIZE = 1024;
55  
56      /** The placeholder for message. */
57      private static final String MESSAGE_PLACEHOLDER = "${message}";
58  
59      /** The placeholder for severity level. */
60      private static final String SEVERITY_LEVEL_PLACEHOLDER = "${severityLevel}";
61  
62      /** The placeholder for uri. */
63      private static final String URI_PLACEHOLDER = "${uri}";
64  
65      /** The placeholder for line. */
66      private static final String LINE_PLACEHOLDER = "${line}";
67  
68      /** The placeholder for column. */
69      private static final String COLUMN_PLACEHOLDER = "${column}";
70  
71      /** The placeholder for rule id. */
72      private static final String RULE_ID_PLACEHOLDER = "${ruleId}";
73  
74      /** The placeholder for version. */
75      private static final String VERSION_PLACEHOLDER = "${version}";
76  
77      /** The placeholder for results. */
78      private static final String RESULTS_PLACEHOLDER = "${results}";
79  
80      /** Two backslashes to not duplicate strings. */
81      private static final String TWO_BACKSLASHES = "\\\\";
82  
83      /** A pattern for two backslashes. */
84      private static final Pattern A_SPACE_PATTERN = Pattern.compile(" ");
85  
86      /** A pattern for two backslashes. */
87      private static final Pattern TWO_BACKSLASHES_PATTERN = Pattern.compile(TWO_BACKSLASHES);
88  
89      /** A pattern to match a file with a Windows drive letter. */
90      private static final Pattern WINDOWS_DRIVE_LETTER_PATTERN =
91              Pattern.compile("\\A[A-Z]:", Pattern.CASE_INSENSITIVE);
92  
93      /** Helper writer that allows easy encoding and printing. */
94      private final PrintWriter writer;
95  
96      /** Close output stream in auditFinished. */
97      private final boolean closeStream;
98  
99      /** 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 }