From d16f4d23724407d84405738aedc4c62bdba24f33 Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 20 Aug 2026 09:02:13 +0000 Subject: [PATCH] Add integration test for package attestation upload and retrieval in pub_integration Uploads a package with an attestation bundle through the fake server and retrieves it via the attestation API, and checks that an attestation for a different archive is rejected. The test client uploads the bundle to the `attestationUrl` returned by `GET /api/packages/versions/new` before the archive (the protocol since `accept-and-serve-attestations`), and the self-reported builder of the hand-crafted provenance is one of the workflows pub.dev trusts, as the fake server's `FakeAttestationVerifier` trusts it instead of verifying a signature. --- .../lib/src/pub_http_client.dart | 81 ++++++ .../lib/src/pub_tool_client.dart | 17 ++ pkg/pub_integration/pubspec.yaml | 4 +- .../test/attestation_integration_test.dart | 245 ++++++++++++++++++ 4 files changed, 346 insertions(+), 1 deletion(-) create mode 100644 pkg/pub_integration/test/attestation_integration_test.dart diff --git a/pkg/pub_integration/lib/src/pub_http_client.dart b/pkg/pub_integration/lib/src/pub_http_client.dart index a3015ecc49..1ad0883c81 100644 --- a/pkg/pub_integration/lib/src/pub_http_client.dart +++ b/pkg/pub_integration/lib/src/pub_http_client.dart @@ -78,6 +78,23 @@ class PubHttpClient { } } + /// Get the Sigstore attestation bundle for a specific package version, + /// or null if it does not exist. + Future getAttestation(String package, String version) async { + final rs = await _http.get( + _pubHostedUrl.resolve( + '/api/packages/$package/versions/$version/attestation', + ), + ); + if (rs.statusCode == 404) { + return null; + } else if (rs.statusCode == 200) { + return rs.body; + } else { + throw Exception('Unexpected result: ${rs.statusCode} ${rs.reasonPhrase}'); + } + } + /// Get the content of the latest version page of a package or null if it does /// not exists. Future getLatestVersionPage(String package, {String? tab}) async { @@ -295,6 +312,70 @@ class PubHttpClient { } } + /// Uploads a package tarball and optional attestation bundle to the server. + Future uploadPackage({ + required String authToken, + required List packageBytes, + List? attestationBytes, + }) async { + final initRs = await _http.get( + _pubHostedUrl.resolve('/api/packages/versions/new'), + headers: {'authorization': 'Bearer $authToken'}, + ); + if (initRs.statusCode != 200) { + return initRs; + } + final initMap = jsonDecode(initRs.body) as Map; + final uploadUrl = Uri.parse(initMap['url'] as String); + final fields = (initMap['fields'] as Map) + .cast(); + + if (attestationBytes != null) { + // The attestation is uploaded to the server before the archive, see + // `PackageBackend.uploadAttestation`. Servers that do not support + // attestations do not return an `attestationUrl`. + final attestationUrl = initMap['attestationUrl'] as String?; + if (attestationUrl == null) { + throw Exception('The server does not accept attestations.'); + } + final attestationRs = await _http.post( + Uri.parse(attestationUrl), + headers: { + 'authorization': 'Bearer $authToken', + 'content-type': 'application/json; charset="utf-8"', + }, + body: attestationBytes, + ); + if (attestationRs.statusCode != 200) { + return attestationRs; + } + } + + // Upload archive multipart + final uploadReq = MultipartRequest('POST', uploadUrl) + ..fields.addAll(fields) + ..files.add( + MultipartFile.fromBytes( + 'file', + packageBytes, + filename: 'package.tar.gz', + ), + ) + ..followRedirects = false; + final uploadStream = await _http.send(uploadReq); + final uploadRs = await Response.fromStream(uploadStream); + + final location = uploadRs.headers['location']; + if (location == null) { + return uploadRs; + } + final finishUri = Uri.parse(location); + return await _http.get( + finishUri, + headers: {'authorization': 'Bearer $authToken'}, + ); + } + /// Free resources. Future close() async { _http.close(); diff --git a/pkg/pub_integration/lib/src/pub_tool_client.dart b/pkg/pub_integration/lib/src/pub_tool_client.dart index 6aa5f61398..7e374b0260 100644 --- a/pkg/pub_integration/lib/src/pub_tool_client.dart +++ b/pkg/pub_integration/lib/src/pub_tool_client.dart @@ -176,4 +176,21 @@ class DartToolClient { expectedOutputContains: expectedOutputContains, ); } + + Future publishArchive({ + required String archivePath, + String? attestationPath, + String? expectedErrorContains, + String? expectedOutputContains, + }) async { + final args = ['pub', 'publish', '--force', '--from-archive', archivePath]; + if (attestationPath != null) { + args.addAll(['--with-attestation', attestationPath]); + } + await runDart( + args, + expectedErrorContains: expectedErrorContains, + expectedOutputContains: expectedOutputContains, + ); + } } diff --git a/pkg/pub_integration/pubspec.yaml b/pkg/pub_integration/pubspec.yaml index 4238590958..2aa5468168 100644 --- a/pkg/pub_integration/pubspec.yaml +++ b/pkg/pub_integration/pubspec.yaml @@ -9,13 +9,15 @@ resolution: workspace dependencies: _pub_shared: args: ^2.0.0 + crypto: ^3.0.0 http: ^1.0.0 + oauth2: ^2.0.0 path: ^1.8.0 pub_semver: ^2.0.0 puppeteer: 3.26.0 puppeteer_screenshots: retry: ^3.1.2 - oauth2: ^2.0.0 + tar: ^2.0.0 dev_dependencies: coverage: any # test already depends on it diff --git a/pkg/pub_integration/test/attestation_integration_test.dart b/pkg/pub_integration/test/attestation_integration_test.dart new file mode 100644 index 0000000000..7ec32b96a2 --- /dev/null +++ b/pkg/pub_integration/test/attestation_integration_test.dart @@ -0,0 +1,245 @@ +// 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 'dart:convert'; +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:pub_integration/src/fake_test_context_provider.dart'; +import 'package:pub_integration/src/pub_http_client.dart'; +import 'package:tar/tar.dart'; +import 'package:test/test.dart'; + +Future> _createArchiveBytes({required String pubspecContent}) async { + final entries = [ + TarEntry.data( + TarHeader(name: 'pubspec.yaml', mode: 420), + utf8.encode(pubspecContent), + ), + TarEntry.data( + TarHeader(name: 'LICENSE', mode: 420), + utf8.encode('BSD-3-Clause'), + ), + TarEntry.data( + TarHeader(name: 'README.md', mode: 420), + utf8.encode('# Attested Package\n'), + ), + TarEntry.data( + TarHeader(name: 'lib/attested.dart', mode: 420), + utf8.encode('void hello() => print("hello");\n'), + ), + ]; + + final stream = Stream.fromIterable( + entries, + ).cast().transform(tarWriter).transform(gzip.encoder); + return await stream.expand((chunk) => chunk).toList(); +} + +Map _createAttestationBundle({ + required String packageName, + required String version, + required String repository, + required String archiveSha256, +}) { + final statement = { + '_type': 'https://in-toto.io/Statement/v1', + 'subject': [ + { + 'name': '$packageName-$version.tar.gz', + 'digest': {'sha256': archiveSha256}, + }, + ], + 'predicateType': 'https://slsa.dev/provenance/v1', + 'predicate': { + 'buildDefinition': { + 'buildType': 'https://actions.github.io/buildtypes/workflow/v1', + 'externalParameters': { + 'workflow': { + 'ref': 'refs/tags/v$version', + 'repository': repository, + 'path': '.github/workflows/publish.yaml', + }, + }, + 'resolvedDependencies': [ + { + 'uri': 'git+$repository@refs/tags/v$version', + 'digest': {'gitCommit': '7891abbe3dab159e9d0187fc1042d5e0cd82cfad'}, + }, + ], + }, + 'runDetails': { + // The fake server does not verify signatures and trusts the builder + // stated here, which must be one of the workflows pub.dev trusts to + // publish packages (`defaultTrustedSignerWorkflows`). + 'builder': { + 'id': + 'https://github.com/dart-lang/setup-dart/.github/workflows/publish.yml@refs/tags/v2', + }, + }, + }, + }; + + final derBytes = [ + 0x30, + 0x82, + 0x01, + 0x00, + ...utf8.encode(repository), + ...utf8.encode('https://token.actions.githubusercontent.com'), + ]; + + return { + 'mediaType': 'application/vnd.dev.sigstore.bundle.v0.3+json', + 'verificationMaterial': { + 'certificate': {'rawBytes': base64Encode(derBytes)}, + 'tlogEntries': [ + { + 'logIndex': '123456', + 'inclusionProof': { + 'rootHash': 'test-root-hash', + 'hashes': ['hash1', 'hash2'], + }, + }, + ], + }, + 'dsseEnvelope': { + 'payloadType': 'application/vnd.in-toto+json', + 'payload': base64Encode(utf8.encode(jsonEncode(statement))), + 'signatures': [ + {'sig': base64Encode(utf8.encode('test-signature'))}, + ], + }, + }; +} + +void main() { + group('Package attestation integration', () { + late final TestContextProvider fakeTestScenario; + late final PubHttpClient pubHttpClient; + late final String adminAuthToken; + + setUpAll(() async { + fakeTestScenario = await TestContextProvider.start(); + pubHttpClient = PubHttpClient(fakeTestScenario.pubHostedUrl); + final adminUser = await fakeTestScenario.createTestUser( + email: 'admin@pub.dev', + ); + final creds = await adminUser.createCredentials(); + adminAuthToken = + (creds['accessToken'] ?? + creds['token'] ?? + creds['idToken'] ?? + 'fake-admin-token') + as String; + }); + + tearDownAll(() async { + await pubHttpClient.close(); + await fakeTestScenario.close(); + }); + + test( + 'upload valid package with attestation and retrieve via API', + () async { + const pkgName = 'attested_integration_pkg'; + const version = '1.0.0'; + const repo = 'https://github.com/dart-lang/attested_integration_pkg'; + + final pubspecContent = + ''' +name: $pkgName +version: $version +description: Integration tested package with Sigstore attestation. +repository: $repo +environment: + sdk: '>=3.0.0 <4.0.0' +'''; + + final archiveBytes = await _createArchiveBytes( + pubspecContent: pubspecContent, + ); + final archiveSha = sha256.convert(archiveBytes).toString(); + + final bundleJson = _createAttestationBundle( + packageName: pkgName, + version: version, + repository: repo, + archiveSha256: archiveSha, + ); + final attestationBytes = utf8.encode(jsonEncode(bundleJson)); + + final uploadRs = await pubHttpClient.uploadPackage( + authToken: adminAuthToken, + packageBytes: archiveBytes, + attestationBytes: attestationBytes, + ); + expect(uploadRs.statusCode, 200); + expect(uploadRs.body, contains('Successfully uploaded')); + + // Retrieve via GET /api/packages//versions//attestation + final retrievedContent = await pubHttpClient.getAttestation( + pkgName, + version, + ); + expect(retrievedContent, isNotNull); + final retrievedJson = + jsonDecode(retrievedContent!) as Map; + expect( + retrievedJson['mediaType'], + equals('application/vnd.dev.sigstore.bundle.v0.3+json'), + ); + expect(retrievedJson['dsseEnvelope'], isNotNull); + + // Non-existent version returns null (404) + final notFound = await pubHttpClient.getAttestation(pkgName, '2.0.0'); + expect(notFound, isNull); + }, + ); + + test( + 'upload fails when attestation digest does not match archive', + () async { + const pkgName = 'tampered_integration_pkg'; + const version = '1.0.0'; + const repo = 'https://github.com/dart-lang/tampered_integration_pkg'; + + final pubspecContent = + ''' +name: $pkgName +version: $version +description: Package with tampered attestation. +repository: $repo +environment: + sdk: '>=3.0.0 <4.0.0' +'''; + + final archiveBytes = await _createArchiveBytes( + pubspecContent: pubspecContent, + ); + + // Intentionally wrong sha + const fakeSha = + '0000000000000000000000000000000000000000000000000000000000000000'; + + final bundleJson = _createAttestationBundle( + packageName: pkgName, + version: version, + repository: repo, + archiveSha256: fakeSha, + ); + final attestationBytes = utf8.encode(jsonEncode(bundleJson)); + + final uploadRs = await pubHttpClient.uploadPackage( + authToken: adminAuthToken, + packageBytes: archiveBytes, + attestationBytes: attestationBytes, + ); + + expect(uploadRs.statusCode, 400); + expect(uploadRs.body, contains('Invalid package attestation')); + }, + ); + }, timeout: Timeout.factor(testTimeoutFactor)); +}