advisory_inventory.init.gradle (25694B)
1 import groovy.json.JsonOutput 2 import java.nio.ByteBuffer 3 import java.nio.channels.FileChannel 4 import java.nio.charset.StandardCharsets 5 import java.nio.file.Files 6 import java.nio.file.LinkOption 7 import java.nio.file.Path 8 import java.nio.file.Paths 9 import java.nio.file.StandardOpenOption 10 import java.nio.file.attribute.BasicFileAttributes 11 import java.nio.file.attribute.PosixFileAttributes 12 import java.nio.file.attribute.PosixFilePermissions 13 import java.security.MessageDigest 14 import java.util.HexFormat 15 import org.gradle.api.Named 16 import org.gradle.api.artifacts.component.ModuleComponentIdentifier 17 import org.gradle.api.artifacts.component.ModuleComponentSelector 18 import org.gradle.api.artifacts.component.ProjectComponentIdentifier 19 import org.gradle.api.artifacts.component.ProjectComponentSelector 20 import org.gradle.api.artifacts.result.ResolvedDependencyResult 21 import org.gradle.api.artifacts.result.UnresolvedDependencyResult 22 23 final long MAX_ARTIFACT_BYTES = 17_179_869_184L 24 final long MAX_TOTAL_ARTIFACT_BYTES = 17_179_869_184L 25 final int MAX_JSON_BYTES = 67_108_864 26 final int STREAM_BUFFER_BYTES = 65_536 27 final int MAX_EXTERNAL_VARIANT_DEPTH = 4 28 final int MAX_COMPONENTS = 65_536 29 final int MAX_DEPENDENCY_RESULTS = 262_144 30 final int MAX_ARTIFACTS = 65_536 31 final int MAX_VARIANTS_PER_COMPONENT = 4_096 32 final int MAX_ATTRIBUTES = 1_024 33 final int MAX_CAPABILITIES = 1_024 34 final int MAX_REJECTED_VERSIONS = 1_024 35 final Set<String> ADMITTED_EXTENSIONS = [ 36 "aar", "jar", "js", "klib", "module", "pom", "wasm", "zip" 37 ] as Set 38 39 def authorities = [ 40 "harvestcircle": [ 41 build_root: ".", 42 workloads: [ 43 ":app:design_system|desktopRuntimeClasspath": "app_design_system", 44 ":app:desktop|runtimeClasspath": "app_desktop", 45 ":app:shared|desktopRuntimeClasspath": "app_shared", 46 ":tools:design_catalog|desktopRuntimeClasspath": "tools_design_catalog", 47 ], 48 ], 49 "harvestcircle-build-logic": [ 50 build_root: "build-logic", 51 workloads: [ 52 ":contracts|runtimeClasspath": "build_logic_contracts", 53 ":plugins|runtimeClasspath": "build_logic_plugins", 54 ], 55 ], 56 ] 57 58 def fail = { -> 59 throw new GradleException("governed advisory graph projection failed") 60 } 61 62 def safeString = { Object value, int maximum, boolean allowEmpty = false -> 63 if (!(value instanceof CharSequence)) { 64 fail() 65 } 66 String text = value.toString() 67 if ((!allowEmpty && text.isEmpty()) 68 || text.getBytes(StandardCharsets.UTF_8).length > maximum 69 || text.codePoints().anyMatch { codePoint -> 70 codePoint > 0x7f || Character.isISOControl(codePoint) 71 }) { 72 fail() 73 } 74 text 75 } 76 77 def nullableString = { Object value, int maximum, boolean allowEmpty = false -> 78 value == null ? null : safeString(value, maximum, allowEmpty) 79 } 80 81 Closure<String> canonicalJson 82 canonicalJson = { Object value -> 83 if (value == null || value instanceof Boolean || value instanceof Number 84 || value instanceof CharSequence) { 85 return JsonOutput.toJson(value) 86 } 87 if (value instanceof Map) { 88 def keys = value.keySet().collect { key -> 89 if (!(key instanceof String)) { 90 fail() 91 } 92 safeString(key, 256) 93 }.sort() 94 return "{" + keys.collect { key -> 95 JsonOutput.toJson(key) + ":" + canonicalJson(value[key]) 96 }.join(",") + "}" 97 } 98 if (value instanceof Collection) { 99 return "[" + value.collect { entry -> canonicalJson(entry) }.join(",") + "]" 100 } 101 fail() 102 } 103 104 def boundedSize = { Collection values, int maximum, boolean allowEmpty = true -> 105 int count = values.size() 106 if ((!allowEmpty && count == 0) || count > maximum) { 107 fail() 108 } 109 count 110 } 111 112 def canonicalSortedSet = { Collection rows, int maximum, boolean allowEmpty = true -> 113 boundedSize(rows, maximum, allowEmpty) 114 def byIdentity = new TreeMap<String, Object>() 115 rows.each { row -> 116 String identity = canonicalJson(row) 117 byIdentity[identity] = row 118 } 119 if (byIdentity.size() > maximum || (!allowEmpty && byIdentity.isEmpty())) { 120 fail() 121 } 122 byIdentity.values().toList() 123 } 124 125 def canonicalSortedRows = { Collection rows, int maximum, boolean allowEmpty = true -> 126 boundedSize(rows, maximum, allowEmpty) 127 def byIdentity = new TreeMap<String, Object>() 128 rows.each { row -> 129 String identity = canonicalJson(row) 130 if (byIdentity.put(identity, row) != null) { 131 fail() 132 } 133 } 134 if (byIdentity.size() != rows.size()) { 135 fail() 136 } 137 byIdentity.values().toList() 138 } 139 140 def attributeValue = { Object value -> 141 if (value instanceof Named) { 142 return safeString(value.name, 512) 143 } 144 if (value instanceof Enum) { 145 return safeString(value.name(), 512) 146 } 147 if (value instanceof CharSequence || value instanceof Number || value instanceof Boolean) { 148 return safeString(String.valueOf(value), 512, true) 149 } 150 fail() 151 } 152 153 def attributeRows = { attributes -> 154 def keys = attributes.keySet() 155 int expectedCount = boundedSize(keys, MAX_ATTRIBUTES) 156 def rows = keys.collect { attribute -> 157 [ 158 name: safeString(attribute.name, 512), 159 value: attributeValue(attributes.getAttribute(attribute)), 160 ] 161 }.sort { left, right -> 162 left.name <=> right.name ?: left.value <=> right.value 163 } 164 if (rows.size() != expectedCount 165 || rows.collect { row -> row.name }.toSet().size() != rows.size()) { 166 fail() 167 } 168 rows 169 } 170 171 def capabilityRows = { capabilities -> 172 int expectedCount = boundedSize(capabilities, MAX_CAPABILITIES) 173 def rows = capabilities.collect { capability -> 174 [ 175 group: safeString(capability.group, 256, true), 176 name: safeString(capability.name, 256), 177 version: nullableString(capability.version, 128, true), 178 ] 179 } 180 if (rows.size() != expectedCount) { 181 fail() 182 } 183 canonicalSortedSet(rows, MAX_CAPABILITIES) 184 } 185 186 Closure<Map> variantRow 187 variantRow = { variant, int depth = 0 -> 188 if (depth > MAX_EXTERNAL_VARIANT_DEPTH) { 189 fail() 190 } 191 def external = variant.externalVariant 192 [ 193 attributes: attributeRows(variant.attributes), 194 capabilities: capabilityRows(variant.capabilities), 195 external_variant: external.present ? variantRow(external.get(), depth + 1) : null, 196 ] 197 } 198 199 def variantEnvelope = { Collection variants -> 200 def selected = canonicalSortedSet(variants, MAX_VARIANTS_PER_COMPONENT, false) 201 [selected: selected] 202 } 203 204 def requiredProperty = { String name -> 205 def value = gradle.startParameter.projectProperties[name] 206 if (value == null) { 207 fail() 208 } 209 safeString(value, 4_096) 210 } 211 212 def logicalBuildRoot = { String invocationBuildRoot, String buildPath -> 213 String admitted = safeString(buildPath, 256) 214 if (admitted == ":") { 215 return invocationBuildRoot 216 } 217 if (admitted == ":build-logic") { 218 return "build-logic" 219 } 220 fail() 221 } 222 223 def componentCore = { identifier, String invocationBuildRoot -> 224 if (identifier instanceof ModuleComponentIdentifier) { 225 return [ 226 build_root: null, 227 group: safeString(identifier.group, 256), 228 kind: "module", 229 name: safeString(identifier.module, 256), 230 project_path: null, 231 version: safeString(identifier.version, 128), 232 ] 233 } 234 if (identifier instanceof ProjectComponentIdentifier) { 235 return [ 236 build_root: logicalBuildRoot(invocationBuildRoot, identifier.build.buildPath), 237 group: null, 238 kind: "project", 239 name: null, 240 project_path: safeString(identifier.projectPath, 256), 241 version: null, 242 ] 243 } 244 fail() 245 } 246 247 def selectorRow = { selector, String invocationBuildRoot -> 248 if (!selector.capabilitySelectors.isEmpty()) { 249 fail() 250 } 251 if (selector instanceof ModuleComponentSelector) { 252 def constraint = selector.versionConstraint 253 def rejectedVersions = constraint.rejectedVersions 254 int rejectedCount = boundedSize(rejectedVersions, MAX_REJECTED_VERSIONS) 255 def rejected = rejectedVersions.collect { version -> 256 safeString(version, 512) 257 } 258 if (rejected.size() != rejectedCount) { 259 fail() 260 } 261 return [ 262 attributes: attributeRows(selector.attributes), 263 build_root: null, 264 capabilities: capabilityRows(selector.requestedCapabilities), 265 group: safeString(selector.group, 256), 266 kind: "module", 267 name: safeString(selector.module, 256), 268 project_path: null, 269 version: safeString(selector.version, 512, true), 270 version_constraint: [ 271 branch: nullableString(constraint.branch, 256, true), 272 preferred: safeString(constraint.preferredVersion, 512, true), 273 rejected: canonicalSortedSet(rejected, MAX_REJECTED_VERSIONS), 274 required: safeString(constraint.requiredVersion, 512, true), 275 strict: safeString(constraint.strictVersion, 512, true), 276 ], 277 ] 278 } 279 if (selector instanceof ProjectComponentSelector) { 280 return [ 281 attributes: attributeRows(selector.attributes), 282 build_root: logicalBuildRoot(invocationBuildRoot, selector.buildPath), 283 capabilities: capabilityRows(selector.requestedCapabilities), 284 group: null, 285 kind: "project", 286 name: null, 287 project_path: safeString(selector.projectPath, 256), 288 version: null, 289 version_constraint: null, 290 ] 291 } 292 fail() 293 } 294 295 def sameBasicIdentity = { BasicFileAttributes left, BasicFileAttributes right -> 296 left.fileKey() != null 297 && right.fileKey() != null 298 && left.fileKey() == right.fileKey() 299 && left.isRegularFile() 300 && right.isRegularFile() 301 && left.size() == right.size() 302 && left.lastModifiedTime() == right.lastModifiedTime() 303 && left.creationTime() == right.creationTime() 304 } 305 306 def digestBytes = { byte[] bytes -> 307 HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)) 308 } 309 310 def observeRegularFile = { Path path, long maximum -> 311 if (!path.isAbsolute() || path != path.normalize()) { 312 fail() 313 } 314 BasicFileAttributes before = Files.readAttributes( 315 path, BasicFileAttributes, LinkOption.NOFOLLOW_LINKS 316 ) 317 if (!before.isRegularFile() || before.isSymbolicLink() || before.fileKey() == null 318 || before.size() <= 0L || before.size() > maximum) { 319 fail() 320 } 321 def digest = MessageDigest.getInstance("SHA-256") 322 long observed = 0L 323 FileChannel channel = FileChannel.open( 324 path, StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS 325 ) 326 try { 327 if (channel.size() != before.size()) { 328 fail() 329 } 330 ByteBuffer buffer = ByteBuffer.allocate(STREAM_BUFFER_BYTES) 331 while (true) { 332 int count = channel.read(buffer) 333 if (count < 0) { 334 break 335 } 336 if (count == 0) { 337 fail() 338 } 339 observed = Math.addExact(observed, (long) count) 340 if (observed > maximum) { 341 fail() 342 } 343 buffer.flip() 344 digest.update(buffer) 345 buffer.clear() 346 } 347 if (observed != before.size() || channel.position() != observed 348 || channel.size() != observed) { 349 fail() 350 } 351 } finally { 352 channel.close() 353 } 354 BasicFileAttributes after = Files.readAttributes( 355 path, BasicFileAttributes, LinkOption.NOFOLLOW_LINKS 356 ) 357 if (!sameBasicIdentity(before, after) || after.size() != observed) { 358 fail() 359 } 360 [ 361 byte_length: observed, 362 sha256: HexFormat.of().formatHex(digest.digest()), 363 ] 364 } 365 366 def artifactCorrelationKey = { artifact, String invocationBuildRoot -> 367 Path source = artifact.file.toPath().toAbsolutePath().normalize() 368 canonicalJson([ 369 component: componentCore(artifact.id.componentIdentifier, invocationBuildRoot), 370 source_path: safeString(source.toString(), 4_096), 371 ]) 372 } 373 374 def moduleVersionRow = { moduleVersion -> 375 if (moduleVersion == null) { 376 fail() 377 } 378 def identifier = moduleVersion.id 379 if (identifier == null) { 380 fail() 381 } 382 [ 383 group: safeString(identifier.group, 256), 384 name: safeString(identifier.name, 256), 385 version: safeString(identifier.version, 128), 386 ] 387 } 388 389 def writeCreateNew = { Path output, byte[] bytes -> 390 if (!output.isAbsolute() || output != output.normalize() 391 || output.fileName == null || output.parent == null 392 || bytes.length <= 0 || bytes.length > MAX_JSON_BYTES) { 393 fail() 394 } 395 safeString(output.fileName.toString(), 256) 396 Path parent = output.parent 397 BasicFileAttributes parentBefore = Files.readAttributes( 398 parent, BasicFileAttributes, LinkOption.NOFOLLOW_LINKS 399 ) 400 if (!parentBefore.isDirectory() || parentBefore.isSymbolicLink() 401 || parentBefore.fileKey() == null 402 || Files.exists(output, LinkOption.NOFOLLOW_LINKS)) { 403 fail() 404 } 405 def outputOptions = [ 406 StandardOpenOption.CREATE_NEW, 407 StandardOpenOption.WRITE, 408 LinkOption.NOFOLLOW_LINKS, 409 ] as Set 410 def outputPermissions = PosixFilePermissions.fromString("rw-------") 411 FileChannel channel = FileChannel.open( 412 output, outputOptions, PosixFilePermissions.asFileAttribute(outputPermissions) 413 ) 414 try { 415 ByteBuffer buffer = ByteBuffer.wrap(bytes) 416 while (buffer.hasRemaining()) { 417 if (channel.write(buffer) <= 0) { 418 fail() 419 } 420 } 421 channel.force(true) 422 if (channel.position() != bytes.length || channel.size() != bytes.length) { 423 fail() 424 } 425 } finally { 426 channel.close() 427 } 428 PosixFileAttributes outputAttributes = Files.readAttributes( 429 output, PosixFileAttributes, LinkOption.NOFOLLOW_LINKS 430 ) 431 if (!outputAttributes.isRegularFile() || outputAttributes.isSymbolicLink() 432 || outputAttributes.fileKey() == null 433 || outputAttributes.permissions() != outputPermissions) { 434 fail() 435 } 436 BasicFileAttributes parentAfter = Files.readAttributes( 437 parent, BasicFileAttributes, LinkOption.NOFOLLOW_LINKS 438 ) 439 if (parentBefore.fileKey() != parentAfter.fileKey() 440 || !parentAfter.isDirectory() || parentAfter.isSymbolicLink()) { 441 fail() 442 } 443 def observed = observeRegularFile(output, MAX_JSON_BYTES) 444 if (observed.byte_length != bytes.length || observed.sha256 != digestBytes(bytes)) { 445 fail() 446 } 447 } 448 449 gradle.afterProject { project, state -> 450 if (state.failure != null) { 451 return 452 } 453 def authority = authorities[project.rootProject.name] 454 if (authority == null) { 455 return 456 } 457 def matching = authority.workloads.findAll { key, ignored -> 458 key.startsWith(project.path + "|") 459 } 460 if (matching.isEmpty()) { 461 return 462 } 463 if (project.tasks.findByName("rshrAdvisoryGraph") != null) { 464 fail() 465 } 466 project.tasks.register("rshrAdvisoryGraph") { 467 doLast { 468 String targetConfiguration = requiredProperty("rshrAdvisoryConfiguration") 469 String outputArgument = requiredProperty("rshrAdvisoryOutput") 470 String authorityKey = project.path + "|" + targetConfiguration 471 String workloadId = authority.workloads[authorityKey] 472 String expectedTask = project.path + ":rshrAdvisoryGraph" 473 if (workloadId == null 474 || gradle.startParameter.taskNames != [expectedTask] 475 || !gradle.startParameter.offline) { 476 fail() 477 } 478 def configuration = project.configurations.findByName(targetConfiguration) 479 if (configuration == null || !configuration.canBeResolved) { 480 fail() 481 } 482 483 String invocationBuildRoot = authority.build_root 484 def resolution = configuration.incoming.resolutionResult 485 def allDependencies = resolution.allDependencies 486 int dependencyCount = boundedSize( 487 allDependencies, MAX_DEPENDENCY_RESULTS 488 ) 489 def dependencyResults = allDependencies.toList() 490 if (dependencyResults.size() != dependencyCount) { 491 fail() 492 } 493 dependencyResults.each { dependency -> 494 if (dependency instanceof UnresolvedDependencyResult 495 || !(dependency instanceof ResolvedDependencyResult)) { 496 fail() 497 } 498 } 499 500 def componentStates = new TreeMap<String, Object>() 501 def allComponents = resolution.allComponents 502 int componentCount = boundedSize(allComponents, MAX_COMPONENTS, false) 503 allComponents.each { component -> 504 Map core = componentCore(component.id, invocationBuildRoot) 505 String key = canonicalJson(core) 506 if (componentStates.containsKey(key)) { 507 fail() 508 } 509 def rawVariants = component.variants 510 int variantCount = boundedSize( 511 rawVariants, MAX_VARIANTS_PER_COMPONENT 512 ) 513 def variants = rawVariants.collect { variant -> variantRow(variant) } 514 if (variants.size() != variantCount) { 515 fail() 516 } 517 componentStates[key] = [ 518 component: component, 519 core: core, 520 variants: variants, 521 ] 522 } 523 if (componentStates.size() != componentCount) { 524 fail() 525 } 526 def appendVariant = { Map componentState, Map selectedVariant -> 527 if (componentState.variants.size() >= MAX_VARIANTS_PER_COMPONENT) { 528 fail() 529 } 530 componentState.variants.add(selectedVariant) 531 } 532 def rootComponent = resolution.rootComponent.get() 533 Map rootCore = componentCore(rootComponent.id, invocationBuildRoot) 534 String rootKey = canonicalJson(rootCore) 535 if (!componentStates.containsKey(rootKey)) { 536 fail() 537 } 538 appendVariant( 539 componentStates[rootKey], variantRow(resolution.rootVariant.get()) 540 ) 541 542 def edgeRows = [] 543 dependencyResults.each { dependency -> 544 Map from = componentCore(dependency.from.id, invocationBuildRoot) 545 Map to = componentCore(dependency.selected.id, invocationBuildRoot) 546 String fromKey = canonicalJson(from) 547 String toKey = canonicalJson(to) 548 if (!componentStates.containsKey(fromKey) 549 || !componentStates.containsKey(toKey)) { 550 fail() 551 } 552 Map selectedVariant = variantRow(dependency.resolvedVariant) 553 appendVariant(componentStates[toKey], selectedVariant) 554 edgeRows.add([ 555 constraint: dependency.constraint, 556 from: from, 557 requested: selectorRow(dependency.requested, invocationBuildRoot), 558 selected_variant: selectedVariant, 559 to: to, 560 ]) 561 } 562 if (edgeRows.size() != dependencyCount) { 563 fail() 564 } 565 edgeRows = canonicalSortedRows(edgeRows, MAX_DEPENDENCY_RESULTS) 566 567 def artifactCollection = configuration.incoming.artifacts 568 if (!artifactCollection.failures.isEmpty()) { 569 fail() 570 } 571 def resolvedArtifacts = artifactCollection.artifacts 572 int artifactCount = boundedSize(resolvedArtifacts, MAX_ARTIFACTS) 573 def legacyResolution = configuration.resolvedConfiguration 574 if (legacyResolution.hasError()) { 575 fail() 576 } 577 legacyResolution.rethrowFailure() 578 def legacyArtifacts = legacyResolution.resolvedArtifacts 579 int legacyArtifactCount = boundedSize(legacyArtifacts, MAX_ARTIFACTS) 580 if (legacyArtifactCount != artifactCount) { 581 fail() 582 } 583 def legacyByKey = new TreeMap<String, Object>() 584 legacyArtifacts.each { artifact -> 585 String key = artifactCorrelationKey(artifact, invocationBuildRoot) 586 def metadata = [ 587 artifact_id: artifact.id, 588 artifact_type: safeString(artifact.type, 64), 589 classifier: nullableString(artifact.classifier, 128), 590 extension: safeString(artifact.extension, 64), 591 logical_name: safeString(artifact.name, 256), 592 module_version: moduleVersionRow(artifact.moduleVersion), 593 ] 594 if (!ADMITTED_EXTENSIONS.contains(metadata.extension) 595 || legacyByKey.put(key, metadata) != null) { 596 fail() 597 } 598 } 599 if (legacyByKey.size() != legacyArtifactCount) { 600 fail() 601 } 602 long totalArtifactBytes = 0L 603 def artifactRows = resolvedArtifacts.collect { artifact -> 604 Map component = componentCore( 605 artifact.id.componentIdentifier, invocationBuildRoot 606 ) 607 Map variantOwner = componentCore(artifact.variant.owner, invocationBuildRoot) 608 String componentKey = canonicalJson(component) 609 if (componentKey != canonicalJson(variantOwner) 610 || !componentStates.containsKey(componentKey)) { 611 fail() 612 } 613 Map selectedVariant = variantRow(artifact.variant) 614 appendVariant(componentStates[componentKey], selectedVariant) 615 Path source = artifact.file.toPath().toAbsolutePath().normalize() 616 String fileName = safeString(source.fileName.toString(), 256) 617 int separator = fileName.lastIndexOf('.') 618 if (separator <= 0 || separator == fileName.length() - 1) { 619 fail() 620 } 621 String extension = safeString(fileName.substring(separator + 1), 64) 622 if (!ADMITTED_EXTENSIONS.contains(extension)) { 623 fail() 624 } 625 String correlationKey = artifactCorrelationKey( 626 artifact, invocationBuildRoot 627 ) 628 def metadata = legacyByKey.remove(correlationKey) 629 if (metadata == null || metadata.artifact_id != artifact.id 630 || metadata.extension != extension) { 631 fail() 632 } 633 def observation = observeRegularFile(source, MAX_ARTIFACT_BYTES) 634 totalArtifactBytes = Math.addExact( 635 totalArtifactBytes, (long) observation.byte_length 636 ) 637 if (totalArtifactBytes > MAX_TOTAL_ARTIFACT_BYTES) { 638 fail() 639 } 640 [ 641 artifact_name: fileName, 642 artifact_type: metadata.artifact_type, 643 classifier: metadata.classifier, 644 component: component, 645 extension: extension, 646 group: component.group, 647 logical_name: metadata.logical_name, 648 name: component.name, 649 module_version: metadata.module_version, 650 observed_byte_length: observation.byte_length, 651 observed_sha256: observation.sha256, 652 source_path: safeString(source.toString(), 4_096), 653 variant: selectedVariant, 654 version: component.version, 655 ] 656 } 657 if (artifactRows.size() != artifactCount 658 || !legacyByKey.isEmpty() 659 || !artifactCollection.failures.isEmpty()) { 660 fail() 661 } 662 artifactRows = canonicalSortedRows(artifactRows, MAX_ARTIFACTS) 663 664 def componentRows = componentStates.collect { key, value -> 665 value.core + [ 666 root: key == rootKey, 667 variant: variantEnvelope(value.variants), 668 ] 669 }.sort { left, right -> 670 canonicalJson(left) <=> canonicalJson(right) 671 } 672 if (componentRows.size() != componentCount 673 || componentRows.count { row -> row.root } != 1) { 674 fail() 675 } 676 677 def manifest = [ 678 schema: "radroots.gradle-advisory-graph.v1", 679 workload_id: workloadId, 680 build_root: invocationBuildRoot, 681 project_path: project.path, 682 configuration: targetConfiguration, 683 components: componentRows, 684 edges: edgeRows, 685 artifacts: artifactRows, 686 ] 687 byte[] bytes = canonicalJson(manifest).getBytes(StandardCharsets.UTF_8) 688 writeCreateNew(Paths.get(outputArgument), bytes) 689 } 690 } 691 }