001/*
002 * SPDX-FileCopyrightText: none
003 * SPDX-License-Identifier: CC0-1.0
004 */
005
006package dev.metaschema.modules.sarif;
007
008import org.schemastore.json.sarif.x210.Artifact;
009import org.schemastore.json.sarif.x210.ArtifactLocation;
010import org.schemastore.json.sarif.x210.Location;
011import org.schemastore.json.sarif.x210.LogicalLocation;
012import org.schemastore.json.sarif.x210.Message;
013import org.schemastore.json.sarif.x210.MultiformatMessageString;
014import org.schemastore.json.sarif.x210.PhysicalLocation;
015import org.schemastore.json.sarif.x210.Region;
016import org.schemastore.json.sarif.x210.ReportingDescriptor;
017import org.schemastore.json.sarif.x210.Result;
018import org.schemastore.json.sarif.x210.Run;
019import org.schemastore.json.sarif.x210.Sarif;
020import org.schemastore.json.sarif.x210.SarifModule;
021import org.schemastore.json.sarif.x210.Tool;
022import org.schemastore.json.sarif.x210.ToolComponent;
023
024import java.io.IOException;
025import java.io.StringWriter;
026import java.math.BigInteger;
027import java.net.URI;
028import java.net.URISyntaxException;
029import java.nio.file.Path;
030import java.nio.file.StandardOpenOption;
031import java.util.Collection;
032import java.util.LinkedHashMap;
033import java.util.LinkedList;
034import java.util.List;
035import java.util.Map;
036import java.util.Set;
037import java.util.UUID;
038import java.util.concurrent.atomic.AtomicInteger;
039
040import dev.metaschema.core.datatype.markup.MarkupLine;
041import dev.metaschema.core.datatype.markup.MarkupMultiline;
042import dev.metaschema.core.model.IAttributable;
043import dev.metaschema.core.model.IResourceLocation;
044import dev.metaschema.core.model.MetaschemaException;
045import dev.metaschema.core.model.constraint.ConstraintValidationFinding;
046import dev.metaschema.core.model.constraint.IConstraint;
047import dev.metaschema.core.model.constraint.IConstraint.Level;
048import dev.metaschema.core.model.validation.IValidationFinding;
049import dev.metaschema.core.model.validation.JsonSchemaContentValidator.JsonValidationFinding;
050import dev.metaschema.core.model.validation.XmlSchemaContentValidator.XmlValidationFinding;
051import dev.metaschema.core.util.CollectionUtil;
052import dev.metaschema.core.util.IVersionInfo;
053import dev.metaschema.core.util.ObjectUtils;
054import dev.metaschema.core.util.UriUtils;
055import dev.metaschema.databind.IBindingContext;
056import dev.metaschema.databind.io.Format;
057import dev.metaschema.databind.io.SerializationFeature;
058import edu.umd.cs.findbugs.annotations.NonNull;
059import edu.umd.cs.findbugs.annotations.Nullable;
060
061/**
062 * Supports building a Static Analysis Results Interchange Format (SARIF)
063 * document based on a set of validation findings.
064 */
065@SuppressWarnings("PMD.CouplingBetweenObjects")
066public final class SarifValidationHandler {
067  private enum Kind {
068    NOT_APPLICABLE("notApplicable"),
069    PASS("pass"),
070    FAIL("fail"),
071    REVIEW("review"),
072    OPEN("open"),
073    INFORMATIONAL("informational");
074
075    @NonNull
076    private final String label;
077
078    Kind(@NonNull String label) {
079      this.label = label;
080    }
081
082    @NonNull
083    public String getLabel() {
084      return label;
085    }
086  }
087
088  private enum SeverityLevel {
089    NONE("none"),
090    NOTE("note"),
091    WARNING("warning"),
092    ERROR("error");
093
094    @NonNull
095    private final String label;
096
097    SeverityLevel(@NonNull String label) {
098      this.label = label;
099    }
100
101    @NonNull
102    public String getLabel() {
103      return label;
104    }
105  }
106
107  @NonNull
108  static final String SARIF_NS = "https://docs.oasis-open.org/sarif/sarif/v2.1.0";
109  /**
110   * The property key for specifying a URL that provides help information for a
111   * constraint.
112   */
113  @NonNull
114  public static final IAttributable.Key SARIF_HELP_URL_KEY
115      = IAttributable.key("help-url", SARIF_NS);
116  /**
117   * The property key for specifying plain text help content for a constraint.
118   */
119  @NonNull
120  public static final IAttributable.Key SARIF_HELP_TEXT_KEY
121      = IAttributable.key("help-text", SARIF_NS);
122  /**
123   * The property key for specifying markdown-formatted help content for a
124   * constraint.
125   */
126  @NonNull
127  public static final IAttributable.Key SARIF_HELP_MARKDOWN_KEY
128      = IAttributable.key("help-markdown", SARIF_NS);
129
130  @NonNull
131  private final URI source;
132  @Nullable
133  private final IVersionInfo toolVersion;
134  private final AtomicInteger artifactIndex = new AtomicInteger(-1);
135  private final AtomicInteger ruleIndex = new AtomicInteger(-1);
136
137  @SuppressWarnings("PMD.UseConcurrentHashMap")
138  @NonNull
139  private final Map<URI, ArtifactRecord> artifacts = new LinkedHashMap<>();
140  @NonNull
141  private final List<AbstractRuleRecord> rules = new LinkedList<>();
142  @SuppressWarnings("PMD.UseConcurrentHashMap")
143  @NonNull
144  private final Map<IConstraint, ConstraintRuleRecord> constraintRules = new LinkedHashMap<>();
145  @NonNull
146  private final List<IResult> results = new LinkedList<>();
147  @NonNull
148  private final SchemaRuleRecord schemaRule = new SchemaRuleRecord();
149  private boolean schemaValid = true;
150
151  /**
152   * Construct a new validation handler.
153   *
154   * @param source
155   *          the URI of the content that was validated
156   * @param toolVersion
157   *          the version information for the tool producing the validation
158   *          results
159   */
160  public SarifValidationHandler(
161      @NonNull URI source,
162      @Nullable IVersionInfo toolVersion) {
163    if (!source.isAbsolute()) {
164      throw new IllegalArgumentException(String.format("The source URI '%s' is not absolute.", source.toASCIIString()));
165    }
166
167    this.source = source;
168    this.toolVersion = toolVersion;
169  }
170
171  @NonNull
172  private URI getSource() {
173    return source;
174  }
175
176  private IVersionInfo getToolVersion() {
177    return toolVersion;
178  }
179
180  /**
181   * Register a collection of validation finding.
182   *
183   * @param findings
184   *          the findings to register
185   */
186  public void addFindings(@NonNull Collection<? extends IValidationFinding> findings) {
187    for (IValidationFinding finding : findings) {
188      assert finding != null;
189      addFinding(finding);
190    }
191  }
192
193  /**
194   * Register a validation finding.
195   *
196   * @param finding
197   *          the finding to register
198   */
199  public void addFinding(@NonNull IValidationFinding finding) {
200    if (finding instanceof JsonValidationFinding) {
201      addJsonValidationFinding((JsonValidationFinding) finding);
202    } else if (finding instanceof XmlValidationFinding) {
203      addXmlValidationFinding((XmlValidationFinding) finding);
204    } else if (finding instanceof ConstraintValidationFinding) {
205      addConstraintValidationFinding((ConstraintValidationFinding) finding);
206    } else {
207      throw new IllegalStateException();
208    }
209  }
210
211  private ConstraintRuleRecord getRuleRecord(@NonNull IConstraint constraint) {
212    ConstraintRuleRecord retval = constraintRules.get(constraint);
213    if (retval == null) {
214      retval = new ConstraintRuleRecord(constraint);
215      constraintRules.put(constraint, retval);
216      rules.add(retval);
217    }
218    return retval;
219  }
220
221  private ArtifactRecord getArtifactRecord(@NonNull URI artifactUri) {
222    ArtifactRecord retval = artifacts.get(artifactUri);
223    if (retval == null) {
224      retval = new ArtifactRecord(artifactUri);
225      artifacts.put(artifactUri, retval);
226    }
227    return retval;
228  }
229
230  private void addJsonValidationFinding(@NonNull JsonValidationFinding finding) {
231    results.add(new SchemaResult(finding));
232    if (schemaValid && IValidationFinding.Kind.FAIL.equals(finding.getKind())) {
233      schemaValid = false;
234    }
235  }
236
237  private void addXmlValidationFinding(@NonNull XmlValidationFinding finding) {
238    results.add(new SchemaResult(finding));
239    if (schemaValid && IValidationFinding.Kind.FAIL.equals(finding.getKind())) {
240      schemaValid = false;
241    }
242  }
243
244  private void addConstraintValidationFinding(@NonNull ConstraintValidationFinding finding) {
245    results.add(new ConstraintResult(finding));
246  }
247
248  /**
249   * Generate a SARIF document based on the collected findings.
250   *
251   * @param outputUri
252   *          the URI to use as the base for relative paths in the SARIF document
253   * @return the generated SARIF document
254   * @throws IOException
255   *           if an error occurred while generating the SARIF document
256   */
257  @NonNull
258  private Sarif generateSarif(@NonNull URI outputUri) throws IOException {
259    Sarif sarif = new Sarif();
260    sarif.setVersion("2.1.0");
261
262    Run run = new Run();
263    sarif.addRun(run);
264
265    Artifact artifact = new Artifact();
266    artifact.setLocation(getArtifactRecord(getSource()).generateArtifactLocation(outputUri));
267    run.addArtifact(artifact);
268
269    for (IResult result : results) {
270      result.generateResults(outputUri).forEach(run::addResult);
271    }
272
273    IVersionInfo toolVersion = getToolVersion();
274    if (!rules.isEmpty() || toolVersion != null) {
275      Tool tool = new Tool();
276      ToolComponent driver = new ToolComponent();
277
278      if (toolVersion != null) {
279        driver.setName(toolVersion.getName());
280        driver.setVersion(toolVersion.getVersion());
281      }
282
283      for (AbstractRuleRecord rule : rules) {
284        driver.addRule(rule.generate());
285      }
286
287      tool.setDriver(driver);
288      run.setTool(tool);
289    }
290
291    return sarif;
292  }
293
294  /**
295   * Write the collection of findings to a string in SARIF format.
296   *
297   * @param bindingContext
298   *          the context used to access Metaschema module information based on
299   *          Java class bindings
300   * @return the SARIF document as a string
301   * @throws IOException
302   *           if an error occurred while generating the SARIF document
303   */
304  @NonNull
305  public String writeToString(@NonNull IBindingContext bindingContext) throws IOException {
306    registerSarifMetaschemaModule(bindingContext);
307    try (StringWriter writer = new StringWriter()) {
308      bindingContext.newSerializer(Format.JSON, Sarif.class)
309          .disableFeature(SerializationFeature.SERIALIZE_ROOT)
310          .serialize(generateSarif(getSource()), writer);
311      return ObjectUtils.notNull(writer.toString());
312    }
313  }
314
315  /**
316   * Write the collection of findings to the provided output file.
317   *
318   * @param outputFile
319   *          the path to the output file to write to
320   * @param bindingContext
321   *          the context used to access Metaschema module information based on
322   *          Java class bindings
323   * @throws IOException
324   *           if an error occurred while writing the SARIF file
325   */
326  public void write(
327      @NonNull Path outputFile,
328      @NonNull IBindingContext bindingContext) throws IOException {
329
330    URI output = ObjectUtils.notNull(outputFile.toUri());
331    Sarif sarif = generateSarif(output);
332
333    registerSarifMetaschemaModule(bindingContext);
334    bindingContext.newSerializer(Format.JSON, Sarif.class)
335        .disableFeature(SerializationFeature.SERIALIZE_ROOT)
336        .serialize(
337            sarif,
338            outputFile,
339            StandardOpenOption.CREATE,
340            StandardOpenOption.WRITE,
341            StandardOpenOption.TRUNCATE_EXISTING);
342  }
343
344  private static void registerSarifMetaschemaModule(@NonNull IBindingContext bindingContext) {
345    try {
346      bindingContext.registerModule(SarifModule.class);
347    } catch (MetaschemaException ex) {
348      throw new IllegalStateException("Unable to register the builtin SARIF module.", ex);
349    }
350  }
351
352  private interface IResult {
353    @NonNull
354    IValidationFinding getFinding();
355
356    @NonNull
357    List<Result> generateResults(@NonNull URI output) throws IOException;
358  }
359
360  private abstract class AbstractResult<T extends IValidationFinding> implements IResult {
361    @NonNull
362    private final T finding;
363
364    protected AbstractResult(@NonNull T finding) {
365      this.finding = finding;
366    }
367
368    @Override
369    public T getFinding() {
370      return finding;
371    }
372
373    @NonNull
374    protected Kind kind(@NonNull IValidationFinding finding) {
375      IValidationFinding.Kind kind = finding.getKind();
376
377      Kind retval;
378      switch (kind) {
379      case FAIL:
380        retval = Kind.FAIL;
381        break;
382      case INFORMATIONAL:
383        retval = Kind.INFORMATIONAL;
384        break;
385      case NOT_APPLICABLE:
386        retval = Kind.NOT_APPLICABLE;
387        break;
388      case PASS:
389        retval = Kind.PASS;
390        break;
391      default:
392        throw new IllegalArgumentException(String.format("Invalid finding kind '%s'.", kind));
393      }
394      return retval;
395    }
396
397    @NonNull
398    protected SeverityLevel level(@NonNull Level severity) {
399      SeverityLevel retval;
400      switch (severity) {
401      case CRITICAL:
402      case ERROR:
403        retval = SeverityLevel.ERROR;
404        break;
405      case INFORMATIONAL:
406      case DEBUG:
407        retval = SeverityLevel.NOTE;
408        break;
409      case WARNING:
410        retval = SeverityLevel.WARNING;
411        break;
412      case NONE:
413        retval = SeverityLevel.NONE;
414        break;
415      default:
416        throw new IllegalArgumentException(String.format("Invalid severity '%s'.", severity));
417      }
418      return retval;
419    }
420
421    protected void message(@NonNull IValidationFinding finding, @NonNull Result result) {
422      String message = finding.getMessage();
423      if (message == null) {
424        message = "";
425      }
426
427      Message msg = new Message();
428      msg.setText(message);
429      result.setMessage(msg);
430    }
431
432    protected void location(@NonNull IValidationFinding finding, @NonNull Result result, @NonNull URI base)
433        throws IOException {
434      IResourceLocation location = finding.getLocation();
435      if (location != null) {
436        // region
437        Region region = new Region();
438
439        if (location.getLine() > -1) {
440          region.setStartLine(BigInteger.valueOf(location.getLine()));
441          region.setEndLine(BigInteger.valueOf(location.getLine()));
442        }
443        if (location.getColumn() > -1) {
444          region.setStartColumn(BigInteger.valueOf(location.getColumn() + 1));
445          region.setEndColumn(BigInteger.valueOf(location.getColumn() + 1));
446        }
447        if (location.getByteOffset() > -1) {
448          region.setByteOffset(BigInteger.valueOf(location.getByteOffset()));
449          region.setByteLength(BigInteger.ZERO);
450        }
451        if (location.getCharOffset() > -1) {
452          region.setCharOffset(BigInteger.valueOf(location.getCharOffset()));
453          region.setCharLength(BigInteger.ZERO);
454        }
455
456        PhysicalLocation physical = new PhysicalLocation();
457
458        URI documentUri = finding.getDocumentUri();
459        if (documentUri != null) {
460          physical.setArtifactLocation(getArtifactRecord(documentUri).generateArtifactLocation(base));
461        }
462        physical.setRegion(region);
463
464        LogicalLocation logical = new LogicalLocation();
465
466        logical.setDecoratedName(finding.getPath());
467
468        Location loc = new Location();
469        loc.setPhysicalLocation(physical);
470        loc.addLogicalLocation(logical);
471        result.addLocation(loc);
472      }
473    }
474  }
475
476  private final class SchemaResult
477      extends AbstractResult<IValidationFinding> {
478
479    protected SchemaResult(@NonNull IValidationFinding finding) {
480      super(finding);
481    }
482
483    @Override
484    public List<Result> generateResults(@NonNull URI output) throws IOException {
485      IValidationFinding finding = getFinding();
486
487      Result result = new Result();
488
489      result.setRuleId(schemaRule.getId());
490      result.setRuleIndex(BigInteger.valueOf(schemaRule.getIndex()));
491      result.setGuid(schemaRule.getGuid());
492
493      result.setKind(kind(finding).getLabel());
494      result.setLevel(level(finding.getSeverity()).getLabel());
495      message(finding, result);
496      location(finding, result, output);
497
498      return CollectionUtil.singletonList(result);
499    }
500  }
501
502  private final class ConstraintResult
503      extends AbstractResult<ConstraintValidationFinding> {
504
505    protected ConstraintResult(@NonNull ConstraintValidationFinding finding) {
506      super(finding);
507    }
508
509    @Override
510    public List<Result> generateResults(@NonNull URI output) throws IOException {
511      ConstraintValidationFinding finding = getFinding();
512
513      List<Result> retval = new LinkedList<>();
514
515      Kind kind = kind(finding);
516      SeverityLevel level = level(finding.getSeverity());
517
518      for (IConstraint constraint : finding.getConstraints()) {
519        assert constraint != null;
520        ConstraintRuleRecord rule = getRuleRecord(constraint);
521
522        @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
523        Result result = new Result();
524
525        String id = constraint.getId();
526        if (id != null) {
527          result.setRuleId(id);
528        }
529        result.setRuleIndex(BigInteger.valueOf(rule.getIndex()));
530        result.setGuid(rule.getGuid());
531        result.setKind(kind.getLabel());
532        result.setLevel(level.getLabel());
533        message(finding, result);
534        location(finding, result, output);
535
536        retval.add(result);
537      }
538      return retval;
539    }
540  }
541
542  private abstract class AbstractRuleRecord {
543    private final int index;
544    @NonNull
545    private final UUID guid;
546
547    private AbstractRuleRecord() {
548      this.index = ruleIndex.addAndGet(1);
549      this.guid = ObjectUtils.notNull(UUID.randomUUID());
550    }
551
552    public int getIndex() {
553      return index;
554    }
555
556    @NonNull
557    public UUID getGuid() {
558      return guid;
559    }
560
561    @NonNull
562    protected abstract ReportingDescriptor generate();
563  }
564
565  private final class SchemaRuleRecord
566      extends AbstractRuleRecord {
567
568    @Override
569    protected ReportingDescriptor generate() {
570      ReportingDescriptor retval = new ReportingDescriptor();
571      retval.setId(getId());
572      retval.setGuid(getGuid());
573      return retval;
574    }
575
576    public String getId() {
577      return "schema-valid";
578    }
579  }
580
581  private final class ConstraintRuleRecord
582      extends AbstractRuleRecord {
583    @NonNull
584    private final IConstraint constraint;
585
586    public ConstraintRuleRecord(@NonNull IConstraint constraint) {
587      this.constraint = constraint;
588    }
589
590    @NonNull
591    public IConstraint getConstraint() {
592      return constraint;
593    }
594
595    @Override
596    protected ReportingDescriptor generate() {
597      ReportingDescriptor retval = new ReportingDescriptor();
598      IConstraint constraint = getConstraint();
599
600      UUID guid = getGuid();
601
602      String id = constraint.getId();
603      if (id == null) {
604        retval.setId(guid.toString());
605      } else {
606        retval.setId(id);
607      }
608      retval.setGuid(guid);
609      String formalName = constraint.getFormalName();
610      if (formalName != null) {
611        MultiformatMessageString text = new MultiformatMessageString();
612        text.setText(formalName);
613        retval.setShortDescription(text);
614      }
615      MarkupLine description = constraint.getDescription();
616      if (description != null) {
617        MultiformatMessageString text = new MultiformatMessageString();
618        text.setText(description.toText());
619        text.setMarkdown(description.toMarkdown());
620        retval.setFullDescription(text);
621      }
622
623      Set<String> helpUrls = constraint.getPropertyValues(SARIF_HELP_URL_KEY);
624      if (!helpUrls.isEmpty()) {
625        retval.setHelpUri(URI.create(helpUrls.stream().findFirst().get()));
626      }
627
628      Set<String> helpText = constraint.getPropertyValues(SARIF_HELP_TEXT_KEY);
629      Set<String> helpMarkdown = constraint.getPropertyValues(SARIF_HELP_MARKDOWN_KEY);
630      // if there is help text or markdown, produce a message
631      if (!helpText.isEmpty() || !helpMarkdown.isEmpty()) {
632        MultiformatMessageString help = new MultiformatMessageString();
633
634        MarkupMultiline markdown = helpMarkdown.stream().map(MarkupMultiline::fromMarkdown).findFirst().orElse(null);
635        if (markdown != null) {
636          // markdown is provided
637          help.setMarkdown(markdown.toMarkdown());
638        }
639
640        String text = helpText.isEmpty()
641            ? ObjectUtils.requireNonNull(markdown).toText() // if text is empty, markdown must be provided
642            : helpText.stream().findFirst().get(); // use the provided text
643        help.setText(text);
644
645        retval.setHelp(help);
646      }
647
648      return retval;
649    }
650
651  }
652
653  private final class ArtifactRecord {
654    @NonNull
655    private final URI uri;
656    private final int index;
657
658    public ArtifactRecord(@NonNull URI uri) {
659      this.uri = uri;
660      this.index = artifactIndex.addAndGet(1);
661    }
662
663    @NonNull
664    public URI getUri() {
665      return uri;
666    }
667
668    public int getIndex() {
669      return index;
670    }
671
672    public ArtifactLocation generateArtifactLocation(@NonNull URI baseUri) throws IOException {
673      ArtifactLocation location = new ArtifactLocation();
674
675      try {
676        location.setUri(UriUtils.relativize(baseUri, getUri(), true));
677      } catch (URISyntaxException ex) {
678        throw new IOException(ex);
679      }
680
681      location.setIndex(BigInteger.valueOf(getIndex()));
682      return location;
683    }
684  }
685}