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         final String version = SarifLogger.class.getPackage().getImplementationVersion();
174         final String rendered = report
175             .replace(VERSION_PLACEHOLDER, String.valueOf(version))
176             .replace(RESULTS_PLACEHOLDER, String.join(",\n", results));
177         writer.print(rendered);
178         if (closeStream) {
179             writer.close();
180         }
181         else {
182             writer.flush();
183         }
184     }
185 
186     @Override
187     public void addError(AuditEvent event) {
188         if (event.getColumn() > 0) {
189             results.add(resultLineColumn
190                 .replace(SEVERITY_LEVEL_PLACEHOLDER, renderSeverityLevel(event.getSeverityLevel()))
191                 .replace(URI_PLACEHOLDER, renderFileNameUri(event.getFileName()))
192                 .replace(COLUMN_PLACEHOLDER, Integer.toString(event.getColumn()))
193                 .replace(LINE_PLACEHOLDER, Integer.toString(event.getLine()))
194                 .replace(MESSAGE_PLACEHOLDER, escape(event.getMessage()))
195                 .replace(RULE_ID_PLACEHOLDER, event.getViolation().getKey())
196             );
197         }
198         else {
199             results.add(resultLineOnly
200                 .replace(SEVERITY_LEVEL_PLACEHOLDER, renderSeverityLevel(event.getSeverityLevel()))
201                 .replace(URI_PLACEHOLDER, renderFileNameUri(event.getFileName()))
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     }
208 
209     @Override
210     public void addException(AuditEvent event, Throwable throwable) {
211         final StringWriter stringWriter = new StringWriter();
212         final PrintWriter printer = new PrintWriter(stringWriter);
213         throwable.printStackTrace(printer);
214         if (event.getFileName() == null) {
215             results.add(resultErrorOnly
216                 .replace(SEVERITY_LEVEL_PLACEHOLDER, renderSeverityLevel(event.getSeverityLevel()))
217                 .replace(MESSAGE_PLACEHOLDER, escape(stringWriter.toString()))
218             );
219         }
220         else {
221             results.add(resultFileOnly
222                 .replace(SEVERITY_LEVEL_PLACEHOLDER, renderSeverityLevel(event.getSeverityLevel()))
223                 .replace(URI_PLACEHOLDER, renderFileNameUri(event.getFileName()))
224                 .replace(MESSAGE_PLACEHOLDER, escape(stringWriter.toString()))
225             );
226         }
227     }
228 
229     @Override
230     public void fileStarted(AuditEvent event) {
231         // No need to implement this method in this class
232     }
233 
234     @Override
235     public void fileFinished(AuditEvent event) {
236         // No need to implement this method in this class
237     }
238 
239     /**
240      * Render the file name URI for the given file name.
241      *
242      * @param fileName the file name to render the URI for
243      * @return the rendered URI for the given file name
244      */
245     private static String renderFileNameUri(final String fileName) {
246         String normalized =
247                 A_SPACE_PATTERN
248                         .matcher(TWO_BACKSLASHES_PATTERN.matcher(fileName).replaceAll("/"))
249                         .replaceAll("%20");
250         if (WINDOWS_DRIVE_LETTER_PATTERN.matcher(normalized).find()) {
251             normalized = '/' + normalized;
252         }
253         return "file:" + normalized;
254     }
255 
256     /**
257      * Render the severity level into SARIF severity level.
258      *
259      * @param severityLevel the Severity level.
260      * @return the rendered severity level in string.
261      */
262     private static String renderSeverityLevel(SeverityLevel severityLevel) {
263         final String renderedSeverityLevel;
264         switch (severityLevel) {
265             case IGNORE:
266                 renderedSeverityLevel = "none";
267                 break;
268             case INFO:
269                 renderedSeverityLevel = "note";
270                 break;
271             case WARNING:
272                 renderedSeverityLevel = "warning";
273                 break;
274             case ERROR:
275             default:
276                 renderedSeverityLevel = "error";
277                 break;
278         }
279         return renderedSeverityLevel;
280     }
281 
282     /**
283      * Escape \b, \f, \n, \r, \t, \", \\ and U+0000 through U+001F.
284      * See <a href="https://www.ietf.org/rfc/rfc4627.txt">reference</a> - 2.5. Strings
285      *
286      * @param value the value to escape.
287      * @return the escaped value if necessary.
288      */
289     public static String escape(String value) {
290         final int length = value.length();
291         final StringBuilder sb = new StringBuilder(length);
292         for (int i = 0; i < length; i++) {
293             final char chr = value.charAt(i);
294             switch (chr) {
295                 case '"':
296                     sb.append("\\\"");
297                     break;
298                 case '\\':
299                     sb.append(TWO_BACKSLASHES);
300                     break;
301                 case '\b':
302                     sb.append("\\b");
303                     break;
304                 case '\f':
305                     sb.append("\\f");
306                     break;
307                 case '\n':
308                     sb.append("\\n");
309                     break;
310                 case '\r':
311                     sb.append("\\r");
312                     break;
313                 case '\t':
314                     sb.append("\\t");
315                     break;
316                 case '/':
317                     sb.append("\\/");
318                     break;
319                 default:
320                     if (chr <= UNICODE_ESCAPE_UPPER_LIMIT) {
321                         sb.append(escapeUnicode1F(chr));
322                     }
323                     else {
324                         sb.append(chr);
325                     }
326                     break;
327             }
328         }
329         return sb.toString();
330     }
331 
332     /**
333      * Escape the character between 0x00 to 0x1F in JSON.
334      *
335      * @param chr the character to be escaped.
336      * @return the escaped string.
337      */
338     private static String escapeUnicode1F(char chr) {
339         final String hexString = Integer.toHexString(chr);
340         return "\\u"
341                 + "0".repeat(UNICODE_LENGTH - hexString.length())
342                 + hexString.toUpperCase(Locale.US);
343     }
344 
345     /**
346      * Read string from given resource.
347      *
348      * @param name name of the desired resource
349      * @return the string content from the give resource
350      * @throws IOException if there is reading errors
351      */
352     public static String readResource(String name) throws IOException {
353         try (InputStream inputStream = SarifLogger.class.getResourceAsStream(name);
354              ByteArrayOutputStream result = new ByteArrayOutputStream()) {
355             if (inputStream == null) {
356                 throw new IOException("Cannot find the resource " + name);
357             }
358             final byte[] buffer = new byte[BUFFER_SIZE];
359             int length = inputStream.read(buffer);
360             while (length != -1) {
361                 result.write(buffer, 0, length);
362                 length = inputStream.read(buffer);
363             }
364             return result.toString(StandardCharsets.UTF_8);
365         }
366     }
367 }