diff --git a/Dockerfile.app b/Dockerfile.app index 9a82ead4bd..b18fa0453f 100644 --- a/Dockerfile.app +++ b/Dockerfile.app @@ -41,8 +41,34 @@ RUN dart /project/tool/pub_get_offline.dart /project/app RUN /project/tool/download-sdk-index-jsons.sh -# Generate JIT snapshot. We run it with --help to make it exit. -RUN dart compile jit-snapshot -o server.jit bin/server.dart --help +# Generate the JIT snapshot, with the native assets mapping of package:sigstore +# and package:boring embedded (see tool/build-server-snapshot.sh). The libraries +# are built in the native-build stage below and copied to the given paths in +# the final image. +RUN /project/tool/build-server-snapshot.sh /project/app/server.jit \ + /project/app/libsigstore_ffi.so /project/app/libbssl_dart.so + +# Collect what the native-build and final stages need from this one: +# - the Rust source of package:sigstore's native library, as resolved by pub, +# - the CMake project and vendored BoringSSL of package:boring, as resolved +# by pub, +# - libgcc_s, which the Rust library depends on and which is not part of the +# minimal /runtime of the Dart image. +RUN set -eux; \ + SIGSTORE_DIRS="$(ls -d /project/.pub-cache/hosted/pub.dev/sigstore-*/)"; \ + [ "$(echo "$SIGSTORE_DIRS" | wc -l)" -eq 1 ]; \ + cp -r "${SIGSTORE_DIRS}rust" /project/sigstore-rust; \ + BORING_DIRS="$(ls -d /project/.pub-cache/hosted/pub.dev/boring-*/)"; \ + [ "$(echo "$BORING_DIRS" | wc -l)" -eq 1 ]; \ + mkdir -p /project/boring; \ + cp -r "${BORING_DIRS}src" "${BORING_DIRS}third_party" /project/boring/; \ + case "$(dpkg --print-architecture)" in \ + amd64) TRIPLET="x86_64-linux-gnu" ;; \ + arm64) TRIPLET="aarch64-linux-gnu" ;; \ + *) echo "Unsupported architecture"; exit 1 ;; \ + esac; \ + mkdir -p "/project/runtime-extra/lib/$TRIPLET"; \ + cp --dereference "/lib/$TRIPLET/libgcc_s.so.1" "/project/runtime-extra/lib/$TRIPLET/libgcc_s.so.1" # Create /tmp directory for the runtime. RUN mkdir -p /project/tmp && chmod 1777 /project/tmp @@ -51,9 +77,33 @@ RUN mkdir -p /project/tmp && chmod 1777 /project/tmp RUN dart compile kernel -o search_index.dill lib/service/entrypoint/search_index.dart RUN dart compile kernel -o sdk_isolate_index.dill lib/service/entrypoint/sdk_isolate_index.dart +# Stage: Build the native libraries of package:sigstore and package:boring from +# the sources bundled with the packages (no prebuilt binaries are downloaded): +# - libsigstore_ffi.so from the Rust crate of package:sigstore, which calls +# BoringSSL through function pointers it receives from Dart at startup, so +# it does not link against it; +# - libbssl_dart.so from the CMake project of package:boring, the same build +# its `checkout` build mode runs (hook/build.dart), which installs the +# dynamic (and static) library into the install prefix. +FROM mirror.gcr.io/library/rust:1.99-bookworm AS native-build +RUN apt-get update && \ + apt-get install -y --no-install-recommends cmake && \ + rm -rf /var/lib/apt/lists/* +COPY --from=build /project/sigstore-rust /project/sigstore-rust +WORKDIR /project/sigstore-rust +RUN cargo rustc --release --locked --crate-type=cdylib && \ + test -f target/release/libsigstore_ffi.so +COPY --from=build /project/boring /project/boring +WORKDIR /project/boring +RUN cmake -S src -B build -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX=/project/boring/install && \ + cmake --build build --target install --parallel && \ + test -f install/libbssl_dart.so + # Build minimal serving image. FROM scratch COPY --from=build /runtime/ / +COPY --from=build /project/runtime-extra/ / COPY --from=build /project/tmp /tmp COPY --from=build /usr/lib/dart /usr/lib/dart COPY --from=build /project/app/server.jit /project/app/ @@ -66,6 +116,9 @@ COPY --from=build /project/app/lib/frontend/templates /project/app/lib/frontend/ COPY --from=build /project/app/.dart_tool/pub-search-data /project/app/.dart_tool/pub-search-data COPY --from=build /project/.dart_tool/package_config.json /project/.dart_tool/package_config.json COPY --from=go-build /project/pkg/signature_verifier/signature_verifier /project/app/signature_verifier +# Must match the paths given to tool/build-server-snapshot.sh above. +COPY --from=native-build /project/sigstore-rust/target/release/libsigstore_ffi.so /project/app/libsigstore_ffi.so +COPY --from=native-build /project/boring/install/libbssl_dart.so /project/app/libbssl_dart.so # Put the kernel snapshots at the same place as the source files for Isolate.spawnUri to work transparently. COPY --from=build /project/app/search_index.dill /project/app/search_index.dill COPY --from=build /project/app/sdk_isolate_index.dill /project/app/sdk_isolate_index.dill diff --git a/app/bin/server.dart b/app/bin/server.dart index ba9a1b1ffb..2628813e0c 100644 --- a/app/bin/server.dart +++ b/app/bin/server.dart @@ -7,13 +7,15 @@ import 'package:args/command_runner.dart'; import 'package:pub_dev/service/entrypoint/analyzer.dart'; import 'package:pub_dev/service/entrypoint/frontend.dart'; import 'package:pub_dev/service/entrypoint/search.dart'; +import 'package:pub_dev/service/entrypoint/self_check.dart'; import 'package:pub_dev/shared/env_config.dart'; void main(List args) async { final runner = CommandRunner('pub_dev', 'pub.dev services') ..addCommand(AnalyzerCommand()) ..addCommand(DefaultCommand()) - ..addCommand(SearchCommand()); + ..addCommand(SearchCommand()) + ..addCommand(SelfCheckCommand()); if (args.isEmpty && envConfig.isRunningInCloud && envConfig.service != null) { args = [envConfig.service!]; diff --git a/app/lib/fake/backend/fake_attestation_verifier.dart b/app/lib/fake/backend/fake_attestation_verifier.dart new file mode 100644 index 0000000000..1c84b2cda6 --- /dev/null +++ b/app/lib/fake/backend/fake_attestation_verifier.dart @@ -0,0 +1,46 @@ +// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import '../../package/attestation_verifier.dart'; + +/// An [AttestationVerifier] that does not verify Sigstore signatures. +/// +/// Only the cryptographic step is replaced: the signer is taken from the +/// builder self-reported in the provenance statement (or defaults to the +/// `dart-lang/setup-dart` publishing workflow), so tests can use hand-crafted +/// bundles without a valid signature. Provenance parsing and the policy checks +/// are the real ones. +class FakeAttestationVerifier extends AttestationVerifier { + static final _defaultSigner = GitHubWorkflowIdentity.tryParse( + 'https://github.com/dart-lang/setup-dart/' + '.github/workflows/publish.yml@refs/tags/v2', + )!; + + FakeAttestationVerifier({ + super.trustedSignerWorkflows, + super.requireTaggedSignerRef, + }); + + /// The fake does not use the native library. + @override + void checkNativeLibrary() {} + + @override + VerifiedSigner verifySignature({ + required List archiveSha256, + required String bundleJson, + }) { + GitHubWorkflowIdentity? builder; + try { + builder = BuildProvenance.parseBundle(bundleJson).builder; + } on FormatException { + // Not a provenance bundle: [AttestationVerifier.verify] parses it again + // and reports the problem, the signer is irrelevant at that point. + } + return VerifiedSigner( + identity: (builder ?? _defaultSigner).toString(), + issuer: githubActionsOidcIssuer, + ); + } +} diff --git a/app/lib/package/attestation_verifier.dart b/app/lib/package/attestation_verifier.dart new file mode 100644 index 0000000000..c1924de416 --- /dev/null +++ b/app/lib/package/attestation_verifier.dart @@ -0,0 +1,287 @@ +// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:crypto/crypto.dart'; +import 'package:gcloud/service_scope.dart' as ss; +import 'package:meta/meta.dart'; +import 'package:pub_semver/pub_semver.dart'; +import 'package:sigstore/provenance.dart'; +import 'package:sigstore/sigstore.dart'; + +import 'models.dart' show VersionProvenance; + +export 'package:sigstore/provenance.dart'; +export 'package:sigstore/sigstore.dart'; + +/// The workflows trusted to sign attestations for packages on pub.dev, as +/// `//`. +/// +/// This is the *signer* of an attestation - the reusable workflow that ran the +/// signing step - and not the repository a package was published from. All +/// packages are signed by the same small set of workflows, which is why the +/// signer identity says nothing about which package was built. That binding +/// comes from the provenance statement, see +/// [BuildProvenance.sourceRepository]. +const defaultTrustedSignerWorkflows = { + 'dart-lang/setup-dart/.github/workflows/publish.yml', +}; + +/// Result of verifying a package attestation bundle. +class AttestationVerificationResult { + final bool isValid; + final String? repository; + final BuildProvenance? provenance; + final String? signerIdentity; + final String? oidcIssuer; + final TransparencyLogEntry? transparencyLogEntry; + final List errors; + + AttestationVerificationResult({ + required this.isValid, + this.repository, + this.provenance, + this.signerIdentity, + this.oidcIssuer, + this.transparencyLogEntry, + this.errors = const [], + }); + + AttestationVerificationResult.failure({ + this.signerIdentity, + this.oidcIssuer, + required this.errors, + }) : isValid = false, + repository = null, + provenance = null, + transparencyLogEntry = null; + + /// The verified facts to persist with the package version. + /// + /// Throws a [StateError] if the verification was not successful. + VersionProvenance toVersionProvenance() { + if (!isValid) { + throw StateError('The attestation did not verify: $errors'); + } + final provenance = this.provenance!; + return VersionProvenance( + repository: provenance.sourceRepository.slug, + repositoryId: provenance.repositoryId, + repositoryOwnerId: provenance.repositoryOwnerId, + workflowPath: provenance.workflowPath, + ref: provenance.sourceRef, + commitSha: provenance.commitSha, + runUrl: provenance.runUrl, + signer: signerIdentity!, + issuer: oidcIssuer!, + transparencyLogIndex: transparencyLogEntry?.logIndex, + transparencyLogIntegratedTime: transparencyLogEntry?.integratedTime, + ); + } +} + +/// The identity of the signer of an attestation, as verified by Sigstore. +class VerifiedSigner { + /// The subject alternative name of the signing certificate - for GitHub + /// Actions the `job_workflow_ref` of the signing workflow. + final String identity; + + /// The OIDC issuer that authenticated the signer. + final String issuer; + + VerifiedSigner({required this.identity, required this.issuer}); +} + +/// Sets the attestation verifier service. +void registerAttestationVerifier(AttestationVerifier value) => + ss.register(#_attestationVerifier, value); + +/// The active attestation verifier service. +AttestationVerifier get attestationVerifier => + ss.lookup(#_attestationVerifier) as AttestationVerifier; + +/// Verifies package attestation bundles using Sigstore and SLSA build +/// provenance policy. +class AttestationVerifier { + final String? _trustedRootJson; + final bool _offline; + final Set _trustedSignerWorkflows; + final bool _requireTaggedSignerRef; + + AttestationVerifier({ + String? trustedRootJson, + bool offline = true, + Set trustedSignerWorkflows = defaultTrustedSignerWorkflows, + bool requireTaggedSignerRef = false, + }) : _trustedRootJson = trustedRootJson, + _offline = offline, + _trustedSignerWorkflows = trustedSignerWorkflows, + _requireTaggedSignerRef = requireTaggedSignerRef; + + /// Checks that the native libraries of `package:sigstore` and of + /// `package:boring`, which it routes its cryptography through, can be loaded + /// and called. + /// + /// Throws (an [ArgumentError] from `dart:ffi`) when the `@Native` bindings + /// cannot be resolved - e.g. when the native assets mapping is missing from + /// the snapshot the server runs from, see `tool/build-server-snapshot.sh`. + /// Meant to be called at startup, so that a broken deployment fails loud + /// instead of failing every attested upload. + void checkNativeLibrary() { + SigstoreClient.create(); + } + + /// Verifies that the archive with the sha256 digest [archiveSha256] is the + /// archive of [packageName] at [packageVersion], built by a trusted workflow + /// from [pubspecRepository] (and [configuredGitHubRepository] when automated + /// publishing is enabled). + /// + /// [bundleJson] is passed as a raw JSON string so that signature verification + /// and DSSE provenance parsing operate on the exact same bytes. + /// + /// Returns a failed [AttestationVerificationResult] when the bundle or the + /// attestation it carries is not acceptable. Throws when the verification + /// itself cannot be carried out (e.g. [SigstoreError.internalError] or a + /// missing native library): such failures are not the uploader's fault and + /// must not be reported as one. + AttestationVerificationResult verify({ + required String packageName, + required Version packageVersion, + required List archiveSha256, + required String bundleJson, + String? pubspecRepository, + String? configuredGitHubRepository, + bool requireRepository = true, + }) { + if (archiveSha256.length != 32) { + throw ArgumentError.value( + archiveSha256, + 'archiveSha256', + 'must be a 32-byte sha256 digest', + ); + } + + // Who signed? Cryptographically verify the bundle over the archive digest. + final VerifiedSigner verified; + try { + verified = verifySignature( + archiveSha256: archiveSha256, + bundleJson: bundleJson, + ); + } on SigstoreError catch (e) { + switch (e) { + case SigstoreError.invalidBundle: + return AttestationVerificationResult.failure( + errors: ['Attestation is not a valid Sigstore bundle.'], + ); + case SigstoreError.verificationFailed: + return AttestationVerificationResult.failure( + errors: ['Attestation signature verification failed.'], + ); + case SigstoreError.internalError: + rethrow; + } + } + final identity = verified.identity; + final issuer = verified.issuer; + + final signer = GitHubWorkflowIdentity.tryParse(identity); + if (signer == null) { + return AttestationVerificationResult.failure( + signerIdentity: identity, + oidcIssuer: issuer, + errors: [ + 'Package attestation verification is currently only supported ' + 'for GitHub Actions workflows (signer identity: "$identity").', + ], + ); + } + + // What does the signed statement say? + final BuildProvenance provenance; + try { + provenance = BuildProvenance.parseBundle(bundleJson); + } on FormatException catch (e) { + return AttestationVerificationResult.failure( + signerIdentity: identity, + oidcIssuer: issuer, + errors: [e.message], + ); + } + + final errors = checkProvenancePolicy( + packageName: packageName, + packageVersion: packageVersion.toString(), + archiveSha256: Digest(archiveSha256).toString(), + provenance: provenance, + signer: signer, + trustedSignerWorkflows: _trustedSignerWorkflows, + oidcIssuer: issuer, + expectedRepository: pubspecRepository, + configuredRepository: configuredGitHubRepository, + requireRepository: requireRepository, + requireTaggedSignerRef: _requireTaggedSignerRef, + ); + if (errors.isNotEmpty) { + return AttestationVerificationResult.failure( + signerIdentity: identity, + oidcIssuer: issuer, + errors: errors, + ); + } + + return AttestationVerificationResult( + isValid: true, + repository: provenance.sourceRepository.url, + provenance: provenance, + signerIdentity: identity, + oidcIssuer: issuer, + transparencyLogEntry: TransparencyLogEntry.tryParseBundle(bundleJson), + ); + } + + /// Verifies the Sigstore signature of [bundleJson] over the artifact with + /// the sha256 digest [archiveSha256], and returns the verified signer. + /// + /// Throws [SigstoreError.invalidBundle] if [bundleJson] is not a Sigstore + /// bundle, [SigstoreError.verificationFailed] if the signature, certificate + /// chain or transparency log entry do not verify, and other errors when the + /// verification cannot be carried out. + /// + /// Only the cryptographic part lives here, so that tests can replace it + /// (see `FakeAttestationVerifier`) while exercising the real provenance + /// parsing and policy. + @visibleForOverriding + VerifiedSigner verifySignature({ + required List archiveSha256, + required String bundleJson, + }) { + final bundle = SigstoreBundle.fromJson(bundleJson); + final client = SigstoreClient.create(); + // The expected identity is left unrestricted here and enforced by + // [checkProvenancePolicy] against the trusted signer workflows: we accept + // a set of workflows, and the ref they ran at varies. + final policy = SigstoreVerificationPolicy.create( + '', + githubActionsOidcIssuer, + _offline, + false, + _trustedRootJson ?? '', + '', + ); + // Verifying against the digest (`isDigest: true`) avoids reading and + // hashing the archive again; the subject digest of the in-toto statement + // is compared against it. + final result = client.verify(archiveSha256, true, bundle, policy); + if (!result.isValid()) { + // `package:sigstore` reports failures by throwing this enum, keep the + // defensive check consistent with it. + // ignore: only_throw_errors + throw SigstoreError.verificationFailed; + } + return VerifiedSigner( + identity: result.verifiedIdentity(), + issuer: result.verifiedIssuer(), + ); + } +} diff --git a/app/lib/package/backend.dart b/app/lib/package/backend.dart index 67de342840..369e76e9cd 100644 --- a/app/lib/package/backend.dart +++ b/app/lib/package/backend.dart @@ -53,6 +53,7 @@ import '../shared/redis_cache.dart' show cache, EntryPurgeExt; import '../shared/storage.dart'; import '../shared/urls.dart' as urls; import '../shared/utils.dart'; +import 'attestation_verifier.dart'; import 'model_properties.dart'; import 'models.dart'; import 'name_tracker.dart'; @@ -1497,10 +1498,11 @@ class PackageBackend { throw PackageRejectedException.dependencyDoesNotExists(name); } - // Read the attestation, if one was uploaded for this upload. + // Read and verify the attestation, if one was uploaded for this upload. // Note: the content has been validated to be a JSON object when it was // uploaded, see [uploadAttestation]. String? attestationContent; + VersionProvenance? provenance; if (await _incomingBucket.tryInfo(attestationObjectName) != null) { attestationContent = utf8.decode( await _incomingBucket.readAsBytes( @@ -1508,6 +1510,41 @@ class PackageBackend { maxSize: maxAttestationContentLength, ), ); + _logger.info('Verifying package attestation ($uploadGuid).'); + final existingPackage = await lookupPackage(pubspec.name); + final githubConfig = existingPackage?.publishingConfig?.githubConfig; + final configuredGitHubRepository = + (githubConfig != null && githubConfig.isEnabled) + ? githubConfig.repository + : null; + + // The signature is verified over the digest computed above from the + // very file that is being published; the archive is not read again. + // + // Only failures attributable to the uploaded bundle are reported as a + // failed result, anything else (e.g. the verifier not being available) + // propagates and fails the request with a logged internal error. + final verificationResult = attestationVerifier.verify( + packageName: pubspec.name, + packageVersion: Version.parse(versionString), + archiveSha256: sha256Hash, + bundleJson: attestationContent, + pubspecRepository: pubspec.repository?.toString(), + configuredGitHubRepository: configuredGitHubRepository, + ); + if (!verificationResult.isValid) { + throw PackageRejectedException( + 'Invalid package attestation: ' + '${verificationResult.errors.join(', ')}', + ); + } + // The verified facts are persisted with the version, so that neither + // the package page nor later policy checks need to parse the bundle. + provenance = verificationResult.toVersionProvenance(); + _logger.info( + 'Attestation verified successfully for ${pubspec.name} $versionString ' + 'from repository ${verificationResult.repository}.', + ); } sw.reset(); @@ -1516,6 +1553,7 @@ class PackageBackend { agent, archive, sha256Hash: sha256Hash, + provenance: provenance, ); final (version, uploadMessages) = await _performTarballUpload( entities: entities, @@ -2813,11 +2851,15 @@ class DerivedPackageVersionEntities { } /// Creates entities from [archive] summary. +/// +/// [provenance] is the verified build provenance of the archive, if it was +/// published with an attestation. Future<_UploadEntities> _createUploadEntities( DatastoreDB db, AuthenticatedAgent agent, PackageSummary archive, { required List sha256Hash, + required VersionProvenance? provenance, }) async { final pubspec = Pubspec.fromYaml(archive.pubspecContent!); final packageKey = db.emptyKey.append(Package, id: pubspec.name); @@ -2832,7 +2874,8 @@ Future<_UploadEntities> _createUploadEntities( ..pubspec = pubspec ..libraries = archive.libraries ..uploader = agent.agentId - ..sha256 = sha256Hash; + ..sha256 = sha256Hash + ..provenance = provenance; final derived = derivePackageVersionEntities( archive: archive, diff --git a/app/lib/package/models.dart b/app/lib/package/models.dart index 3d244062ba..1b5f7db900 100644 --- a/app/lib/package/models.dart +++ b/app/lib/package/models.dart @@ -533,6 +533,110 @@ class GcpPublishingLock { Map toJson() => _$GcpPublishingLockToJson(this); } +/// The build provenance of a package version, as verified at upload from the +/// Sigstore attestation that was published with the archive. +/// +/// These are the facts pub.dev verified at the time of the upload, persisted +/// so that nothing has to re-parse the bundle later (e.g. the package page, +/// or a lock on the provenance repository). The bundle itself is kept next +/// to the archive in the canonical bucket. +@JsonSerializable(explicitToJson: true, includeIfNull: false) +class VersionProvenance { + /// The GitHub repository the archive was built from, as `/`. + final String repository; + + /// The immutable id of [repository] at the time of the build, if the + /// provenance statement carries it. + /// + /// Unlike [repository], this survives renames and transfers, and a deleted + /// and re-created repository gets a new one. + final String? repositoryId; + + /// The immutable id of the owner of [repository] at the time of the build, + /// if the provenance statement carries it. + final String? repositoryOwnerId; + + /// The path of the workflow in [repository] that ran the build, e.g. + /// `.github/workflows/publish.yml`. + final String? workflowPath; + + /// The git ref of [repository] the build ran at, e.g. `refs/tags/v1.0.0`. + final String? ref; + + /// The commit of [repository] the build ran at. + final String? commitSha; + + /// The URL of the workflow run that built the archive, if recorded. + final String? runUrl; + + /// The identity of the trusted workflow that signed the attestation, as + /// verified by Sigstore - the full workflow reference, e.g. + /// `https://github.com/dart-lang/setup-dart/.github/workflows/publish.yml@refs/tags/v2`. + final String signer; + + /// The OIDC issuer that authenticated [signer]. + final String issuer; + + /// The index of the entry in the Sigstore transparency log (Rekor), if the + /// bundle carries one. + final int? transparencyLogIndex; + + /// The time at which the transparency log entry was integrated, if the + /// bundle carries one. + final DateTime? transparencyLogIntegratedTime; + + VersionProvenance({ + required this.repository, + this.repositoryId, + this.repositoryOwnerId, + this.workflowPath, + this.ref, + this.commitSha, + this.runUrl, + required this.signer, + required this.issuer, + this.transparencyLogIndex, + this.transparencyLogIntegratedTime, + }); + + factory VersionProvenance.fromJson(Map json) => + _$VersionProvenanceFromJson(json); + + Map toJson() => _$VersionProvenanceToJson(this); + + /// The URL of [repository]. + String get repositoryUrl => 'https://github.com/$repository'; +} + +/// A [db.Property] encoding [VersionProvenance] as JSON. +class VersionProvenanceProperty extends db.Property { + const VersionProvenanceProperty({super.propertyName, super.required = false}) + : super(indexed: false); + + @override + Object? encodeValue( + db.ModelDB mdb, + Object? value, { + bool forComparison = false, + }) { + if (value == null) return null; + return json.encode(value as VersionProvenance); + } + + @override + Object? decodePrimitiveValue(db.ModelDB mdb, Object? value) { + if (value == null) return null; + return VersionProvenance.fromJson( + json.decode(value as String) as Map, + ); + } + + @override + bool validate(db.ModelDB mdb, Object? value) => + super.validate(mdb, value) && + (value == null || value is VersionProvenance); +} + /// Pub package metadata for a specific uploaded version. /// /// Metadata such as changelog/readme/libraries are used for rendering HTML @@ -577,6 +681,13 @@ class PackageVersion extends db.ExpandoModel { @db.StringProperty() String? publisherId; + /// The build provenance of the archive, as verified from the attestation + /// that was uploaded with it. + /// + /// `null` if the version was published without an attestation. + @VersionProvenanceProperty() + VersionProvenance? provenance; + /// Whether the version has been retracted. @db.BoolProperty(required: true) bool isRetracted = false; diff --git a/app/lib/package/models.g.dart b/app/lib/package/models.g.dart index 754124e2d4..58d7b433a3 100644 --- a/app/lib/package/models.g.dart +++ b/app/lib/package/models.g.dart @@ -90,6 +90,40 @@ GcpPublishingLock _$GcpPublishingLockFromJson(Map json) => Map _$GcpPublishingLockToJson(GcpPublishingLock instance) => {'oauthUserId': instance.oauthUserId}; +VersionProvenance _$VersionProvenanceFromJson(Map json) => + VersionProvenance( + repository: json['repository'] as String, + repositoryId: json['repositoryId'] as String?, + repositoryOwnerId: json['repositoryOwnerId'] as String?, + workflowPath: json['workflowPath'] as String?, + ref: json['ref'] as String?, + commitSha: json['commitSha'] as String?, + runUrl: json['runUrl'] as String?, + signer: json['signer'] as String, + issuer: json['issuer'] as String, + transparencyLogIndex: (json['transparencyLogIndex'] as num?)?.toInt(), + transparencyLogIntegratedTime: + json['transparencyLogIntegratedTime'] == null + ? null + : DateTime.parse(json['transparencyLogIntegratedTime'] as String), + ); + +Map _$VersionProvenanceToJson(VersionProvenance instance) => + { + 'repository': instance.repository, + 'repositoryId': ?instance.repositoryId, + 'repositoryOwnerId': ?instance.repositoryOwnerId, + 'workflowPath': ?instance.workflowPath, + 'ref': ?instance.ref, + 'commitSha': ?instance.commitSha, + 'runUrl': ?instance.runUrl, + 'signer': instance.signer, + 'issuer': instance.issuer, + 'transparencyLogIndex': ?instance.transparencyLogIndex, + 'transparencyLogIntegratedTime': ?instance.transparencyLogIntegratedTime + ?.toIso8601String(), + }; + PackageView _$PackageViewFromJson(Map json) => PackageView( screenshots: (json['screenshots'] as List?) ?.map((e) => ProcessedScreenshot.fromJson(e as Map)) diff --git a/app/lib/service/entrypoint/frontend.dart b/app/lib/service/entrypoint/frontend.dart index 8fed3d7cde..c5fc0e5d2e 100644 --- a/app/lib/service/entrypoint/frontend.dart +++ b/app/lib/service/entrypoint/frontend.dart @@ -15,6 +15,7 @@ import 'package:watcher/watcher.dart'; import '../../frontend/handlers.dart'; import '../../frontend/static_files.dart'; +import '../../package/attestation_verifier.dart'; import '../../package/name_tracker.dart'; import '../../search/top_packages.dart'; import '../../service/announcement/backend.dart'; @@ -41,6 +42,11 @@ class DefaultCommand extends Command { } Future _main() async { + // Fail at startup, rather than on every attested upload, when the native + // library used to verify attestations cannot be loaded (e.g. the snapshot + // was built without its native assets mapping). + attestationVerifier.checkNativeLibrary(); + if (envConfig.isRunningLocally) { await updateLocalBuiltFilesIfNeeded(); await watchForResourceChanges(); diff --git a/app/lib/service/entrypoint/self_check.dart b/app/lib/service/entrypoint/self_check.dart new file mode 100644 index 0000000000..fff9239166 --- /dev/null +++ b/app/lib/service/entrypoint/self_check.dart @@ -0,0 +1,29 @@ +// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:args/command_runner.dart'; + +import '../../package/attestation_verifier.dart'; + +/// Checks that the running binary is able to use its native dependencies. +/// +/// Used to verify the production container image after it has been built, +/// see `app/test/service/entrypoint/dockerfile_app_build_test.dart`. +class SelfCheckCommand extends Command { + @override + String get name => 'self-check'; + + @override + String get description => + 'Checks that the binary can load its native dependencies.'; + + @override + bool get hidden => true; + + @override + Future run() async { + AttestationVerifier().checkNativeLibrary(); + print('self-check: OK'); + } +} diff --git a/app/lib/service/services.dart b/app/lib/service/services.dart index 19ae623025..87b4387353 100644 --- a/app/lib/service/services.dart +++ b/app/lib/service/services.dart @@ -28,12 +28,14 @@ import '../account/default_auth_provider.dart'; import '../account/like_backend.dart'; import '../admin/backend.dart'; import '../audit/backend.dart'; +import '../fake/backend/fake_attestation_verifier.dart'; import '../fake/backend/fake_auth_provider.dart'; import '../fake/backend/fake_domain_verifier.dart'; import '../fake/backend/fake_email_sender.dart'; import '../fake/backend/fake_upload_signer_service.dart'; import '../fake/server/fake_storage_server.dart'; import '../frontend/handlers.dart'; +import '../package/attestation_verifier.dart'; import '../package/backend.dart'; import '../package/name_tracker.dart'; import '../package/search_adapter.dart'; @@ -95,6 +97,7 @@ Future withServices(FutureOr Function() fn) async { registerAuthProvider(DefaultAuthProvider()); registerScopeExitCallback(authProvider.close); registerDomainVerifier(DomainVerifier()); + registerAttestationVerifier(AttestationVerifier()); registerEmailSender( activeConfiguration.gmailRelayServiceAccount != null && activeConfiguration.isProduction @@ -188,6 +191,7 @@ Future withFakeServices({ registerAuthProvider(FakeAuthProvider()); registerScopeExitCallback(authProvider.close); registerDomainVerifier(FakeDomainVerifier()); + registerAttestationVerifier(FakeAttestationVerifier()); registerEmailSender( FakeEmailSender(outputDir: envConfig.fakeEmailSenderOutputDir), ); diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 36f89711b8..e5e8473cb7 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -56,6 +56,13 @@ dependencies: ulid: '2.2.0' tar: '2.0.2' api_builder: + # TODO(https://github.com/dart-lang/tools/pull/2710): Depend on the hosted + # `^0.2.5` once `package:sigstore/provenance.dart` is published. + sigstore: + git: + url: https://github.com/dart-lang/tools.git + ref: 233e5ab23f23ec1ff52f79a39efb77db74d5c73d + path: pkgs/sigstore dev_dependencies: build_runner: '^2.0.0' diff --git a/app/test/package/upload_test.dart b/app/test/package/upload_test.dart index b7c9380d8e..59e50ebb6f 100644 --- a/app/test/package/upload_test.dart +++ b/app/test/package/upload_test.dart @@ -4,10 +4,12 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:typed_data'; import 'package:_pub_shared/data/admin_api.dart'; import 'package:_pub_shared/data/package_api.dart'; import 'package:clock/clock.dart'; +import 'package:crypto/crypto.dart'; import 'package:gcloud/db.dart'; import 'package:gcloud/storage.dart'; import 'package:pub_dev/account/backend.dart'; @@ -16,9 +18,11 @@ import 'package:pub_dev/audit/backend.dart'; import 'package:pub_dev/audit/models.dart'; import 'package:pub_dev/database/database.dart'; import 'package:pub_dev/database/schema.dart'; +import 'package:pub_dev/fake/backend/fake_attestation_verifier.dart'; import 'package:pub_dev/fake/backend/fake_auth_provider.dart'; import 'package:pub_dev/fake/backend/fake_email_sender.dart'; import 'package:pub_dev/frontend/handlers/pubapi.client.dart'; +import 'package:pub_dev/package/attestation_verifier.dart'; import 'package:pub_dev/package/backend.dart'; import 'package:pub_dev/package/models.dart'; import 'package:pub_dev/package/name_tracker.dart'; @@ -28,6 +32,7 @@ import 'package:pub_dev/service/secret/backend.dart'; import 'package:pub_dev/shared/configuration.dart'; import 'package:pub_dev/shared/exceptions.dart'; import 'package:pub_dev/tool/test_profile/models.dart'; +import 'package:pub_semver/pub_semver.dart'; import 'package:test/test.dart'; import 'package:typed_sql/typed_sql.dart' hide AuthenticationException; import 'package:yaml/yaml.dart'; @@ -1724,22 +1729,18 @@ void main() { ], fn: () async { final pubspecContent = - 'name: attested_pkg\nversion: 1.0.0\ndescription: A package with attestation.\nenvironment:\n sdk: ">=2.12.0 <4.0.0"\n'; + 'name: attested_pkg\nversion: 1.0.0\ndescription: A package with attestation.\nrepository: https://github.com/mosuem/attested_pkg\nenvironment:\n sdk: ">=2.12.0 <4.0.0"\n'; final archiveBytes = await packageArchiveBytes( pubspecContent: pubspecContent, ); - final bundleJson = { - 'mediaType': 'application/vnd.dev.sigstore.bundle.v0.3+json', - 'verificationMaterial': {}, - 'dsseEnvelope': { - 'payloadType': 'application/vnd.in-toto+json', - 'payload': base64Encode(utf8.encode('{}')), - 'signatures': [], - }, - }; + final bundleJson = _buildProvenanceBundleJson( + subjectName: 'attested_pkg-1.0.0.tar.gz', + subjectSha256: sha256.convert(archiveBytes).toString(), + repository: 'https://github.com/mosuem/attested_pkg', + ); // Verbatim is what the spec promises: keep the odd formatting. final attestationBytes = utf8.encode( - ' ${jsonEncode(bundleJson).replaceAll(',', ',\n ')}\n', + ' ${bundleJson.replaceAll(',', ',\n ')}\n', ); final client = createPubApiClient(authToken: adminClientToken); @@ -1782,6 +1783,26 @@ void main() { ); expect(info!.assets, isNot(contains('attestation'))); + // The verified provenance facts are persisted with the version. + final pv = await packageBackend.lookupPackageVersion( + 'attested_pkg', + '1.0.0', + ); + expect(pv!.provenance?.toJson(), { + 'repository': 'mosuem/attested_pkg', + 'workflowPath': '.github/workflows/publish.yml', + 'ref': 'refs/tags/v1.0.0', + 'commitSha': 'c0ffee7891abbe3dab159e9d0187fc1042d5e0cd', + 'signer': + 'https://github.com/dart-lang/setup-dart/.github/workflows/publish.yml@refs/tags/v2', + 'issuer': 'https://token.actions.githubusercontent.com', + }); + final unattested = await packageBackend.lookupPackageVersion( + 'attested_pkg', + '0.9.0', + ); + expect(unattested!.provenance, isNull); + // The attestation is exported, to be served from the bucket. final exported = await packageBackend.getExportedAttestationInfo( 'attested_pkg', @@ -1850,19 +1871,26 @@ void main() { ], fn: () async { final pubspecContent = - 'name: attested_pkg\nversion: 1.0.0\ndescription: A package with attestation.\nenvironment:\n sdk: ">=2.12.0 <4.0.0"\n'; + 'name: attested_pkg\nversion: 1.0.0\ndescription: A package with attestation.\nrepository: https://github.com/mosuem/attested_pkg\nenvironment:\n sdk: ">=2.12.0 <4.0.0"\n'; final archiveBytes = await packageArchiveBytes( pubspecContent: pubspecContent, ); + final attestationBytes = utf8.encode( + _buildProvenanceBundleJson( + subjectName: 'attested_pkg-1.0.0.tar.gz', + subjectSha256: sha256.convert(archiveBytes).toString(), + repository: 'https://github.com/mosuem/attested_pkg', + ), + ); final client = createPubApiClient(authToken: adminClientToken); await client.uploadPackageBytes( archiveBytes, - attestationBytes: utf8.encode('{"mediaType": "test"}'), + attestationBytes: attestationBytes, ); await asyncQueue.ongoingProcessing; expect( await client.getPackageVersionAttestation('attested_pkg', '1.0.0'), - utf8.encode('{"mediaType": "test"}'), + attestationBytes, ); await createPubApiClient(authToken: siteAdminToken).adminInvokeAction( @@ -1891,6 +1919,288 @@ void main() { }, ); + testWithProfile( + 'upload fails when provenance repository mismatches pubspec.yaml or configured GitHub repository', + fn: () async { + // 1. Mismatch between provenance repository and pubspec.yaml repository + final pubspecContent = + 'name: mismatched_repo_pkg\nversion: 1.0.0\ndescription: Package with mismatched repo.\nrepository: https://github.com/dart-lang/mismatched_repo_pkg\nenvironment:\n sdk: ">=2.12.0 <4.0.0"\n'; + final archiveBytes = await packageArchiveBytes( + pubspecContent: pubspecContent, + ); + final mismatchedBundle = _buildProvenanceBundleJson( + subjectName: 'mismatched_repo_pkg-1.0.0.tar.gz', + subjectSha256: sha256.convert(archiveBytes).toString(), + repository: 'https://github.com/evil/mismatched_repo_pkg', + ); + final rs1 = createPubApiClient(authToken: adminClientToken) + .uploadPackageBytes( + archiveBytes, + attestationBytes: utf8.encode(mismatchedBundle), + ); + await expectApiException( + rs1, + status: 400, + code: 'PackageRejected', + message: + 'Attestation states the package was built from "https://github.com/evil/mismatched_repo_pkg", but the pubspec.yaml of mismatched_repo_pkg 1.0.0 declares `repository: https://github.com/dart-lang/mismatched_repo_pkg`.', + ); + + // 2. Mismatch between provenance repository and pub.dev's stored + // GitHub Actions automated publishing configuration (even when + // pubspec.yaml inside the tarball self-asserts the attacker's repo). + await withFakeAuthRetryPubApiClient(email: adminAtPubDevEmail, ( + client, + ) async { + await client.setAutomatedPublishing( + 'oxygen', + PkgPublishingConfig( + github: GitHubPublishingConfig( + isEnabled: true, + repository: 'dart-lang/oxygen', + tagPattern: '{{version}}', + ), + ), + ); + }); + + final oxygenPubspec = + 'name: oxygen\nversion: 2.2.0\ndescription: oxygen is awesome\nrepository: https://github.com/evil/oxygen\nenvironment:\n sdk: ">=2.12.0 <4.0.0"\n'; + final oxygenBytes = await packageArchiveBytes( + pubspecContent: oxygenPubspec, + ); + final spoofedBundle = _buildProvenanceBundleJson( + subjectName: 'oxygen-2.2.0.tar.gz', + subjectSha256: sha256.convert(oxygenBytes).toString(), + repository: 'https://github.com/evil/oxygen', + ); + final rs2 = createPubApiClient(authToken: adminClientToken) + .uploadPackageBytes( + oxygenBytes, + attestationBytes: utf8.encode(spoofedBundle), + ); + await expectApiException( + rs2, + status: 400, + code: 'PackageRejected', + message: + 'Attestation states the package was built from "https://github.com/evil/oxygen", which does not match the configured GitHub Actions automated publishing repository "dart-lang/oxygen".', + ); + + // 3. Untrusted signing workflow rejected + final untrustedBuilderBundle = _buildProvenanceBundleJson( + subjectName: 'mismatched_repo_pkg-1.0.0.tar.gz', + subjectSha256: sha256.convert(archiveBytes).toString(), + repository: 'https://github.com/dart-lang/mismatched_repo_pkg', + builderId: + 'https://github.com/evil/workflows/.github/workflows/publish.yml@refs/tags/v1', + ); + final rs3 = createPubApiClient(authToken: adminClientToken) + .uploadPackageBytes( + archiveBytes, + attestationBytes: utf8.encode(untrustedBuilderBundle), + ); + await expectApiException( + rs3, + status: 400, + code: 'PackageRejected', + message: 'not a workflow trusted to publish packages', + ); + }, + ); + + testWithProfile( + 'upload fails when the attestation is for a different archive', + fn: () async { + final pubspecContent = + 'name: tampered_pkg\nversion: 1.0.0\ndescription: Archive differs from the attested one.\nrepository: https://github.com/mosuem/tampered_pkg\nenvironment:\n sdk: ">=2.12.0 <4.0.0"\n'; + final archiveBytes = await packageArchiveBytes( + pubspecContent: pubspecContent, + ); + final otherSha256 = sha256.convert(utf8.encode('other')).toString(); + final bundleJson = _buildProvenanceBundleJson( + subjectName: 'tampered_pkg-1.0.0.tar.gz', + subjectSha256: otherSha256, + repository: 'https://github.com/mosuem/tampered_pkg', + ); + final rs = createPubApiClient(authToken: adminClientToken) + .uploadPackageBytes( + archiveBytes, + attestationBytes: utf8.encode(bundleJson), + ); + await expectApiException( + rs, + status: 400, + code: 'PackageRejected', + message: + 'Attestation is for an archive with sha256 "$otherSha256", but this archive has sha256 "${sha256.convert(archiveBytes)}".', + ); + }, + ); + + test('verifies a real signed package attestation and provenance policies', () { + final verifier = AttestationVerifier(); + final helpfulSha256 = sha256.convert(_helpfulArchiveBytes).bytes; + + final validResult = verifier.verify( + packageName: 'helpful', + packageVersion: Version(0, 1, 5), + archiveSha256: helpfulSha256, + bundleJson: _helpfulBundleJson, + pubspecRepository: 'https://github.com/mosuem/helpful', + configuredGitHubRepository: 'mosuem/helpful', + ); + expect(validResult.errors, isEmpty); + expect(validResult.isValid, isTrue); + expect(validResult.repository, 'https://github.com/mosuem/helpful'); + expect( + validResult.provenance?.commitSha, + '63216a3022ed606538399634cd1c525f8bd4657f', + ); + + // The facts that get persisted with the version. + final persisted = validResult.toVersionProvenance(); + expect(persisted.toJson(), { + 'repository': 'mosuem/helpful', + 'repositoryId': '1339313025', + 'repositoryOwnerId': '27859082', + 'workflowPath': '.github/workflows/publish.yaml', + 'ref': 'refs/heads/main', + 'commitSha': '63216a3022ed606538399634cd1c525f8bd4657f', + 'runUrl': + 'https://github.com/mosuem/helpful/actions/runs/33514943555/attempts/1', + 'signer': + 'https://github.com/dart-lang/setup-dart/.github/workflows/publish.yml@refs/heads/publish-package-attestations', + 'issuer': 'https://token.actions.githubusercontent.com', + 'transparencyLogIndex': 2676401329, + 'transparencyLogIntegratedTime': '2026-09-01T13:42:37.000Z', + }); + expect(persisted.repositoryUrl, 'https://github.com/mosuem/helpful'); + // ... and they survive the JSON round-trip of the Datastore property. + expect( + VersionProvenance.fromJson(persisted.toJson()).toJson(), + persisted.toJson(), + ); + + final wrongPubspecRepo = verifier.verify( + packageName: 'helpful', + packageVersion: Version(0, 1, 5), + archiveSha256: helpfulSha256, + bundleJson: _helpfulBundleJson, + pubspecRepository: 'https://github.com/evil/helpful', + ); + expect(wrongPubspecRepo.isValid, isFalse); + expect( + wrongPubspecRepo.errors.single, + contains( + 'Attestation states the package was built from "https://github.com/mosuem/helpful", but the pubspec.yaml of helpful 0.1.5 declares `repository: https://github.com/evil/helpful`.', + ), + ); + + final wrongConfiguredRepo = verifier.verify( + packageName: 'helpful', + packageVersion: Version(0, 1, 5), + archiveSha256: helpfulSha256, + bundleJson: _helpfulBundleJson, + pubspecRepository: 'https://github.com/mosuem/helpful', + configuredGitHubRepository: 'dart-lang/helpful', + ); + expect(wrongConfiguredRepo.isValid, isFalse); + expect( + wrongConfiguredRepo.errors.single, + contains( + 'does not match the configured GitHub Actions automated publishing repository "dart-lang/helpful"', + ), + ); + + // The signature is over the archive digest: a different archive fails + // signature verification before any policy is evaluated. + final wrongArchive = verifier.verify( + packageName: 'helpful', + packageVersion: Version(0, 1, 5), + archiveSha256: sha256.convert(utf8.encode('other')).bytes, + bundleJson: _helpfulBundleJson, + pubspecRepository: 'https://github.com/mosuem/helpful', + ); + expect(wrongArchive.isValid, isFalse); + expect( + wrongArchive.errors.single, + 'Attestation signature verification failed.', + ); + + // A bundle with a plain artifact signature (no dsseEnvelope) is not a + // valid attestation for the archive. + final noDsseResult = verifier.verify( + packageName: 'helpful', + packageVersion: Version(0, 1, 5), + archiveSha256: helpfulSha256, + bundleJson: _sampleBundleJson, + pubspecRepository: 'https://github.com/mosuem/helpful', + ); + expect(noDsseResult.isValid, isFalse); + + // Not a Sigstore bundle at all. + final notABundle = verifier.verify( + packageName: 'helpful', + packageVersion: Version(0, 1, 5), + archiveSha256: helpfulSha256, + bundleJson: '{"hello": "world"}', + pubspecRepository: 'https://github.com/mosuem/helpful', + ); + expect(notABundle.isValid, isFalse); + expect( + notABundle.errors.single, + 'Attestation is not a valid Sigstore bundle.', + ); + }); + + test('fake verifier reports a bundle without provenance', () { + final noDsseResult = FakeAttestationVerifier().verify( + packageName: 'helpful', + packageVersion: Version(0, 1, 5), + archiveSha256: sha256.convert(_helpfulArchiveBytes).bytes, + bundleJson: _sampleBundleJson, + pubspecRepository: 'https://github.com/mosuem/helpful', + ); + expect(noDsseResult.isValid, isFalse); + expect( + noDsseResult.errors.single, + contains('carries no build provenance'), + ); + }); + + test('internal verification errors are not reported as invalid', () { + final digest = sha256.convert(_helpfulArchiveBytes).bytes; + expect( + () => _ThrowingAttestationVerifier(SigstoreError.internalError).verify( + packageName: 'helpful', + packageVersion: Version(0, 1, 5), + archiveSha256: digest, + bundleJson: _helpfulBundleJson, + ), + throwsA(SigstoreError.internalError), + ); + expect( + () => _ThrowingAttestationVerifier(StateError('no native library')) + .verify( + packageName: 'helpful', + packageVersion: Version(0, 1, 5), + archiveSha256: digest, + bundleJson: _helpfulBundleJson, + ), + throwsStateError, + ); + expect( + () => AttestationVerifier().verify( + packageName: 'helpful', + packageVersion: Version(0, 1, 5), + archiveSha256: [1, 2, 3], + bundleJson: _helpfulBundleJson, + ), + throwsArgumentError, + ); + }); + testWithProfile( 'retrieving attestation of a package without attestation returns 404', fn: () async { @@ -2008,5 +2318,104 @@ void main() { ); }, ); + + testWithProfile( + 'upload fails when attestation is invalid or sha256 does not match', + fn: () async { + final pubspecContent = + 'name: invalid_attested_pkg\nversion: 1.0.0\ndescription: An invalid attested package.\nrepository: https://github.com/mosuem/invalid_attested_pkg\nenvironment:\n sdk: ">=2.12.0 <4.0.0"\n'; + final archiveBytes = await packageArchiveBytes( + pubspecContent: pubspecContent, + ); + + final attestationBytes = utf8.encode(_sampleBundleJson); + final rs = createPubApiClient( + authToken: adminClientToken, + ).uploadPackageBytes(archiveBytes, attestationBytes: attestationBytes); + await expectApiException( + rs, + status: 400, + code: 'PackageRejected', + message: 'Invalid package attestation', + ); + }, + ); }); } + +/// An [AttestationVerifier] whose signature verification fails with [error]. +class _ThrowingAttestationVerifier extends AttestationVerifier { + final Object error; + _ThrowingAttestationVerifier(this.error); + + @override + VerifiedSigner verifySignature({ + required List archiveSha256, + required String bundleJson, + }) { + Error.throwWithStackTrace(error, StackTrace.current); + } +} + +String _buildProvenanceBundleJson({ + required String subjectName, + required String subjectSha256, + required String repository, + String ref = 'refs/tags/v1.0.0', + String workflowPath = '.github/workflows/publish.yml', + String commitSha = 'c0ffee7891abbe3dab159e9d0187fc1042d5e0cd', + String builderId = + 'https://github.com/dart-lang/setup-dart/.github/workflows/publish.yml@refs/tags/v2', +}) { + final statement = { + '_type': 'https://in-toto.io/Statement/v1', + 'subject': [ + { + 'name': subjectName, + 'digest': {'sha256': subjectSha256}, + }, + ], + 'predicateType': 'https://slsa.dev/provenance/v1', + 'predicate': { + 'buildDefinition': { + 'buildType': 'https://actions.github.io/buildtypes/workflow/v1', + 'externalParameters': { + 'workflow': { + 'ref': ref, + 'repository': repository, + 'path': workflowPath, + }, + }, + 'resolvedDependencies': [ + { + 'uri': 'git+$repository@$ref', + 'digest': {'gitCommit': commitSha}, + }, + ], + }, + 'runDetails': { + 'builder': {'id': builderId}, + }, + }, + }; + return jsonEncode({ + 'mediaType': 'application/vnd.dev.sigstore.bundle+json;version=0.3', + 'dsseEnvelope': { + 'payload': base64Encode(utf8.encode(jsonEncode(statement))), + 'payloadType': 'application/vnd.in-toto+json', + 'signatures': [ + {'sig': 'not-a-real-signature'}, + ], + }, + }); +} + +final Uint8List _helpfulArchiveBytes = base64Decode( + 'H4sIAAAAAAAAA+1ae2/bOBLv3/wUg/UCSRauX4ljnIMeoNhKIsCxfJLcbO5wbWiJttlKoo6kkvoW+90PQ0m2m7SX22KdfZwHaCOLjxkO58H5UTGfvdo1AQD0ul0oqPXo7xefT3u9DnR3LtmrV69ypal81YIs/++KeK79D0oxnzWXLM7medyIqNS74GE29OTkq/vfOe1Bu9s56Xa7x62TUwBot3snbWjtQpjH9H++/81mE/w8y4TUMBcSIsHTBSiRML3EJ/rA8EeDNJtN/AfXQjJAS4lEqGAhYMkka5CYzySVqzNC2Ccz2YGSYWVZ72dUMWNeB2eENJsQuEO3D3bRk6YrKIZzpoCnmqURi4w0YcxZqhWIOeglV5DR8CNdsAb5rdX2pyH0fyXDnfL4hvjf65629/H/Bajc/6ee+ivyeDb+d9qP4/9xt93ax/8XoHUwnuQadRDzEOY0xNCvV5mJx0XonfOYNYhJAYMlCz8q4HNYiRyoZOssAX4meMxkv2ppkDCmSoFVdICfCMBMiBgWTANX1es3fwUtc3ZGft5H9pelwZU1vrRH7mUjiXbF4zn/7/Y6j/2/ddLb+/9LUK0GrUa70SXkNQRMaUhEKiTLBCgt81DnkmEIuMs+LlSVJO4ahJTjTqpxLDKHQuCp0jSOIeELSTUXKY42cUUt4UHIj/NYPGzGH2+Np7kWCcWnOZdsKRSDUCQJ15DxNMWIdM8pfBCzRjXPe7Wkm7k6W3OFNI6ZfJ3QlC7wZ660SGCW8zgCpVkGD1wvIZPsNb7TQGW45PdsM1kbJ7MiPIhecn2Vz8AKcTnqyWKqqcQ9S2kaMlDFcXozVwvnclKuOY3hnknFRfp7OcJm+UxlLGysaBLvisdz/n/Saj2p/9q9zt7/X4BSmrA+lI5NIqZCyTM09D5Y1WsYomeXFV6DlBbcLwMHBgvFtZCrPiy1zlS/2VxwvcxnjVAkzUSonCVV6CCEpfdcijRhqe4TABV97MO740YLvaSGHgeSLfKYSohYhpVgGmJZWFSZ269wdA0yqpd9eNdu/AUniNj9+8d9Yp5q1Yd3XcMDQDOlzYhOt3H6O/HC345GzsAe+/ZOeTzj/53e8WP8B3q99h7/eQkaiGwl+WKpodPqdOqgl6xw90yKDyzUmJWXQqoGIR6LuNKSz3KT12kaQa7M6UCJXIbMvJnxlMoVgjeJqhepUUjzV+SaJCLicx6ag0HdVA4ZkwnXmLIxg3LMt3pJtZFjLuJYPGDiD0Ua8SL9UslIwnSfELSTH+BzqQxUVIoTiohBkisNkmlq6hgGdCbusalcNSlMMBWah6xeVDoxVxqn2WaaRo8kirgKY8oTJhtfEYSn28qoBMmkiPKQbWQpJVhL9MtlKWfYSATlWiMR5hhnabVfTSFB6CWTgOcsyWms1movZzE7ZgZvLaZa4phxMxrbMXGgZJdCLGIGo9EAUrFpMhvBtVovLy1mE1JBQlcwY2g8EWgBLI2EVAztJJMiEZpBoSWtIGKS36+Fm0uRFHpRYq4f0H5KywI8xKBpQSY5GpxEo0oL81LFgYsEV44PvnsR3FieDY4PE8996wztIZzfQnBlw8Cd3HrO5VUAV+5oaHs+WOMhDNxx4Dnn08D1fPKd5YPjf2carPEt2D9OPNv3wfXAuZ6MHHsIN5bnWePAsf06OOPBaDp0xpd1OJ8GMHYDMnKuncAeQuDWDdOnw8C9gGvbw9IssM6dkRPcGn4XTjBGXheuRyyYWF7gDKYjy4PJ1Ju4vg24rKHjD0aWc20PG+CMYeyC/dYeB+BfWaPR56sk7s3Y9lD07SXCuQ0jxzof2cjILHLoePYgwNVsngbO0B4H1qhO/Ik9cKxRHewf7evJyPJu6+Wcvv23qT0OHGsEQ+vaurR9OHxGIxPPHUw9+xpFdi/An577gRNMAxsuXXdo9Ozb3ltnYPtnMHJ9o6ypb9fJ0Aosw3jiuRdO4J/h8/nUd4zOnHFge950Ejju+Aiu3Bv7re3BwJr69tAo1x3jUklwZbveLU6KOjC6r8PNlR1c2R7q02jKQhX4gecMgu1urgeB6wVks0YY25cj59IeD2xsdXGWG8e3j8DyHB87OIYt3Fi34E7NknGLpr5NzOOWwdbNRoJzAdbwrYNil50nru87pZkYlQ2uSnX/D0UGnoZ2nWO+Bf/ttI73+O8LEO7/GvzFHzu4BXy2/us+wX+OO63u/vz3AsST4rauvFnrl6bw2Z3wwRl53M2Yzdpc8FbvXvAIEsrTwyOD8i6kyLPDA6t4MDd4TGl1UIeyA8CcpzSusGN4U4HEh0dnxeFOMT3NDtfdAZpNLBDNOYjG2JxnsBDr6hC7/FwNRm6HBxdcKm1AoW3GAOxTxkJ9WOHWayS6DlwFMmdHZ+vZiv//rMC0Z1vDa3uH4O/z/t9ut06f4L/t7h7/fQmqrbEfYkGMBcgDM2WIKQJzzWOuq+v5VVksYP1TQUPsk2apWhcm+JqZarFWgwtGET9WCH/6PMniokQMY0bTsmfJAREevO83TGmWxWWNqBrkNfydSWH4SPR6maeaJ+wzdKhgd8m0Nh8vaCo1iwhBMOmuwqyx0liJXMLdNuR51yfk7u4OH5+AS+XIPrwrQNy7uzvDZ6rogplhGPuehMavRNAnAbKIfpKpPNbwBhHrMI+pNuEPsIxJ9eGBZ5r78H3R78BEokqQrVjIUywyjc4IuRAS/pUzZTSIEU3lTNWxvlqXYUVLFjOqGNxzxYuK+x8l1r3B9P55+Cymd/R7wbL39MuJpjReKa7ei6xwuB1cBDwX/3u9kyfnv/bp6T7+vwDVIKhu9zE6zPkCI7YJBgqBoxAqCylDVRGoTSStIMJDJqWQqk5q8EAl3tSpuon0Bno/apAaKdmwlM7icvoDyfB6z3zsdYCHOTwjmhEFznNXBVTz7q5RzYE9MfIo4BFLNZ+vIMFPyIowV6CHiDDFjBp4KZNiFrNEwcOSpZg+UEBSK8OhyhPMGSbxIGBYCM4wnIZMGRxp3Z4r80Uc4J8Yj4oRxwtLHpIaKL0q01sRiItFO8UnEg801TguMbeSn68V20OaQrik6YIV6JYWJZ61IrVNYjH9m6GQzDjpQQOCJVPMwKgfEF1ErSZCaQhR6pDGBQtSg0Ns2tK3EYGnYZxH5W7gtJv9wpm3XhXfeMRKwANql6sCu5ut0GsaEbs3NqFCIVFBpbyYlUsWffh8DVuSmKXgzc80Ld49hn4VM/euWzoBusl7Mo+L9G8EZbJPagDF2+IR4DWENGHx+5Aq9t5804LdjVX/uxrAPhVyrofgtVJTi2bZEDXRQVTzhx9wLObXRJiL8XXWRTA31xtVoiU8Vni153VQjJHa+rYMTwioxeZCmO19XWxbyYl+McuX/CqXNd/rVI781fm1ELFqVg5tsjb7RPFgttMY8y34z2m3t8d/XoDK/V9DQOXvXxUFeg7/afe6j/P/yfFxb5//X4C+tXq5p/LLyM26dClb+/D9T09glp8P/sSQyp72tKc9/SHoPzGxcHMAOAAA', +); + +const String _helpfulBundleJson = + r'''{"mediaType":"application/vnd.dev.sigstore.bundle.v0.3+json","verificationMaterial":{"certificate":{"rawBytes":"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"},"tlogEntries":[{"logIndex":"2676401329","logId":{"keyId":"wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0="},"kindVersion":{"kind":"dsse","version":"0.0.1"},"integratedTime":"1788270157","inclusionPromise":{"signedEntryTimestamp":"MEYCIQCF0fJPsDlo0/ETbGoj7P1Tv5t6/KnFTC4li5/hgfp+zQIhAMHiH5jQct+InQUDaLsvrj4ScaLttabrbOtgOOFNP5TB"},"inclusionProof":{"logIndex":"2554497067","rootHash":"3VYInn5w29R1Ik5hPXoKNUXLSpjYDA+eOe15tHZDf3Q=","treeSize":"2554497077","hashes":["vEJRC4WGQtYk/U8bQHZ1GSxv9gXkeDlMle2iNtj5oLk=","A+Z5tXgPG9hRnNyqfrNlhB/lSijIrKbBlghyVizPfDw=","34bE/cxzMRK5jIX0UnN60fixf7k7tlZnLfYP9WwTbAA=","xF6ied/Idor3bSwQm8r7l+4G+22wzhE4Yyo2TSTTJzU=","mI6+Jl7u+5zbsD2btfPoIi4JXtac2ZA3T4OQu5XBQv0=","+kWrmBfcvkeD3p6uiuVZRX0NzFhOAsr66BfgZXcY/gE=","Rt2No6mKQC0/Lm4I88OyNhBZctDQArHU/FWcBQXRKHk=","YnrIcUp47OZh4DKzWnPt6HbUJ47Z6J/60daOiLSJFhM=","PSk3xvEFw94Y6oOvFw7ze0gTgr7OTNkRO9qcIOHxQWc=","/7Mrva+smcuOSAUamgAAnlU6WDjX01mVt0u9CfyOS3w=","eXSLrLqKSxRTixSP/k4YimriB2lufao6trqTrsxxlyg=","SndbMKVtcTenAkwi2JBfGzD+mhexp1qJbRIY+A1JRIU=","xH/DCseLHr9eKoYT8qsORZK7zVdEGYWHuVtsVrD95wY="],"checkpoint":{"envelope":"rekor.sigstore.dev - 1193050959916656506\n2554497077\n3VYInn5w29R1Ik5hPXoKNUXLSpjYDA+eOe15tHZDf3Q=\n\n\u2014 rekor.sigstore.dev wNI9ajBFAiEA+vbOqxP76bar3G0xXiMl0qAQ249eIvk2LPNvYJjep64CIGC3cdY2YUlaa6vc0djTkh1irUcugzw9PpnCKsURbPHp\n"}},"canonicalizedBody":"eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiZHNzZSIsInNwZWMiOnsiZW52ZWxvcGVIYXNoIjp7ImFsZ29yaXRobSI6InNoYTI1NiIsInZhbHVlIjoiZDNkOTMxYjZjN2VjNWNhZjRmOTA0MWQwOWNhZTk4ODY5MjhlODUzMDg1NGY3NzQzMmZkY2QxNWYxM2Q4NjU4YSJ9LCJwYXlsb2FkSGFzaCI6eyJhbGdvcml0aG0iOiJzaGEyNTYiLCJ2YWx1ZSI6IjFhOWQ5MTFmZDBkM2Y2NGY1NmY1ZjZjNGIxMTEwN2VmMzY2MmI0ZGViZGM3YmJjZjY2NTliMzQ4NzY5NzQ4OWQifSwic2lnbmF0dXJlcyI6W3sic2lnbmF0dXJlIjoiTUVZQ0lRRGgzeXY1MXREZ1Q5UWFHemh0VXRBSEFsMWpQa2dqSXF2dlZBcFBmWUNaa3dJaEFNNExlaTZvZDFOc1RrdmZOY21zeVlyT3A0azRhNDljYlhWYVRPK0hoTzAwIiwidmVyaWZpZXIiOiJMUzB0TFMxQ1JVZEpUaUJEUlZKVVNVWkpRMEZVUlMwdExTMHRDazFKU1VoVFZFTkRRbk1yWjBGM1NVSkJaMGxWVTJKTGNuWTFjbkp5VlZVclZtOUtVRE5FYzFaelRVSlZhMkZOZDBObldVbExiMXBKZW1vd1JVRjNUWGNLVG5wRlZrMUNUVWRCTVZWRlEyaE5UV015Ykc1ak0xSjJZMjFWZFZwSFZqSk5ValIzU0VGWlJGWlJVVVJGZUZaNllWZGtlbVJIT1hsYVV6RndZbTVTYkFwamJURnNXa2RzYUdSSFZYZElhR05PVFdwWmQwOVVRWGhOVkUwd1RXcE5NMWRvWTA1TmFsbDNUMVJCZUUxVVRURk5hazB6VjJwQlFVMUdhM2RGZDFsSUNrdHZXa2w2YWpCRFFWRlpTVXR2V2tsNmFqQkVRVkZqUkZGblFVVnpNRmxLVTJSVU1saE9hRll3VUV0Q1lrUndOMVZ5V0hseU9FTTFjR2t4UTNrNFlsUUtNVmMwWkRKNWNEYzJhRzlrUTFZd0szUnZMMVo0WVhjeVJrTnVTa3R4WkVSNlpGWXJRMHR1WW1VeVlrVTVWM0pVYWt0UFEwSmxOSGRuWjFoeFRVRTBSd3BCTVZWa1JIZEZRaTkzVVVWQmQwbElaMFJCVkVKblRsWklVMVZGUkVSQlMwSm5aM0pDWjBWR1FsRmpSRUY2UVdSQ1owNVdTRkUwUlVablVWVnZlakZRQ25JdlZsbFBWVWRCYnpSRWJrbE5lWGRHTlU5UE9VTmpkMGgzV1VSV1VqQnFRa0puZDBadlFWVXpPVkJ3ZWpGWmEwVmFZalZ4VG1wd1MwWlhhWGhwTkZrS1drUTRkMlYzV1VSV1VqQlNRVkZJTDBKSVJYZGlORnAwWVVoU01HTklUVFpNZVRsdVlWaFNiMlJYU1hWWk1qbDBUREpTYUdOdVVYUmlSMFoxV25rNWVncGFXRkl4WTBNeGExbFlTakJNZVRWdVlWaFNiMlJYU1haa01qbDVZVEphYzJJelpIcE1NMEl4V1cxNGNHTXlaM1ZsVnpGelVVaEtiRnB1VFhaaFIxWm9DbHBJVFhaalNGWnBZa2RzZW1GRE1YZFpWMDV5V1Zka2JFeFhSakJrUjFaNlpFZEdNR0ZYT1hWamVrRTFRbWR2Y2tKblJVVkJXVTh2VFVGRlFrSkRkRzhLWkVoU2QyTjZiM1pNTTFKMllUSldkVXh0Um1wa1IyeDJZbTVOZFZveWJEQmhTRlpwWkZoT2JHTnRUblppYmxKc1ltNVJkVmt5T1hSTlFqaEhRMmx6UndwQlVWRkNaemM0ZDBGUlNVVkZXR1IyWTIxMGJXSkhPVE5ZTWxKd1l6TkNhR1JIVG05TlJGbEhRMmx6UjBGUlVVSm5OemgzUVZGTlJVdEVXWHBOYWtVeUNsbFVUWGROYWtwc1drUlpkMDVxVlhwUFJFMDFUMVJaZWs1SFRtdE5WMDB4VFdwV2JVOUhTbXRPUkZreFRqSlpkMHRCV1V0TGQxbENRa0ZIUkhaNlFVSUtRa0ZSWVZWSVZtbGlSMng2WVVOQ2IxcFhlSGRhYmxaelNVaFNka2xJUWpGWmFUVnJXbGhaZDBoQldVdExkMWxDUWtGSFJIWjZRVUpDVVZGUFlsYzVlZ3BrVjFaMFRESm9iR0pJUW0xa1YzZDNTRkZaUzB0M1dVSkNRVWRFZG5wQlFrSm5VVkJqYlZadFkzazViMXBYUm10amVUbDBXVmRzZFUxRWMwZERhWE5IQ2tGUlVVSm5OemgzUVZGblJVeFJkM0poU0ZJd1kwaE5Oa3g1T1RCaU1uUnNZbWsxYUZrelVuQmlNalY2VEcxa2NHUkhhREZaYmxaNldsaEthbUl5TlRBS1dsYzFNRXh0VG5aaVZFSTVRbWR2Y2tKblJVVkJXVTh2VFVGRlNrSkhPRTFpVjJnd1pFaENlazlwT0haYU1td3dZVWhXYVV4dFRuWmlVemxyV1ZoS01BcE1WM2hvWW0xamRtTXlWakJrV0VGMFdrZEdlV1JET0hWYU1td3dZVWhXYVV3elpIWmpiWFJ0WWtjNU0yTjVPWGRrVjBwellWaE9iMHh1YkhSaVJVSjVDbHBYV25wTU1taHNXVmRTZWt3elFqRlpiWGh3WXpKbmRHTkhSbXBoTWtadVdsTXhhR1JJVW14ak0xSm9aRWRzZG1KdVRYZFBRVmxMUzNkWlFrSkJSMFFLZG5wQlFrTm5VWEZFUTJocldtcGtiVmxxWkdoT2JVMTNXV3BuTlUxNlJYZGFWR2Q2VFVSbk1sbHRVVEpOVjBrelRucHJNVTR5VW0xUFZGbDVUMWROTUFwTlFqQkhRMmx6UjBGUlVVSm5OemgzUVZGelJVUjNkMDVhTW13d1lVaFdhVXhYYUhaak0xSnNXa1JCZUVKbmIzSkNaMFZGUVZsUEwwMUJSVTFDUTAxTkNrbFhhREJrU0VKNlQyazRkbG95YkRCaFNGWnBURzFPZG1KVE9YUmlNMDR4V2xjd2RtRkhWbk5qUjFveFlrUkJORUpuYjNKQ1owVkZRVmxQTDAxQlJVNEtRa052VFV0RVdYcE5ha1V5V1ZSTmQwMXFTbXhhUkZsM1RtcFZlazlFVFRWUFZGbDZUa2RPYTAxWFRURk5hbFp0VDBkS2EwNUVXVEZPTWxsM1NIZFpTd3BMZDFsQ1FrRkhSSFo2UVVKRVoxRlNSRUU1ZVZwWFducE1NbWhzV1ZkU2Vrd3lNV2hoVnpSM1IyZFpTMHQzV1VKQ1FVZEVkbnBCUWtSM1VVMUVRVzk0Q2sxNlRUVk5la1Y2VFVSSk1VMURhMGREYVhOSFFWRlJRbWMzT0hkQlVrRkZSM2QzV21GSVVqQmpTRTAyVEhrNWJtRllVbTlrVjBsMVdUSTVkRXd5TVhZS1l6TldiR0pVUVZsQ1oyOXlRbWRGUlVGWlR5OU5RVVZTUWtGdlRVTkVTVE5QUkZVMVRVUm5lVTFIUVVkRGFYTkhRVkZSUW1jM09IZEJVa2xGVldkNFVRcGhTRkl3WTBoTk5reDVPVzVoV0ZKdlpGZEpkVmt5T1hSTU1qRjJZek5XYkdKVE9XOWFWM2gzV201V2MweDVOVzVoV0ZKdlpGZEpkbVF5T1hsaE1scHpDbUl6WkhwTU0wSXhXVzE0Y0dNeVozVmxWMFowWWtWQ2VWcFhXbnBNTW1oc1dWZFNla3d5TVdoaFZ6UjNUMEZaUzB0M1dVSkNRVWRFZG5wQlFrVjNVWEVLUkVObk1rMTZTWGhPYlVWNlRVUkplVnBYVVRKTlJGa3hUWHBuZWs5VWF6Sk5lbEpxV2tSR2FrNVVTVEZhYW1ocFdrUlJNazVVWkcxTlEwVkhRMmx6UndwQlVWRkNaemM0ZDBGU1VVVkZkM2RTWkRJNWVXRXlXbk5pTTJSbVdrZHNlbU5IUmpCWk1tZDNWbEZaUzB0M1dVSkNRVWRFZG5wQlFrWlJVa2hFUlZadkNtUklVbmRqZW05MlRESmtjR1JIYURGWmFUVnFZakl3ZG1KWE9YcGtWMVowVERKb2JHSklRbTFrVjNkMldWZE9NR0ZYT1hWamVUbDVaRmMxZWt4NlRYb0tUbFJGTUU5VVVYcE9WRlV4VERKR01HUkhWblJqU0ZKNlRIcEZkMFpuV1V0TGQxbENRa0ZIUkhaNlFVSkdaMUZKUkVGYWQyUlhTbk5oVjAxM1UzZFpTd3BMZDFsQ1FrRkhSSFo2UVVKSFFWRTVSRVIwZVZwWVFuWlBiVEYyWXpOV2JHSlZRWGxPZW1jeFQxUkJORTFwT1c5YVYzaDNXbTVXYzFGRVJYcE5lbXQ2Q2sxVVRYZE5hbFUyWTIxV2JVOXVTbXhhYmsxMllVZFdhRnBJVFhaaVYwWndZbXBEUW1sM1dVdExkMWxDUWtGSVYyVlJTVVZCWjFJNVFraHpRV1ZSUWpNS1FVNHdPVTFIY2tkNGVFVjVXWGhyWlVoS2JHNU9kMHRwVTJ3Mk5ETnFlWFF2TkdWTFkyOUJka3RsTms5QlFVRkNiMFl3TVVodk5FRkJRVkZFUVVWbmR3cFNaMGxvUVV0c2RISTFaazlNV0djNFZqVnplRU13YzJKS2IwUm1kbWRzVm13MU5sSnZkSEp1T0hWUVYySnRjVzFCYVVWQk5USkRVWFY1WWxCSWRsRmpDbFV3YUN0c05DdGFZbUVyWlVoak9XdFFTV1J0TnpCVk5WVnVkRmhWZW5kM1EyZFpTVXR2V2tsNmFqQkZRWGROUkdGQlFYZGFVVWwzVG5CWmRtdEdlV1FLYTBOeVJ6VTNjVXgwUWk5NVpVOWhSMVp6YzAxUGFsRnBURkY0VTBwNVNHZzViUzkwTDB4VVZXUnhlbWRaVDB0cWVVZENSV2RNYUcxQmFrVkJNbVJwWXdwbVZHaGtWakZUYWtkdVdEZGFPVFZWUzBreFNWQk5VV1pQVHpJek1rSjBjWEkzUVhFeWJGZHNUbWR3TW5oRFJXeHJXV0V2YjBKa1RXdGpSRUlLTFMwdExTMUZUa1FnUTBWU1ZFbEdTVU5CVkVVdExTMHRMUW89In1dfX0="}],"timestampVerificationData":{}},"dsseEnvelope":{"payload":"eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjEiLCJzdWJqZWN0IjpbeyJuYW1lIjoicGFja2FnZS50YXIuZ3oiLCJkaWdlc3QiOnsic2hhMjU2IjoiYzhlY2ZhNjExMDBlY2UxYTU1ZmNiNTI3MTZkYjc0ZjkyYjZkZWJlYjc1NzdhNGI4Y2E0NTQ4ZjQ0MGJmYTZiZiJ9fV0sInByZWRpY2F0ZVR5cGUiOiJodHRwczovL3Nsc2EuZGV2L3Byb3ZlbmFuY2UvdjEiLCJwcmVkaWNhdGUiOnsiYnVpbGREZWZpbml0aW9uIjp7ImJ1aWxkVHlwZSI6Imh0dHBzOi8vYWN0aW9ucy5naXRodWIuaW8vYnVpbGR0eXBlcy93b3JrZmxvdy92MSIsImV4dGVybmFsUGFyYW1ldGVycyI6eyJ3b3JrZmxvdyI6eyJyZWYiOiJyZWZzL2hlYWRzL21haW4iLCJyZXBvc2l0b3J5IjoiaHR0cHM6Ly9naXRodWIuY29tL21vc3VlbS9oZWxwZnVsIiwicGF0aCI6Ii5naXRodWIvd29ya2Zsb3dzL3B1Ymxpc2gueWFtbCJ9fSwiaW50ZXJuYWxQYXJhbWV0ZXJzIjp7ImdpdGh1YiI6eyJldmVudF9uYW1lIjoid29ya2Zsb3dfZGlzcGF0Y2giLCJyZXBvc2l0b3J5X2lkIjoiMTMzOTMxMzAyNSIsInJlcG9zaXRvcnlfb3duZXJfaWQiOiIyNzg1OTA4MiIsInJ1bm5lcl9lbnZpcm9ubWVudCI6ImdpdGh1Yi1ob3N0ZWQifX0sInJlc29sdmVkRGVwZW5kZW5jaWVzIjpbeyJ1cmkiOiJnaXQraHR0cHM6Ly9naXRodWIuY29tL21vc3VlbS9oZWxwZnVsQHJlZnMvaGVhZHMvbWFpbiIsImRpZ2VzdCI6eyJnaXRDb21taXQiOiI2MzIxNmEzMDIyZWQ2MDY1MzgzOTk2MzRjZDFjNTI1ZjhiZDQ2NTdmIn19XX0sInJ1bkRldGFpbHMiOnsiYnVpbGRlciI6eyJpZCI6Imh0dHBzOi8vZ2l0aHViLmNvbS9kYXJ0LWxhbmcvc2V0dXAtZGFydC8uZ2l0aHViL3dvcmtmbG93cy9wdWJsaXNoLnltbEByZWZzL2hlYWRzL3B1Ymxpc2gtcGFja2FnZS1hdHRlc3RhdGlvbnMifSwibWV0YWRhdGEiOnsiaW52b2NhdGlvbklkIjoiaHR0cHM6Ly9naXRodWIuY29tL21vc3VlbS9oZWxwZnVsL2FjdGlvbnMvcnVucy8zMzUxNDk0MzU1NS9hdHRlbXB0cy8xIn19fX0=","payloadType":"application/vnd.in-toto+json","signatures":[{"sig":"MEYCIQDh3yv51tDgT9QaGzhtUtAHAl1jPkgjIqvvVApPfYCZkwIhAM4Lei6od1NsTkvfNcmsyYrOp4k4a49cbXVaTO+HhO00"}]}}'''; + +const _sampleBundleJson = + r'''{"mediaType": "application/vnd.dev.sigstore.bundle+json;version=0.3", "verificationMaterial": {"certificate": {"rawBytes": "MIIIMTCCB7egAwIBAgIUaL/tsmQTHk21mt1Uuk+w7avDBz4wCgYIKoZIzj0EAwMwNzEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MR4wHAYDVQQDExVzaWdzdG9yZS1pbnRlcm1lZGlhdGUwHhcNMjQwMzE5MTcyNjI2WhcNMjQwMzE5MTczNjI2WjAAMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE22S1j/NkEXzBPQAuamHXLpwx+RPnnzZQl/pkEZ8xorvKnzujCS1mVTBo9kBxmYWo2DHtyVyfgnuOqVTzLYmho6OCBtYwggbSMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAzAdBgNVHQ4EFgQUFv1SCziEKN2rRyrjeVlFbSLg1/QwHwYDVR0jBBgwFoAU39Ppz1YkEZb5qNjpKFWixi4YZD8wgaUGA1UdEQEB/wSBmjCBl4aBlGh0dHBzOi8vZ2l0aHViLmNvbS9zaWdzdG9yZS1jb25mb3JtYW5jZS9leHRyZW1lbHktZGFuZ2Vyb3VzLXB1YmxpYy1vaWRjLWJlYWNvbi8uZ2l0aHViL3dvcmtmbG93cy9leHRyZW1lbHktZGFuZ2Vyb3VzLW9pZGMtYmVhY29uLnltbEByZWZzL2hlYWRzL21haW4wOQYKKwYBBAGDvzABAQQraHR0cHM6Ly90b2tlbi5hY3Rpb25zLmdpdGh1YnVzZXJjb250ZW50LmNvbTAfBgorBgEEAYO/MAECBBF3b3JrZmxvd19kaXNwYXRjaDA2BgorBgEEAYO/MAEDBChjN2IzZGZiMzM1ZjA1MWUxYzg2YmRhNGM3MTZmYWM5N2RmNjJhZDgxMC0GCisGAQQBg78wAQQEH0V4dHJlbWVseSBkYW5nZXJvdXMgT0lEQyBiZWFjb24wSQYKKwYBBAGDvzABBQQ7c2lnc3RvcmUtY29uZm9ybWFuY2UvZXh0cmVtZWx5LWRhbmdlcm91cy1wdWJsaWMtb2lkYy1iZWFjb24wHQYKKwYBBAGDvzABBgQPcmVmcy9oZWFkcy9tYWluMDsGCisGAQQBg78wAQgELQwraHR0cHM6Ly90b2tlbi5hY3Rpb25zLmdpdGh1YnVzZXJjb250ZW50LmNvbTCBpgYKKwYBBAGDvzABCQSBlwyBlGh0dHBzOi8vZ2l0aHViLmNvbS9zaWdzdG9yZS1jb25mb3JtYW5jZS9leHRyZW1lbHktZGFuZ2Vyb3VzLXB1YmxpYy1vaWRjLWJlYWNvbi8uZ2l0aHViL3dvcmtmbG93cy9leHRyZW1lbHktZGFuZ2Vyb3VzLW9pZGMtYmVhY29uLnltbEByZWZzL2hlYWRzL21haW4wOAYKKwYBBAGDvzABCgQqDChjN2IzZGZiMzM1ZjA1MWUxYzg2YmRhNGM3MTZmYWM5N2RmNjJhZDgxMB0GCisGAQQBg78wAQsEDwwNZ2l0aHViLWhvc3RlZDBeBgorBgEEAYO/MAEMBFAMTmh0dHBzOi8vZ2l0aHViLmNvbS9zaWdzdG9yZS1jb25mb3JtYW5jZS9leHRyZW1lbHktZGFuZ2Vyb3VzLXB1YmxpYy1vaWRjLWJlYWNvbjA4BgorBgEEAYO/MAENBCoMKGM3YjNkZmIzMzVmMDUxZTFjODZiZGE0YzcxNmZhYzk3ZGY2MmFkODEwHwYKKwYBBAGDvzABDgQRDA9yZWZzL2hlYWRzL21haW4wGQYKKwYBBAGDvzABDwQLDAk2MzI1OTY4OTcwNwYKKwYBBAGDvzABEAQpDCdodHRwczovL2dpdGh1Yi5jb20vc2lnc3RvcmUtY29uZm9ybWFuY2UwGQYKKwYBBAGDvzABEQQLDAkxMzE4MDQ1NjMwgaYGCisGAQQBg78wARIEgZcMgZRodHRwczovL2dpdGh1Yi5jb20vc2lnc3RvcmUtY29uZm9ybWFuY2UvZXh0cmVtZWx5LWRhbmdlcm91cy1wdWJsaWMtb2lkYy1iZWFjb24vLmdpdGh1Yi93b3JrZmxvd3MvZXh0cmVtZWx5LWRhbmdlcm91cy1vaWRjLWJlYWNvbi55bWxAcmVmcy9oZWFkcy9tYWluMDgGCisGAQQBg78wARMEKgwoYzdiM2RmYjMzNWYwNTFlMWM4NmJkYTRjNzE2ZmFjOTdkZjYyYWQ4MTAhBgorBgEEAYO/MAEUBBMMEXdvcmtmbG93X2Rpc3BhdGNoMIGBBgorBgEEAYO/MAEVBHMMcWh0dHBzOi8vZ2l0aHViLmNvbS9zaWdzdG9yZS1jb25mb3JtYW5jZS9leHRyZW1lbHktZGFuZ2Vyb3VzLXB1YmxpYy1vaWRjLWJlYWNvbi9hY3Rpb25zL3J1bnMvODM0NzQ4MTYyOC9hdHRlbXB0cy8xMBYGCisGAQQBg78wARYECAwGcHVibGljMIGKBgorBgEEAdZ5AgQCBHwEegB4AHYA3T0wasbHETJjGR4cmWc3AqJKXrjePK3/h4pygC8p7o4AAAGOV8AHpgAABAMARzBFAiBFeMbpFarlPwb0naTr4mjWDvXApOd9ORqOk36Brt9SmwIhAJJvjor+DXUXr7S3Vm9jVFT3CL0BxcKGj86m5mYzQvubMAoGCCqGSM49BAMDA2gAMGUCMA8lTixdS4iN9mAUduObcSJmhZLyvK7zaX05DLEDCgPWxDHk+JBZUKYRIuHHgwFnOwIxALMamo9dfENMzRgNCzYfp/y+rSOhVjXXE9mCn6BuJETlpRDfGvxUg/5LF9f4lYqozA=="}, "tlogEntries": [{"logIndex": "79571823", "logId": {"keyId": "wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0="}, "kindVersion": {"kind": "hashedrekord", "version": "0.0.1"}, "integratedTime": "1710869186", "inclusionPromise": {"signedEntryTimestamp": "MEYCIQDMNM49CNrcrpuvB9G3likdSse0miAkY0ILCqzRGP5ZJQIhAKnSS9GUSFVCar1+Sq3qoRtJIJ8x9tqRnQ8kuS1ojtTH"}, "inclusionProof": {"logIndex": "75408392", "rootHash": "Fnnj13Uu1jdksPc4HZLapKX329dVlD5+MGNsiqBq1XM=", "treeSize": "75408393", "hashes": ["1J7hRIEGvYdAyzEs+GhAE9L+38oHye3BhalgoQRZoo4=", "W/OUCkh/lqDDwbBkZgP7eTV/wx4WifD1wtfRLbavfxI=", "9wya2BEhfLGDfDRVN46OU2RXkozWCM1Z4qMu6SPiWoY=", "ZRs3lKAIlu0t0GtLupAcOu1y20nOaOshSKosWAqFO+w=", "BGqH+LzVuhuqCLiUvBJaB2hlsvtu2a15qq1WGw6mG44=", "OeS7D4kPES7ChE7kWSEmhbAMqBcKVj/z8/afMK4Y3pI=", "JtjqvAqFyXXYjWlZfDzElHpEzdBjsz1LmGFJuYx0kTU=", "s/ZIVcfcD4/nuZwUtQf4ydGsIAkGTPTzk3b0zhUC95k=", "YU1jZY/fp5tJdGF/i+/7ez8107O4/lOUp7acMPFEaOA=", "7Z18YLBAvejEV4nJHIKoks/xlijnhR005qTW2w4QtHg=", "98enzMaC+x5oCMvIZQA5z8vu2apDMCFvE/935NfuPw8="], "checkpoint": {"envelope": "rekor.sigstore.dev - 2605736670972794746\n75408393\nFnnj13Uu1jdksPc4HZLapKX329dVlD5+MGNsiqBq1XM=\n\n— rekor.sigstore.dev wNI9ajBFAiBTyiBM9WtyOTgohje6QZ5rFGJUdMq7Wk3A6oThE98SUgIhAMvxDwa7FyqRqg+YV3rdPPrfS23w19iK+piMSGVOmP5w\n"}}, "canonicalizedBody": "eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiaGFzaGVkcmVrb3JkIiwic3BlYyI6eyJkYXRhIjp7Imhhc2giOnsiYWxnb3JpdGhtIjoic2hhMjU2IiwidmFsdWUiOiJhMGNmYzcxMjcxZDZlMjc4ZTU3Y2QzMzJmZjk1N2MzZjcwNDNmZGRhMzU0YzRjYmIxOTBhMzBkNTZlZmEwMWJmIn19LCJzaWduYXR1cmUiOnsiY29udGVudCI6Ik1FVUNJQ1lGcS80YlRFZGx1cmdxVnVObXdDY0lXdTNOS09DZ3ZlV0FKQmllekowdUFpRUEyaTdVMTgrYVJwRnhMWWtzcjVIS0JRUXkwOHpFMDUwV0ljMFJ6S3VuRElBPSIsInB1YmxpY0tleSI6eyJjb250ZW50IjoiTFMwdExTMUNSVWRKVGlCRFJWSlVTVVpKUTBGVVJTMHRMUzB0Q2sxSlNVbE5WRU5EUWpkbFowRjNTVUpCWjBsVllVd3ZkSE50VVZSSWF6SXhiWFF4VlhWckszYzNZWFpFUW5vMGQwTm5XVWxMYjFwSmVtb3dSVUYzVFhjS1RucEZWazFDVFVkQk1WVkZRMmhOVFdNeWJHNWpNMUoyWTIxVmRWcEhWakpOVWpSM1NFRlpSRlpSVVVSRmVGWjZZVmRrZW1SSE9YbGFVekZ3WW01U2JBcGpiVEZzV2tkc2FHUkhWWGRJYUdOT1RXcFJkMDE2UlRWTlZHTjVUbXBKTWxkb1kwNU5hbEYzVFhwRk5VMVVZM3BPYWtreVYycEJRVTFHYTNkRmQxbElDa3R2V2tsNmFqQkRRVkZaU1V0dldrbDZhakJFUVZGalJGRm5RVVV5TWxNeGFpOU9hMFZZZWtKUVVVRjFZVzFJV0V4d2QzZ3JVbEJ1Ym5wYVVXd3ZjR3NLUlZvNGVHOXlka3R1ZW5WcVExTXhiVlpVUW04NWEwSjRiVmxYYnpKRVNIUjVWbmxtWjI1MVQzRldWSHBNV1cxb2J6WlBRMEowV1hkbloySlRUVUUwUndwQk1WVmtSSGRGUWk5M1VVVkJkMGxJWjBSQlZFSm5UbFpJVTFWRlJFUkJTMEpuWjNKQ1owVkdRbEZqUkVGNlFXUkNaMDVXU0ZFMFJVWm5VVlZHZGpGVENrTjZhVVZMVGpKeVVubHlhbVZXYkVaaVUweG5NUzlSZDBoM1dVUldVakJxUWtKbmQwWnZRVlV6T1ZCd2VqRlphMFZhWWpWeFRtcHdTMFpYYVhocE5Ga0tXa1E0ZDJkaFZVZEJNVlZrUlZGRlFpOTNVMEp0YWtOQ2JEUmhRbXhIYURCa1NFSjZUMms0ZGxveWJEQmhTRlpwVEcxT2RtSlRPWHBoVjJSNlpFYzVlUXBhVXpGcVlqSTFiV0l6U25SWlZ6VnFXbE01YkdWSVVubGFWekZzWWtocmRGcEhSblZhTWxaNVlqTldla3hZUWpGWmJYaHdXWGt4ZG1GWFVtcE1WMHBzQ2xsWFRuWmlhVGgxV2pKc01HRklWbWxNTTJSMlkyMTBiV0pIT1ROamVUbHNaVWhTZVZwWE1XeGlTR3QwV2tkR2RWb3lWbmxpTTFaNlRGYzVjRnBIVFhRS1dXMVdhRmt5T1hWTWJteDBZa1ZDZVZwWFducE1NbWhzV1ZkU2Vrd3lNV2hoVnpSM1QxRlpTMHQzV1VKQ1FVZEVkbnBCUWtGUlVYSmhTRkl3WTBoTk5ncE1lVGt3WWpKMGJHSnBOV2haTTFKd1lqSTFla3h0WkhCa1IyZ3hXVzVXZWxwWVNtcGlNalV3V2xjMU1FeHRUblppVkVGbVFtZHZja0puUlVWQldVOHZDazFCUlVOQ1FrWXpZak5LY2xwdGVIWmtNVGxyWVZoT2QxbFlVbXBoUkVFeVFtZHZja0puUlVWQldVOHZUVUZGUkVKRGFHcE9Na2w2V2tkYWFVMTZUVEVLV21wQk1VMVhWWGhaZW1jeVdXMVNhRTVIVFROTlZGcHRXVmROTlU0eVVtMU9ha3BvV2tSbmVFMURNRWREYVhOSFFWRlJRbWMzT0hkQlVWRkZTREJXTkFwa1NFcHNZbGRXYzJWVFFtdFpWelZ1V2xoS2RtUllUV2RVTUd4RlVYbENhVnBYUm1waU1qUjNVMUZaUzB0M1dVSkNRVWRFZG5wQlFrSlJVVGRqTW14dUNtTXpVblpqYlZWMFdUSTVkVnB0T1hsaVYwWjFXVEpWZGxwWWFEQmpiVlowV2xkNE5VeFhVbWhpYldSc1kyMDVNV041TVhka1YwcHpZVmROZEdJeWJHc0tXWGt4YVZwWFJtcGlNalIzU0ZGWlMwdDNXVUpDUVVkRWRucEJRa0puVVZCamJWWnRZM2s1YjFwWFJtdGplVGwwV1Zkc2RVMUVjMGREYVhOSFFWRlJRZ3BuTnpoM1FWRm5SVXhSZDNKaFNGSXdZMGhOTmt4NU9UQmlNblJzWW1rMWFGa3pVbkJpTWpWNlRHMWtjR1JIYURGWmJsWjZXbGhLYW1JeU5UQmFWelV3Q2t4dFRuWmlWRU5DY0dkWlMwdDNXVUpDUVVkRWRucEJRa05SVTBKc2QzbENiRWRvTUdSSVFucFBhVGgyV2pKc01HRklWbWxNYlU1MllsTTVlbUZYWkhvS1pFYzVlVnBUTVdwaU1qVnRZak5LZEZsWE5XcGFVemxzWlVoU2VWcFhNV3hpU0d0MFdrZEdkVm95Vm5saU0xWjZURmhDTVZsdGVIQlplVEYyWVZkU2FncE1WMHBzV1ZkT2RtSnBPSFZhTW13d1lVaFdhVXd6WkhaamJYUnRZa2M1TTJONU9XeGxTRko1V2xjeGJHSklhM1JhUjBaMVdqSldlV0l6Vm5wTVZ6bHdDbHBIVFhSWmJWWm9XVEk1ZFV4dWJIUmlSVUo1V2xkYWVrd3lhR3haVjFKNlRESXhhR0ZYTkhkUFFWbExTM2RaUWtKQlIwUjJla0ZDUTJkUmNVUkRhR29LVGpKSmVscEhXbWxOZWsweFdtcEJNVTFYVlhoWmVtY3lXVzFTYUU1SFRUTk5WRnB0V1ZkTk5VNHlVbTFPYWtwb1drUm5lRTFDTUVkRGFYTkhRVkZSUWdwbk56aDNRVkZ6UlVSM2QwNWFNbXd3WVVoV2FVeFhhSFpqTTFKc1drUkNaVUpuYjNKQ1owVkZRVmxQTDAxQlJVMUNSa0ZOVkcxb01HUklRbnBQYVRoMkNsb3liREJoU0ZacFRHMU9kbUpUT1hwaFYyUjZaRWM1ZVZwVE1XcGlNalZ0WWpOS2RGbFhOV3BhVXpsc1pVaFNlVnBYTVd4aVNHdDBXa2RHZFZveVZua0tZak5XZWt4WVFqRlpiWGh3V1hreGRtRlhVbXBNVjBwc1dWZE9kbUpxUVRSQ1oyOXlRbWRGUlVGWlR5OU5RVVZPUWtOdlRVdEhUVE5aYWs1cldtMUplZ3BOZWxadFRVUlZlRnBVUm1wUFJGcHBXa2RGTUZsNlkzaE9iVnBvV1hwck0xcEhXVEpOYlVaclQwUkZkMGgzV1V0TGQxbENRa0ZIUkhaNlFVSkVaMUZTQ2tSQk9YbGFWMXA2VERKb2JGbFhVbnBNTWpGb1lWYzBkMGRSV1V0TGQxbENRa0ZIUkhaNlFVSkVkMUZNUkVGck1rMTZTVEZQVkZrMFQxUmpkMDUzV1VzS1MzZFpRa0pCUjBSMmVrRkNSVUZSY0VSRFpHOWtTRkozWTNwdmRrd3laSEJrUjJneFdXazFhbUl5TUhaak1teHVZek5TZG1OdFZYUlpNamwxV20wNWVRcGlWMFoxV1RKVmQwZFJXVXRMZDFsQ1FrRkhSSFo2UVVKRlVWRk1SRUZyZUUxNlJUUk5SRkV4VG1wTmQyZGhXVWREYVhOSFFWRlJRbWMzT0hkQlVrbEZDbWRhWTAxbldsSnZaRWhTZDJONmIzWk1NbVJ3WkVkb01WbHBOV3BpTWpCMll6SnNibU16VW5aamJWVjBXVEk1ZFZwdE9YbGlWMFoxV1RKVmRscFlhREFLWTIxV2RGcFhlRFZNVjFKb1ltMWtiR050T1RGamVURjNaRmRLYzJGWFRYUmlNbXhyV1hreGFWcFhSbXBpTWpSMlRHMWtjR1JIYURGWmFUa3pZak5LY2dwYWJYaDJaRE5OZGxwWWFEQmpiVlowV2xkNE5VeFhVbWhpYldSc1kyMDVNV041TVhaaFYxSnFURmRLYkZsWFRuWmlhVFUxWWxkNFFXTnRWbTFqZVRsdkNscFhSbXRqZVRsMFdWZHNkVTFFWjBkRGFYTkhRVkZSUW1jM09IZEJVazFGUzJkM2IxbDZaR2xOTWxKdFdXcE5lazVYV1hkT1ZFWnNUVmROTkU1dFNtc0tXVlJTYWs1NlJUSmFiVVpxVDFSa2ExcHFXWGxaVjFFMFRWUkJhRUpuYjNKQ1owVkZRVmxQTDAxQlJWVkNRazFOUlZoa2RtTnRkRzFpUnpreldESlNjQXBqTTBKb1pFZE9iMDFKUjBKQ1oyOXlRbWRGUlVGWlR5OU5RVVZXUWtoTlRXTlhhREJrU0VKNlQyazRkbG95YkRCaFNGWnBURzFPZG1KVE9YcGhWMlI2Q21SSE9YbGFVekZxWWpJMWJXSXpTblJaVnpWcVdsTTViR1ZJVW5sYVZ6RnNZa2hyZEZwSFJuVmFNbFo1WWpOV2VreFlRakZaYlhod1dYa3hkbUZYVW1vS1RGZEtiRmxYVG5aaWFUbG9XVE5TY0dJeU5YcE1NMG94WW01TmRrOUVUVEJPZWxFMFRWUlplVTlET1doa1NGSnNZbGhDTUdONU9IaE5RbGxIUTJselJ3cEJVVkZDWnpjNGQwRlNXVVZEUVhkSFkwaFdhV0pIYkdwTlNVZExRbWR2Y2tKblJVVkJaRm8xUVdkUlEwSklkMFZsWjBJMFFVaFpRVE5VTUhkaGMySklDa1ZVU21wSFVqUmpiVmRqTTBGeFNrdFljbXBsVUVzekwyZzBjSGxuUXpod04yODBRVUZCUjA5V09FRkljR2RCUVVKQlRVRlNla0pHUVdsQ1JtVk5ZbkFLUm1GeWJGQjNZakJ1WVZSeU5HMXFWMFIyV0VGd1QyUTVUMUp4VDJzek5rSnlkRGxUYlhkSmFFRktTblpxYjNJclJGaFZXSEkzVXpOV2JUbHFWa1pVTXdwRFREQkNlR05MUjJvNE5tMDFiVmw2VVhaMVlrMUJiMGREUTNGSFUwMDBPVUpCVFVSQk1tZEJUVWRWUTAxQk9HeFVhWGhrVXpScFRqbHRRVlZrZFU5aUNtTlRTbTFvV2t4NWRrczNlbUZZTURWRVRFVkVRMmRRVjNoRVNHc3JTa0phVlV0WlVrbDFTRWhuZDBadVQzZEplRUZNVFdGdGJ6bGtaa1ZPVFhwU1owNEtRM3BaWm5BdmVTdHlVMDlvVm1wWVdFVTViVU51TmtKMVNrVlViSEJTUkdaSGRuaFZaeTgxVEVZNVpqUnNXWEZ2ZWtFOVBRb3RMUzB0TFVWT1JDQkRSVkpVU1VaSlEwRlVSUzB0TFMwdENnPT0ifX19fQ=="}]}, "messageSignature": {"messageDigest": {"algorithm": "SHA2_256", "digest": "oM/HEnHW4njlfNMy/5V8P3BD/do1TEy7GQow1W76Ab8="}, "signature": "MEUCICYFq/4bTEdlurgqVuNmwCcIWu3NKOCgveWAJBiezJ0uAiEA2i7U18+aRpFxLYksr5HKBQQy08zE050WIc0RzKunDIA="}}'''; diff --git a/app/test/service/entrypoint/dockerfile_app_build_test.dart b/app/test/service/entrypoint/dockerfile_app_build_test.dart index edf9cff7d4..c04a83cb2b 100644 --- a/app/test/service/entrypoint/dockerfile_app_build_test.dart +++ b/app/test/service/entrypoint/dockerfile_app_build_test.dart @@ -9,7 +9,13 @@ import 'package:_pub_shared/worker/docker_utils.dart'; import 'package:test/test.dart'; void main() { - test('building Dockerfile.app', () async { + test('building and running Dockerfile.app', () async { await buildAppDockerImage(); - }, timeout: Timeout(Duration(minutes: 5))); + + // The image is only usable when the server can load the native libraries + // of `package:sigstore` and `package:boring` from the snapshot it runs, + // see `tool/build-server-snapshot.sh`. + final output = await runAppDockerImage(['self-check']); + expect(output, contains('self-check: OK')); + }, timeout: Timeout(Duration(minutes: 20))); } diff --git a/pkg/_pub_shared/lib/worker/docker_utils.dart b/pkg/_pub_shared/lib/worker/docker_utils.dart index c1e6464739..bddecef04e 100644 --- a/pkg/_pub_shared/lib/worker/docker_utils.dart +++ b/pkg/_pub_shared/lib/worker/docker_utils.dart @@ -18,6 +18,21 @@ Future buildWorkerDockerImage() async { await _buildDockerImage(path: 'Dockerfile.worker', image: 'pub_worker'); } +/// Runs the `pub_app` image built by [buildAppDockerImage] with [args] passed +/// to its entrypoint (the server), and returns the output of the process. +/// +/// Throws if the process exits with a non-zero exit code. +Future runAppDockerImage(List args) async { + final pr = await Process.run('docker', ['run', '--rm', 'pub_app', ...args]); + if (pr.exitCode != 0) { + throw Exception( + 'docker run exited with code ${pr.exitCode}\n' + 'stdout:\n${pr.stdout}\nstderr:\n${pr.stderr}', + ); + } + return pr.stdout.toString(); +} + Future _buildDockerImage({ required String path, required String image, diff --git a/pubspec.lock b/pubspec.lock index 38e08e761e..ad6a6630f8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -73,6 +73,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + boring: + dependency: transitive + description: + name: boring + sha256: "82d3b79a85dd84ed4b9d935f71fa51884b2e72387eb4c181cdab822f4d723e0f" + url: "https://pub.dev" + source: hosted + version: "0.4.2" buffer: dependency: transitive description: @@ -137,6 +145,14 @@ packages: url: "https://pub.dev" source: hosted version: "8.13.0" + change_case: + dependency: transitive + description: + name: change_case + sha256: e41ef3df58521194ef8d7649928954805aeb08061917cf658322305e61568003 + url: "https://pub.dev" + source: hosted + version: "2.2.0" characters: dependency: transitive description: @@ -209,6 +225,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.3" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "828110d598123b5ea96c00c9f3c72105bf79f8ee36c20a39b26209ade421ec57" + url: "https://pub.dev" + source: hosted + version: "2.1.0" code_builder: dependency: transitive description: @@ -377,6 +401,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.0" + hooks: + dependency: transitive + description: + name: hooks + sha256: eaac480a35ec0814146c2c48d96aaa829e0e44a7662c88ae84c9edf4bc35651f + url: "https://pub.dev" + source: hosted + version: "2.2.0" html: dependency: transitive description: @@ -561,6 +593,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.1" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "90eaad8059ebc2004a63810a4696ea9acdb02473e47ed3b6b3bafb100ac77e31" + url: "https://pub.dev" + source: hosted + version: "0.19.5" + native_toolchain_cmake: + dependency: transitive + description: + name: native_toolchain_cmake + sha256: "8ba223410102665483e873b83d2156720233a6a0181c07f9dc41cb2e961336b0" + url: "https://pub.dev" + source: hosted + version: "0.3.2" neat_cache: dependency: transitive description: @@ -641,6 +689,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.1.0" + platform: + dependency: transitive + description: + name: platform + sha256: a36d119c13416516a7b5913fbe8af8531e11633d784c550b2125f76c758524ec + url: "https://pub.dev" + source: hosted + version: "3.2.0" pool: dependency: transitive description: @@ -665,6 +721,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.5.20" + process: + dependency: transitive + description: + name: process + sha256: "4242ba3508d37e01808bdf71ad1d5bb93a8d671bf2e7450e6b1b353fb0808891" + url: "https://pub.dev" + source: hosted + version: "5.0.6" protobuf: dependency: transitive description: @@ -697,6 +761,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.26.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "1cb8564af8d43b464294411db9217f5ec04891c6f22ee2c32d73ae05e88a6bd2" + url: "https://pub.dev" + source: hosted + version: "1.1.1" retry: dependency: transitive description: @@ -769,6 +841,15 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + sigstore: + dependency: transitive + description: + path: "pkgs/sigstore" + ref: "233e5ab23f23ec1ff52f79a39efb77db74d5c73d" + resolved-ref: "233e5ab23f23ec1ff52f79a39efb77db74d5c73d" + url: "https://github.com/dart-lang/tools.git" + source: git + version: "0.2.5-wip" slugid: dependency: transitive description: @@ -1010,4 +1091,4 @@ packages: source: hosted version: "2.2.4" sdks: - dart: ">=3.13.0 <4.0.0" + dart: ">=3.13.3 <4.0.0" diff --git a/tool/build-server-snapshot.sh b/tool/build-server-snapshot.sh new file mode 100755 index 0000000000..73392f550d --- /dev/null +++ b/tool/build-server-snapshot.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash + +# Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file +# for details. All rights reserved. Use of this source code is governed by a +# BSD-style license that can be found in the LICENSE file. + +# Builds the app-JIT snapshot of the server, with the native assets mapping of +# `package:sigstore` and `package:boring` embedded. +# +# `package:sigstore` calls into a native library (`libsigstore_ffi.so`) through +# `@Native` bindings, and routes its cryptography through the BoringSSL of +# `package:boring` (`libbssl_dart.so`), another native library. The Dart VM +# resolves `@Native` bindings through a native assets mapping (asset id -> +# library path). `dart run` produces that mapping by executing the build hooks +# of the dependencies and embeds it in the kernel it runs; `dart compile +# jit-snapshot` does not, so a snapshot created by it has no way to find the +# libraries at runtime. +# +# This script does what `dart run` does, with a mapping that points at the +# location of the library in the production image: +# +# 1. compile `bin/server.dart` to kernel, +# 2. compile the mapping to a kernel component with the frontend server, +# 3. concatenate the two, and +# 4. create the app-JIT snapshot from the result (the VM keeps the mapping in +# the snapshot, no build hooks or sources are needed at runtime). +# +# The libraries themselves are built from the sources bundled with the +# packages, see `Dockerfile.app`. +# +# Usage: build-server-snapshot.sh \ +# \ +# + +set -euo pipefail + +if [ "$#" -ne 3 ]; then + echo "Usage: $0 " >&2 + exit 1 +fi +OUTPUT="$1" +SIGSTORE_LIBRARY="$2" +BORING_LIBRARY="$3" + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &> /dev/null && pwd)" +APP_DIR="$SCRIPT_DIR/../app" + +DART_SDK="$(cd -- "$(dirname -- "$(readlink -f "$(command -v dart)")")/.." && pwd)" +DART_AOT_RUNTIME="$DART_SDK/bin/dartaotruntime" +FRONTEND_SERVER="$DART_SDK/bin/snapshots/frontend_server_aot.dart.snapshot" +if [ ! -x "$DART_AOT_RUNTIME" ] || [ ! -f "$FRONTEND_SERVER" ]; then + echo "Could not find the frontend server in the Dart SDK at $DART_SDK" >&2 + exit 1 +fi + +case "$(uname -m)" in + x86_64) DART_ARCH="x64" ;; + aarch64 | arm64) DART_ARCH="arm64" ;; + *) + echo "Unsupported architecture: $(uname -m)" >&2 + exit 1 + ;; +esac + +WORK_DIR="$(mktemp -d)" +trap 'rm -rf "$WORK_DIR"' EXIT + +cd "$APP_DIR" + +# 1. The server as kernel. +dart compile kernel -o "$WORK_DIR/server.dill" bin/server.dart + +# 2. The native assets mapping as kernel component. The asset ids are the ones +# the build hooks of the packages register: for `package:sigstore` the +# library URI of its `@Native` bindings, for `package:boring` the +# `@DefaultAsset` of its bindings. +cat > "$WORK_DIR/native_assets.yaml" < "$WORK_DIR/server_with_native_assets.dill" + +# 4. The app-JIT snapshot. The training run is started with `--help` to make it +# exit right away. +dart --snapshot-kind=app-jit --snapshot="$OUTPUT" \ + "$WORK_DIR/server_with_native_assets.dill" --help + +echo "Created $OUTPUT with native assets mapping:" +cat "$WORK_DIR/native_assets.yaml"