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}