From 8cae40272c9220b73d74603e331767be8c94e698 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 19 Aug 2026 14:28:59 +0000 Subject: [PATCH 1/9] Accept and expose package attestation bundles on pub.dev - Adds `AssetKind.attestation` for storing attestation bundles as `PackageVersionAsset` entities in Datastore. - In `packageBackend.publishUploadedBlob`, checks for accompanying `tmp/.sigstore.json` in the incoming bucket, reads it, and saves it as an attestation asset. - Exposes `GET /api/packages//versions//attestation` endpoint for client retrieval. - Updates `PubApiClientExt` and adds unit tests in `upload_test.dart`. --- app/lib/frontend/handlers/pubapi.client.dart | 10 +++++ app/lib/frontend/handlers/pubapi.dart | 26 ++++++++++++ app/lib/frontend/handlers/pubapi.g.dart | 18 ++++++++ app/lib/package/backend.dart | 34 +++++++++++++++ app/lib/package/models.dart | 1 + app/lib/tool/utils/pub_api_client.dart | 33 +++++++++++++-- app/test/package/upload_test.dart | 44 ++++++++++++++++++++ 7 files changed, 162 insertions(+), 4 deletions(-) diff --git a/app/lib/frontend/handlers/pubapi.client.dart b/app/lib/frontend/handlers/pubapi.client.dart index 951c4edd9b..96e4ef6923 100644 --- a/app/lib/frontend/handlers/pubapi.client.dart +++ b/app/lib/frontend/handlers/pubapi.client.dart @@ -59,6 +59,16 @@ class PubApiClient { ); } + Future> getPackageVersionAttestation( + String package, + String version, + ) async { + return await _client.requestBytes( + verb: 'get', + path: '/api/packages/$package/versions/$version/attestation', + ); + } + Future> fetchPackage(String package, String version) async { return await _client.requestBytes( verb: 'get', diff --git a/app/lib/frontend/handlers/pubapi.dart b/app/lib/frontend/handlers/pubapi.dart index d67e757549..b0e095d081 100644 --- a/app/lib/frontend/handlers/pubapi.dart +++ b/app/lib/frontend/handlers/pubapi.dart @@ -17,6 +17,7 @@ import 'package:shelf_router/shelf_router.dart'; import '../../account/consent_backend.dart'; import '../../admin/backend.dart'; import '../../package/backend.dart' hide InviteStatus; +import '../../package/models.dart' show AssetKind; import '../../publisher/backend.dart'; import '../../shared/exceptions.dart'; import '../../shared/handlers.dart'; @@ -80,6 +81,31 @@ class PubApi { ); } + /// Fetches the Sigstore attestation bundle for a specific (package, version) pair. + @EndPoint.get('/api/packages//versions//attestation') + Future getPackageVersionAttestation( + Request request, + String package, + String version, + ) async { + checkPackageVersionParams(package, version); + final asset = await packageBackend.lookupPackageVersionAsset( + package, + version, + AssetKind.attestation, + ); + if (asset == null || asset.textContent == null) { + throw NotFoundException.resource('attestation for $package $version'); + } + return Response.ok( + asset.textContent, + headers: { + 'content-type': 'application/json; charset="utf-8"', + ...CacheControl.clientApi.headers, + }, + ); + } + /// Downloading package. /// /// This is the endpoint we link to from the version listing. diff --git a/app/lib/frontend/handlers/pubapi.g.dart b/app/lib/frontend/handlers/pubapi.g.dart index b0b0e7bc87..b78b78ea9c 100644 --- a/app/lib/frontend/handlers/pubapi.g.dart +++ b/app/lib/frontend/handlers/pubapi.g.dart @@ -75,6 +75,24 @@ Router _$PubApiRouter(PubApi service) { return $utilities.unhandledError(e, st); } }); + router.add('GET', r'/api/packages//versions//attestation', ( + Request request, + String package, + String version, + ) async { + try { + final _$result = await service.getPackageVersionAttestation( + request, + package, + version, + ); + return _$result; + } on ApiResponseException catch (e) { + return e.asApiResponse(); + } catch (e, st) { + return $utilities.unhandledError(e, st); + } + }); router.add('GET', r'/api/archives/-.tar.gz', ( Request request, String package, diff --git a/app/lib/package/backend.dart b/app/lib/package/backend.dart index 9063ea72d7..32d4588fe7 100644 --- a/app/lib/package/backend.dart +++ b/app/lib/package/backend.dart @@ -1208,12 +1208,31 @@ class PackageBackend { throw PackageRejectedException.dependencyDoesNotExists(name); } + // Check for an accompanying Sigstore attestation bundle in the incoming bucket. + String? attestationContent; + final attestationObjectName = + '${tmpObjectName(uploadGuid)}.sigstore.json'; + final attestationInfo = await _incomingBucket.tryInfo( + attestationObjectName, + ); + if (attestationInfo?.length != null) { + _logger.info('Reading package attestation ($uploadGuid).'); + final attestationFilename = + '${dir.absolute.path}/attestation.sigstore.json'; + await _incomingBucket.readWithRetry( + attestationObjectName, + (input) => _saveTarballToFS(input, attestationFilename), + ); + attestationContent = await File(attestationFilename).readAsString(); + } + sw.reset(); final entities = await _createUploadEntities( db, agent, archive, sha256Hash: sha256Hash, + attestationContent: attestationContent, ); final (version, uploadMessages) = await _performTarballUpload( entities: entities, @@ -1229,6 +1248,9 @@ class PackageBackend { sw.reset(); await _incomingBucket.deleteWithRetry(uploadObjectName); await _incomingBucket.deleteWithRetry(workObjectName); + if (attestationInfo?.length != null) { + await _incomingBucket.deleteWithRetry(attestationObjectName); + } _logger.info('Temporary object removed in ${sw.elapsed}.'); return [ 'Successfully uploaded ' @@ -2368,6 +2390,7 @@ Future<_UploadEntities> _createUploadEntities( AuthenticatedAgent agent, PackageSummary archive, { required List sha256Hash, + String? attestationContent, }) async { final pubspec = Pubspec.fromYaml(archive.pubspecContent!); final packageKey = db.emptyKey.append(Package, id: pubspec.name); @@ -2387,6 +2410,7 @@ Future<_UploadEntities> _createUploadEntities( final derived = derivePackageVersionEntities( archive: archive, versionCreated: version.created!, + attestationContent: attestationContent, ); // TODO: verify if assets sizes are within the transaction limit (10 MB) @@ -2397,6 +2421,7 @@ Future<_UploadEntities> _createUploadEntities( DerivedPackageVersionEntities derivePackageVersionEntities({ required PackageSummary archive, required DateTime versionCreated, + String? attestationContent, }) { final pubspec = Pubspec.fromYaml(archive.pubspecContent!); final key = QualifiedVersionKey( @@ -2455,6 +2480,15 @@ DerivedPackageVersionEntities derivePackageVersionEntities({ path: archive.licensePath, textContent: capContent(archive.licenseContent), ), + if (attestationContent != null) + PackageVersionAsset.init( + package: key.package, + version: key.version, + kind: AssetKind.attestation, + versionCreated: versionCreated, + path: '${key.package}-${key.version}.sigstore.json', + textContent: capContent(attestationContent), + ), ]; final versionInfo = PackageVersionInfo() diff --git a/app/lib/package/models.dart b/app/lib/package/models.dart index db3c65502b..af8ea87846 100644 --- a/app/lib/package/models.dart +++ b/app/lib/package/models.dart @@ -765,6 +765,7 @@ abstract class AssetKind { static const changelog = 'changelog'; static const example = 'example'; static const license = 'license'; + static const attestation = 'attestation'; } /// A derived entity that holds extracted asset of a [PackageVersion] archive. diff --git a/app/lib/tool/utils/pub_api_client.dart b/app/lib/tool/utils/pub_api_client.dart index dd9270dde2..4eeb152b3e 100644 --- a/app/lib/tool/utils/pub_api_client.dart +++ b/app/lib/tool/utils/pub_api_client.dart @@ -174,7 +174,10 @@ bool _retryIf(Exception e) { extension PubApiClientExt on PubApiClient { @visibleForTesting - Future preparePackageUpload(List bytes) async { + Future preparePackageUpload( + List bytes, { + List? attestationBytes, + }) async { final uploadInfo = await getPackageUploadUrl(); final request = http.MultipartRequest('POST', Uri.parse(uploadInfo.url)) @@ -197,12 +200,34 @@ extension PubApiClientExt on PubApiClient { final callbackUri = Uri.parse( uploadInfo.fields!['success_action_redirect']!, ); - return callbackUri.queryParameters['upload_id']!; + final uploadId = callbackUri.queryParameters['upload_id']!; + + if (attestationBytes != null) { + final baseKey = uploadInfo.fields!['key']!; + final attestationFields = Map.from(uploadInfo.fields!); + attestationFields['key'] = '$baseKey.sigstore.json'; + attestationFields.remove('success_action_redirect'); + final attRequest = + http.MultipartRequest('POST', Uri.parse(uploadInfo.url)) + ..headers[fakeClockHeaderName] = clock.now().toIso8601String() + ..fields.addAll(attestationFields) + ..files.add(http.MultipartFile.fromBytes('file', attestationBytes)) + ..followRedirects = false; + await attRequest.send(); + } + + return uploadId; } @visibleForTesting - Future uploadPackageBytes(List bytes) async { - final uploadId = await preparePackageUpload(bytes); + Future uploadPackageBytes( + List bytes, { + List? attestationBytes, + }) async { + final uploadId = await preparePackageUpload( + bytes, + attestationBytes: attestationBytes, + ); return await finishPackageUpload(uploadId); } } diff --git a/app/test/package/upload_test.dart b/app/test/package/upload_test.dart index fcb0da224b..95e407ff05 100644 --- a/app/test/package/upload_test.dart +++ b/app/test/package/upload_test.dart @@ -3,6 +3,7 @@ // BSD-style license that can be found in the LICENSE file. import 'dart:async'; +import 'dart:convert'; import 'package:_pub_shared/data/package_api.dart'; import 'package:clock/clock.dart'; @@ -1641,5 +1642,48 @@ void main() { ); }, ); + + testWithProfile( + 'successful upload with attestation bundle and api retrieval', + fn: () async { + final pubspecContent = + 'name: attested_pkg\nversion: 1.0.0\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 attestationBytes = utf8.encode(jsonEncode(bundleJson)); + + final client = createPubApiClient(authToken: adminClientToken); + final message = await client.uploadPackageBytes( + archiveBytes, + attestationBytes: attestationBytes, + ); + expect(message.success.message, contains('Successfully uploaded')); + + // Verify attestation asset was stored in Datastore + final asset = await packageBackend.lookupPackageVersionAsset( + 'attested_pkg', + '1.0.0', + AssetKind.attestation, + ); + expect(asset, isNotNull); + expect(asset!.textContent, isNotNull); + final storedJson = + jsonDecode(asset.textContent!) as Map; + expect( + storedJson['mediaType'], + equals('application/vnd.dev.sigstore.bundle.v0.3+json'), + ); + }, + ); }); } From 9c8eea02d99238019294e21e65dfc83e1af2794a Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 20 Aug 2026 09:27:11 +0000 Subject: [PATCH 2/9] Sync pubapi.client.dart and fix URL/upload tests for attestation endpoint --- .../frontend/handlers/invalid_package_url_test.dart | 4 ++-- app/test/package/upload_test.dart | 2 +- pkg/_pub_shared/lib/src/pubapi.client.dart | 10 ++++++++++ 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/app/test/frontend/handlers/invalid_package_url_test.dart b/app/test/frontend/handlers/invalid_package_url_test.dart index d4ee331c38..9c49005ac5 100644 --- a/app/test/frontend/handlers/invalid_package_url_test.dart +++ b/app/test/frontend/handlers/invalid_package_url_test.dart @@ -34,7 +34,7 @@ void main() { expect(urls, contains('/packages//versions/')); expect(urls, contains('/api/packages/')); // this a naive assertion that fails, if new end-points are introduced! - expect(urls, hasLength(44), reason: 'check if new end-points was added'); + expect(urls, hasLength(45), reason: 'check if new end-points was added'); }); testWithProfile( @@ -71,7 +71,7 @@ void main() { (url) => url .replaceAll('', 'oxygen') .replaceAll('', '1.2.0'), - {200, 303, 401}, + {200, 303, 401, 404}, ); await check( diff --git a/app/test/package/upload_test.dart b/app/test/package/upload_test.dart index 95e407ff05..ad562b3361 100644 --- a/app/test/package/upload_test.dart +++ b/app/test/package/upload_test.dart @@ -1647,7 +1647,7 @@ void main() { 'successful upload with attestation bundle and api retrieval', fn: () async { final pubspecContent = - 'name: attested_pkg\nversion: 1.0.0\nenvironment:\n sdk: ">=2.12.0 <4.0.0"\n'; + 'name: attested_pkg\nversion: 1.0.0\ndescription: A package with attestation.\nenvironment:\n sdk: ">=2.12.0 <4.0.0"\n'; final archiveBytes = await packageArchiveBytes( pubspecContent: pubspecContent, ); diff --git a/pkg/_pub_shared/lib/src/pubapi.client.dart b/pkg/_pub_shared/lib/src/pubapi.client.dart index 951c4edd9b..96e4ef6923 100644 --- a/pkg/_pub_shared/lib/src/pubapi.client.dart +++ b/pkg/_pub_shared/lib/src/pubapi.client.dart @@ -59,6 +59,16 @@ class PubApiClient { ); } + Future> getPackageVersionAttestation( + String package, + String version, + ) async { + return await _client.requestBytes( + verb: 'get', + path: '/api/packages/$package/versions/$version/attestation', + ); + } + Future> fetchPackage(String package, String version) async { return await _client.requestBytes( verb: 'get', From 2943d4e5762476e2c57feb529ebf11541d813b80 Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 20 Aug 2026 11:15:09 +0000 Subject: [PATCH 3/9] Add tests and validation for invalid attestation and 404 retrieval --- app/lib/package/backend.dart | 14 ++++- app/test/package/upload_test.dart | 88 +++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 1 deletion(-) diff --git a/app/lib/package/backend.dart b/app/lib/package/backend.dart index 32d4588fe7..51b60924e2 100644 --- a/app/lib/package/backend.dart +++ b/app/lib/package/backend.dart @@ -3,6 +3,7 @@ // BSD-style license that can be found in the LICENSE file. import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'package:_pub_shared/data/account_api.dart' as account_api; @@ -1223,7 +1224,18 @@ class PackageBackend { attestationObjectName, (input) => _saveTarballToFS(input, attestationFilename), ); - attestationContent = await File(attestationFilename).readAsString(); + try { + final bytes = await File(attestationFilename).readAsBytes(); + attestationContent = utf8.decode(bytes); + final decoded = jsonDecode(attestationContent); + if (decoded is! Map) { + throw FormatException('Attestation bundle must be a JSON object.'); + } + } on FormatException catch (e) { + throw PackageRejectedException( + 'Invalid attestation bundle format: $e', + ); + } } sw.reset(); diff --git a/app/test/package/upload_test.dart b/app/test/package/upload_test.dart index ad562b3361..4dcd74f436 100644 --- a/app/test/package/upload_test.dart +++ b/app/test/package/upload_test.dart @@ -1683,6 +1683,94 @@ void main() { storedJson['mediaType'], equals('application/vnd.dev.sigstore.bundle.v0.3+json'), ); + + // Verify attestation can be retrieved via the API endpoint + final retrievedBytes = await client.getPackageVersionAttestation( + 'attested_pkg', + '1.0.0', + ); + final retrievedJson = + jsonDecode(utf8.decode(retrievedBytes)) as Map; + expect( + retrievedJson['mediaType'], + equals('application/vnd.dev.sigstore.bundle.v0.3+json'), + ); + }, + ); + + testWithProfile( + 'retrieving attestation of a package without attestation returns 404', + fn: () async { + final pubspecContent = + 'name: unattested_pkg\nversion: 1.0.0\ndescription: A package without attestation.\nenvironment:\n sdk: ">=2.12.0 <4.0.0"\n'; + final archiveBytes = await packageArchiveBytes( + pubspecContent: pubspecContent, + ); + + final client = createPubApiClient(authToken: adminClientToken); + final message = await client.uploadPackageBytes(archiveBytes); + expect(message.success.message, contains('Successfully uploaded')); + + final rs = client.getPackageVersionAttestation( + 'unattested_pkg', + '1.0.0', + ); + await expectApiException( + rs, + status: 404, + code: 'NotFound', + message: 'Could not find `attestation for unattested_pkg 1.0.0`.', + ); + }, + ); + + testWithProfile( + 'upload fails when attestation bundle has invalid JSON or invalid bytes', + fn: () async { + final pubspecContent = + 'name: bad_attested_pkg\nversion: 1.0.0\ndescription: A package with bad attestation.\nenvironment:\n sdk: ">=2.12.0 <4.0.0"\n'; + final archiveBytes = await packageArchiveBytes( + pubspecContent: pubspecContent, + ); + + // 1. Invalid non-UTF8 / tampered raw bytes + final rs1 = createPubApiClient(authToken: adminClientToken) + .uploadPackageBytes( + archiveBytes, + attestationBytes: [0xFF, 0xFE, 0xFD], + ); + await expectApiException( + rs1, + status: 400, + code: 'PackageRejected', + message: 'Invalid attestation bundle format', + ); + + // 2. Invalid non-JSON string + final rs2 = createPubApiClient(authToken: adminClientToken) + .uploadPackageBytes( + archiveBytes, + attestationBytes: utf8.encode('this is not json'), + ); + await expectApiException( + rs2, + status: 400, + code: 'PackageRejected', + message: 'Invalid attestation bundle format', + ); + + // 3. Non-object JSON + final rs3 = createPubApiClient(authToken: adminClientToken) + .uploadPackageBytes( + archiveBytes, + attestationBytes: utf8.encode('[1, 2, 3]'), + ); + await expectApiException( + rs3, + status: 400, + code: 'PackageRejected', + message: 'Invalid attestation bundle format', + ); }, ); }); From 96cfcc88070f5059b246f8b3fc2c190b3ab06a2a Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 20 Aug 2026 11:32:29 +0000 Subject: [PATCH 4/9] switch order --- app/lib/tool/utils/pub_api_client.dart | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/app/lib/tool/utils/pub_api_client.dart b/app/lib/tool/utils/pub_api_client.dart index 4eeb152b3e..b400eba373 100644 --- a/app/lib/tool/utils/pub_api_client.dart +++ b/app/lib/tool/utils/pub_api_client.dart @@ -197,11 +197,6 @@ extension PubApiClientExt on PubApiClient { ); } - final callbackUri = Uri.parse( - uploadInfo.fields!['success_action_redirect']!, - ); - final uploadId = callbackUri.queryParameters['upload_id']!; - if (attestationBytes != null) { final baseKey = uploadInfo.fields!['key']!; final attestationFields = Map.from(uploadInfo.fields!); @@ -216,7 +211,10 @@ extension PubApiClientExt on PubApiClient { await attRequest.send(); } - return uploadId; + final callbackUri = Uri.parse( + uploadInfo.fields!['success_action_redirect']!, + ); + return callbackUri.queryParameters['upload_id']!; } @visibleForTesting From ac244616aca67e01ead0831b065a9dac7bc22ed1 Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 20 Aug 2026 11:34:02 +0000 Subject: [PATCH 5/9] Upload attestation bundle before package archive in preparePackageUpload --- app/lib/tool/utils/pub_api_client.dart | 28 +++++++++++++------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/app/lib/tool/utils/pub_api_client.dart b/app/lib/tool/utils/pub_api_client.dart index b400eba373..daccfdea99 100644 --- a/app/lib/tool/utils/pub_api_client.dart +++ b/app/lib/tool/utils/pub_api_client.dart @@ -180,6 +180,20 @@ extension PubApiClientExt on PubApiClient { }) async { final uploadInfo = await getPackageUploadUrl(); + if (attestationBytes != null) { + final baseKey = uploadInfo.fields!['key']!; + final attestationFields = Map.from(uploadInfo.fields!); + attestationFields['key'] = '$baseKey.sigstore.json'; + attestationFields.remove('success_action_redirect'); + final attRequest = + http.MultipartRequest('POST', Uri.parse(uploadInfo.url)) + ..headers[fakeClockHeaderName] = clock.now().toIso8601String() + ..fields.addAll(attestationFields) + ..files.add(http.MultipartFile.fromBytes('file', attestationBytes)) + ..followRedirects = false; + await attRequest.send(); + } + final request = http.MultipartRequest('POST', Uri.parse(uploadInfo.url)) ..headers[fakeClockHeaderName] = clock.now().toIso8601String() ..fields.addAll(uploadInfo.fields!) @@ -197,20 +211,6 @@ extension PubApiClientExt on PubApiClient { ); } - if (attestationBytes != null) { - final baseKey = uploadInfo.fields!['key']!; - final attestationFields = Map.from(uploadInfo.fields!); - attestationFields['key'] = '$baseKey.sigstore.json'; - attestationFields.remove('success_action_redirect'); - final attRequest = - http.MultipartRequest('POST', Uri.parse(uploadInfo.url)) - ..headers[fakeClockHeaderName] = clock.now().toIso8601String() - ..fields.addAll(attestationFields) - ..files.add(http.MultipartFile.fromBytes('file', attestationBytes)) - ..followRedirects = false; - await attRequest.send(); - } - final callbackUri = Uri.parse( uploadInfo.fields!['success_action_redirect']!, ); From 13c151edd36fbb2c2acb825114a4dc5cba6ab32f Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 20 Aug 2026 11:38:35 +0000 Subject: [PATCH 6/9] Add docs --- app/lib/tool/utils/pub_api_client.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/lib/tool/utils/pub_api_client.dart b/app/lib/tool/utils/pub_api_client.dart index daccfdea99..68b928b8e9 100644 --- a/app/lib/tool/utils/pub_api_client.dart +++ b/app/lib/tool/utils/pub_api_client.dart @@ -180,6 +180,8 @@ extension PubApiClientExt on PubApiClient { }) async { final uploadInfo = await getPackageUploadUrl(); + // Send the attestion bundle first, so we never accidentally upload a + // package without its attestation bundle. if (attestationBytes != null) { final baseKey = uploadInfo.fields!['key']!; final attestationFields = Map.from(uploadInfo.fields!); From c00d89435970bb8ec86001bea3c87c6e5888b18b Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 3 Sep 2026 15:37:43 +0000 Subject: [PATCH 7/9] Support dedicated attestation upload URL and fields in UploadInfo and UploadSignerService --- .../backend/fake_upload_signer_service.dart | 8 ++++ app/lib/package/backend.dart | 1 + app/lib/package/upload_signer_service.dart | 39 ++++++++++++++++++- app/lib/tool/utils/pub_api_client.dart | 16 ++++---- app/test/package/upload_test.dart | 16 ++++++++ pkg/_pub_shared/lib/data/package_api.dart | 13 ++++++- pkg/_pub_shared/lib/data/package_api.g.dart | 11 +++++- 7 files changed, 94 insertions(+), 10 deletions(-) diff --git a/app/lib/fake/backend/fake_upload_signer_service.dart b/app/lib/fake/backend/fake_upload_signer_service.dart index f57b1a73b1..ff61344889 100644 --- a/app/lib/fake/backend/fake_upload_signer_service.dart +++ b/app/lib/fake/backend/fake_upload_signer_service.dart @@ -19,6 +19,8 @@ class FakeUploadSignerService implements UploadSignerService { String? successRedirectUrl, String predefinedAcl = 'project-private', int maxUploadSize = UploadSignerService.maxUploadSize, + String? attestationObject, + int maxAttestationUploadSize = UploadSignerService.maxAttestationUploadSize, }) async { return UploadInfo( url: Uri.parse('$_storagePrefix/$bucket/$object').toString(), @@ -27,6 +29,12 @@ class FakeUploadSignerService implements UploadSignerService { if (successRedirectUrl != null) 'success_action_redirect': successRedirectUrl, }, + attestationUrl: attestationObject != null + ? Uri.parse('$_storagePrefix/$bucket/$attestationObject').toString() + : null, + attestationFields: attestationObject != null + ? {'key': '$bucket/$attestationObject'} + : null, ); } diff --git a/app/lib/package/backend.dart b/app/lib/package/backend.dart index 51b60924e2..4a7a8f1bc8 100644 --- a/app/lib/package/backend.dart +++ b/app/lib/package/backend.dart @@ -1064,6 +1064,7 @@ class PackageBackend { object, lifetime, successRedirectUrl: '$url', + attestationObject: '$object.sigstore.json', ); } diff --git a/app/lib/package/upload_signer_service.dart b/app/lib/package/upload_signer_service.dart index daffdaa65e..7cc8887992 100644 --- a/app/lib/package/upload_signer_service.dart +++ b/app/lib/package/upload_signer_service.dart @@ -54,6 +54,7 @@ Future createUploadSigner(http.Client authClient) async { /// https://cloud.google.com/storage/docs/xml-api/post-object abstract class UploadSignerService { static const int maxUploadSize = 100 * 1024 * 1024; + static const int maxAttestationUploadSize = 10 * 1024 * 1024; static final Uri _uploadUrl = Uri.parse('https://storage.googleapis.com'); Future buildUpload( @@ -62,6 +63,8 @@ abstract class UploadSignerService { Duration lifetime, { String? successRedirectUrl, int maxUploadSize = maxUploadSize, + String? attestationObject, + int maxAttestationUploadSize = maxAttestationUploadSize, }) async { final now = clock.now().toUtc(); final expirationString = now.add(lifetime).toIso8601String(); @@ -94,7 +97,41 @@ abstract class UploadSignerService { 'success_action_redirect': successRedirectUrl, }; - return UploadInfo(url: _uploadUrl.toString(), fields: fields); + Map? attestationFields; + if (attestationObject != null) { + final attestationKey = '$bucket/$attestationObject'; + final attestationConditions = [ + {'key': attestationKey}, + {'expires': expirationString}, + ['content-length-range', 0, maxAttestationUploadSize], + ]; + final attestationPolicyMap = { + 'expiration': expirationString, + 'conditions': attestationConditions, + }; + final attestationPolicyString = base64.encode( + jsonUtf8Encoder.convert(attestationPolicyMap), + ); + final SigningResult attestationResult = await sign( + ascii.encode(attestationPolicyString), + ); + final attestationSignatureString = base64.encode(attestationResult.bytes); + + attestationFields = { + 'key': attestationKey, + 'Expires': expirationString, + 'GoogleAccessId': attestationResult.googleAccessId, + 'policy': attestationPolicyString, + 'signature': attestationSignatureString, + }; + } + + return UploadInfo( + url: _uploadUrl.toString(), + fields: fields, + attestationUrl: attestationObject != null ? _uploadUrl.toString() : null, + attestationFields: attestationFields, + ); } Future sign(List bytes); diff --git a/app/lib/tool/utils/pub_api_client.dart b/app/lib/tool/utils/pub_api_client.dart index 68b928b8e9..230ee0484e 100644 --- a/app/lib/tool/utils/pub_api_client.dart +++ b/app/lib/tool/utils/pub_api_client.dart @@ -180,17 +180,19 @@ extension PubApiClientExt on PubApiClient { }) async { final uploadInfo = await getPackageUploadUrl(); - // Send the attestion bundle first, so we never accidentally upload a + // Send the attestation bundle first, so we never accidentally upload a // package without its attestation bundle. if (attestationBytes != null) { - final baseKey = uploadInfo.fields!['key']!; - final attestationFields = Map.from(uploadInfo.fields!); - attestationFields['key'] = '$baseKey.sigstore.json'; - attestationFields.remove('success_action_redirect'); + if (uploadInfo.attestationUrl == null || + uploadInfo.attestationFields == null) { + throw StateError( + 'Server does not support uploading package attestations.', + ); + } final attRequest = - http.MultipartRequest('POST', Uri.parse(uploadInfo.url)) + http.MultipartRequest('POST', Uri.parse(uploadInfo.attestationUrl!)) ..headers[fakeClockHeaderName] = clock.now().toIso8601String() - ..fields.addAll(attestationFields) + ..fields.addAll(uploadInfo.attestationFields!) ..files.add(http.MultipartFile.fromBytes('file', attestationBytes)) ..followRedirects = false; await attRequest.send(); diff --git a/app/test/package/upload_test.dart b/app/test/package/upload_test.dart index 4dcd74f436..dde081e474 100644 --- a/app/test/package/upload_test.dart +++ b/app/test/package/upload_test.dart @@ -1643,6 +1643,22 @@ void main() { }, ); + testWithProfile( + 'getPackageUploadUrl returns attestationUrl and attestationFields', + fn: () async { + final client = createPubApiClient(authToken: adminClientToken); + final uploadInfo = await client.getPackageUploadUrl(); + expect(uploadInfo.url, isNotEmpty); + expect(uploadInfo.fields, isNotNull); + expect(uploadInfo.attestationUrl, isNotEmpty); + expect(uploadInfo.attestationFields, isNotNull); + expect( + uploadInfo.attestationFields!['key'], + endsWith('.sigstore.json'), + ); + }, + ); + testWithProfile( 'successful upload with attestation bundle and api retrieval', fn: () async { diff --git a/pkg/_pub_shared/lib/data/package_api.dart b/pkg/_pub_shared/lib/data/package_api.dart index 1d40bcb372..cf1f3d7d76 100644 --- a/pkg/_pub_shared/lib/data/package_api.dart +++ b/pkg/_pub_shared/lib/data/package_api.dart @@ -18,7 +18,18 @@ class UploadInfo { /// The fields the uploader should add to the multipart upload. final Map? fields; - UploadInfo({required this.url, required this.fields}); + /// The endpoint where the optional package attestation bundle should be posted. + final String? attestationUrl; + + /// The fields the uploader should add to the attestation multipart upload. + final Map? attestationFields; + + UploadInfo({ + required this.url, + required this.fields, + this.attestationUrl, + this.attestationFields, + }); factory UploadInfo.fromJson(Map json) => _$UploadInfoFromJson(json); diff --git a/pkg/_pub_shared/lib/data/package_api.g.dart b/pkg/_pub_shared/lib/data/package_api.g.dart index 8d18301bbe..a376d27365 100644 --- a/pkg/_pub_shared/lib/data/package_api.g.dart +++ b/pkg/_pub_shared/lib/data/package_api.g.dart @@ -11,10 +11,19 @@ UploadInfo _$UploadInfoFromJson(Map json) => UploadInfo( fields: (json['fields'] as Map?)?.map( (k, e) => MapEntry(k, e as String), ), + attestationUrl: json['attestationUrl'] as String?, + attestationFields: (json['attestationFields'] as Map?)?.map( + (k, e) => MapEntry(k, e as String), + ), ); Map _$UploadInfoToJson(UploadInfo instance) => - {'url': instance.url, 'fields': instance.fields}; + { + 'url': instance.url, + 'fields': instance.fields, + 'attestationUrl': instance.attestationUrl, + 'attestationFields': instance.attestationFields, + }; PkgOptions _$PkgOptionsFromJson(Map json) => PkgOptions( isDiscontinued: json['isDiscontinued'] as bool?, From 0333863cfe209fb5632aa5e4ecf54a305f69ca89 Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 7 Sep 2026 12:20:29 +0000 Subject: [PATCH 8/9] Receive package attestation bundle via POST in finish upload endpoint - Revert attestationUrl and attestationFields in UploadInfo, UploadSignerService, and FakeUploadSignerService. - Support POST requests on /api/packages/versions/newUploadFinish and /api/packages/versions/newUploadFinish/ to receive {'attestation': } JSON body. - Update PackageBackend.publishUploadedBlob to accept attestationContent directly rather than reading a separate object from Cloud Storage. - Expose Client and sendRaw in api_builder to facilitate sending raw and POST finalize requests. - Update PubApiClientExt.uploadPackageBytes to send attestation bundle in POST finalization request. - Run codegen and update tests. --- .../backend/fake_upload_signer_service.dart | 8 --- app/lib/frontend/handlers/pubapi.client.dart | 2 + app/lib/frontend/handlers/pubapi.dart | 39 +++++++++++++- app/lib/frontend/handlers/pubapi.g.dart | 25 +++++++++ app/lib/package/backend.dart | 27 ++-------- app/lib/package/upload_signer_service.dart | 39 +------------- app/lib/tool/utils/pub_api_client.dart | 52 +++++++++---------- app/test/package/upload_test.dart | 16 ------ pkg/_pub_shared/lib/data/package_api.dart | 13 +---- pkg/_pub_shared/lib/data/package_api.g.dart | 11 +--- pkg/_pub_shared/lib/src/pubapi.client.dart | 2 + pkg/api_builder/lib/_client_utils.dart | 22 ++++++++ .../lib/src/client_library_generator.dart | 13 +++++ 13 files changed, 135 insertions(+), 134 deletions(-) diff --git a/app/lib/fake/backend/fake_upload_signer_service.dart b/app/lib/fake/backend/fake_upload_signer_service.dart index ff61344889..f57b1a73b1 100644 --- a/app/lib/fake/backend/fake_upload_signer_service.dart +++ b/app/lib/fake/backend/fake_upload_signer_service.dart @@ -19,8 +19,6 @@ class FakeUploadSignerService implements UploadSignerService { String? successRedirectUrl, String predefinedAcl = 'project-private', int maxUploadSize = UploadSignerService.maxUploadSize, - String? attestationObject, - int maxAttestationUploadSize = UploadSignerService.maxAttestationUploadSize, }) async { return UploadInfo( url: Uri.parse('$_storagePrefix/$bucket/$object').toString(), @@ -29,12 +27,6 @@ class FakeUploadSignerService implements UploadSignerService { if (successRedirectUrl != null) 'success_action_redirect': successRedirectUrl, }, - attestationUrl: attestationObject != null - ? Uri.parse('$_storagePrefix/$bucket/$attestationObject').toString() - : null, - attestationFields: attestationObject != null - ? {'key': '$bucket/$attestationObject'} - : null, ); } diff --git a/app/lib/frontend/handlers/pubapi.client.dart b/app/lib/frontend/handlers/pubapi.client.dart index 96e4ef6923..46923e9882 100644 --- a/app/lib/frontend/handlers/pubapi.client.dart +++ b/app/lib/frontend/handlers/pubapi.client.dart @@ -30,6 +30,8 @@ class PubApiClient { final _i2.Client _client; + _i2.Client get client => _client; + Future> listVersions(String package) async { return await _client.requestBytes( verb: 'get', diff --git a/app/lib/frontend/handlers/pubapi.dart b/app/lib/frontend/handlers/pubapi.dart index b0e095d081..cd0d0d8a3a 100644 --- a/app/lib/frontend/handlers/pubapi.dart +++ b/app/lib/frontend/handlers/pubapi.dart @@ -2,6 +2,8 @@ // 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 'package:_pub_shared/data/account_api.dart'; import 'package:_pub_shared/data/admin_api.dart'; import 'package:_pub_shared/data/advisories_api.dart'; @@ -165,6 +167,7 @@ class PubApi { /// https://github.com/dart-lang/pub/blob/master/doc/repository-spec-v2.md#publishing-packages /// /// GET /api/packages/versions/newUploadFinish + /// POST /api/packages/versions/newUploadFinish /// [200 OK] /// { /// "success" : { @@ -172,6 +175,7 @@ class PubApi { /// }, /// } @EndPoint.get('/api/packages/versions/newUploadFinish') + @EndPoint.post('/api/packages/versions/newUploadFinish') Future packageUploadCallback(Request request) async { final uploadId = request.requestedUri.queryParameters['upload_id']; InvalidInputException.checkNotNull(uploadId, 'upload_id'); @@ -179,11 +183,44 @@ class PubApi { } @EndPoint.get('/api/packages/versions/newUploadFinish/') + @EndPoint.post('/api/packages/versions/newUploadFinish/') Future finishPackageUpload( Request request, String uploadId, ) async { - final messages = await packageBackend.publishUploadedBlob(uploadId); + String? attestationContent; + if (request.method == 'POST') { + try { + final bytes = await request.read().expand((i) => i).toList(); + if (bytes.isEmpty) { + throw PackageRejectedException( + 'Invalid attestation bundle format: request body must contain an "attestation" object.', + ); + } + final bodyText = utf8.decode(bytes); + final bodyJson = jsonDecode(bodyText); + if (bodyJson is Map && + bodyJson.containsKey('attestation')) { + final attestation = bodyJson['attestation']; + if (attestation is! Map) { + throw PackageRejectedException( + 'Invalid attestation bundle format: attestation must be a JSON object.', + ); + } + attestationContent = jsonEncode(attestation); + } else { + throw PackageRejectedException( + 'Invalid attestation bundle format: request body must contain an "attestation" object.', + ); + } + } on FormatException catch (e) { + throw PackageRejectedException('Invalid attestation bundle format: $e'); + } + } + final messages = await packageBackend.publishUploadedBlob( + uploadId, + attestationContent: attestationContent, + ); return SuccessMessage(success: Message(message: messages.join('\n'))); } diff --git a/app/lib/frontend/handlers/pubapi.g.dart b/app/lib/frontend/handlers/pubapi.g.dart index b78b78ea9c..af02f44319 100644 --- a/app/lib/frontend/handlers/pubapi.g.dart +++ b/app/lib/frontend/handlers/pubapi.g.dart @@ -129,6 +129,18 @@ Router _$PubApiRouter(PubApi service) { return $utilities.unhandledError(e, st); } }); + router.add('POST', r'/api/packages/versions/newUploadFinish', ( + Request request, + ) async { + try { + final _$result = await service.packageUploadCallback(request); + return $utilities.jsonResponse(_$result.toJson()); + } on ApiResponseException catch (e) { + return e.asApiResponse(); + } catch (e, st) { + return $utilities.unhandledError(e, st); + } + }); router.add('GET', r'/api/packages/versions/newUploadFinish/', ( Request request, String uploadId, @@ -142,6 +154,19 @@ Router _$PubApiRouter(PubApi service) { return $utilities.unhandledError(e, st); } }); + router.add('POST', r'/api/packages/versions/newUploadFinish/', ( + Request request, + String uploadId, + ) async { + try { + final _$result = await service.finishPackageUpload(request, uploadId); + return $utilities.jsonResponse(_$result.toJson()); + } on ApiResponseException catch (e) { + return e.asApiResponse(); + } catch (e, st) { + return $utilities.unhandledError(e, st); + } + }); router.add('POST', r'/api/packages//uploaders', ( Request request, String package, diff --git a/app/lib/package/backend.dart b/app/lib/package/backend.dart index 4a7a8f1bc8..13ee83c1d5 100644 --- a/app/lib/package/backend.dart +++ b/app/lib/package/backend.dart @@ -1064,13 +1064,15 @@ class PackageBackend { object, lifetime, successRedirectUrl: '$url', - attestationObject: '$object.sigstore.json', ); } /// Finishes the upload of a package and returns the list of messages /// related to the publishing. - Future> publishUploadedBlob(String uploadGuid) async { + Future> publishUploadedBlob( + String uploadGuid, { + String? attestationContent, + }) async { final restriction = await getUploadRestrictionStatus(); if (restriction == UploadRestrictionStatus.noUploads) { throw PackageRejectedException.uploadRestricted(); @@ -1210,24 +1212,8 @@ class PackageBackend { throw PackageRejectedException.dependencyDoesNotExists(name); } - // Check for an accompanying Sigstore attestation bundle in the incoming bucket. - String? attestationContent; - final attestationObjectName = - '${tmpObjectName(uploadGuid)}.sigstore.json'; - final attestationInfo = await _incomingBucket.tryInfo( - attestationObjectName, - ); - if (attestationInfo?.length != null) { - _logger.info('Reading package attestation ($uploadGuid).'); - final attestationFilename = - '${dir.absolute.path}/attestation.sigstore.json'; - await _incomingBucket.readWithRetry( - attestationObjectName, - (input) => _saveTarballToFS(input, attestationFilename), - ); + if (attestationContent != null) { try { - final bytes = await File(attestationFilename).readAsBytes(); - attestationContent = utf8.decode(bytes); final decoded = jsonDecode(attestationContent); if (decoded is! Map) { throw FormatException('Attestation bundle must be a JSON object.'); @@ -1261,9 +1247,6 @@ class PackageBackend { sw.reset(); await _incomingBucket.deleteWithRetry(uploadObjectName); await _incomingBucket.deleteWithRetry(workObjectName); - if (attestationInfo?.length != null) { - await _incomingBucket.deleteWithRetry(attestationObjectName); - } _logger.info('Temporary object removed in ${sw.elapsed}.'); return [ 'Successfully uploaded ' diff --git a/app/lib/package/upload_signer_service.dart b/app/lib/package/upload_signer_service.dart index 7cc8887992..daffdaa65e 100644 --- a/app/lib/package/upload_signer_service.dart +++ b/app/lib/package/upload_signer_service.dart @@ -54,7 +54,6 @@ Future createUploadSigner(http.Client authClient) async { /// https://cloud.google.com/storage/docs/xml-api/post-object abstract class UploadSignerService { static const int maxUploadSize = 100 * 1024 * 1024; - static const int maxAttestationUploadSize = 10 * 1024 * 1024; static final Uri _uploadUrl = Uri.parse('https://storage.googleapis.com'); Future buildUpload( @@ -63,8 +62,6 @@ abstract class UploadSignerService { Duration lifetime, { String? successRedirectUrl, int maxUploadSize = maxUploadSize, - String? attestationObject, - int maxAttestationUploadSize = maxAttestationUploadSize, }) async { final now = clock.now().toUtc(); final expirationString = now.add(lifetime).toIso8601String(); @@ -97,41 +94,7 @@ abstract class UploadSignerService { 'success_action_redirect': successRedirectUrl, }; - Map? attestationFields; - if (attestationObject != null) { - final attestationKey = '$bucket/$attestationObject'; - final attestationConditions = [ - {'key': attestationKey}, - {'expires': expirationString}, - ['content-length-range', 0, maxAttestationUploadSize], - ]; - final attestationPolicyMap = { - 'expiration': expirationString, - 'conditions': attestationConditions, - }; - final attestationPolicyString = base64.encode( - jsonUtf8Encoder.convert(attestationPolicyMap), - ); - final SigningResult attestationResult = await sign( - ascii.encode(attestationPolicyString), - ); - final attestationSignatureString = base64.encode(attestationResult.bytes); - - attestationFields = { - 'key': attestationKey, - 'Expires': expirationString, - 'GoogleAccessId': attestationResult.googleAccessId, - 'policy': attestationPolicyString, - 'signature': attestationSignatureString, - }; - } - - return UploadInfo( - url: _uploadUrl.toString(), - fields: fields, - attestationUrl: attestationObject != null ? _uploadUrl.toString() : null, - attestationFields: attestationFields, - ); + return UploadInfo(url: _uploadUrl.toString(), fields: fields); } Future sign(List bytes); diff --git a/app/lib/tool/utils/pub_api_client.dart b/app/lib/tool/utils/pub_api_client.dart index 230ee0484e..b94fc417bd 100644 --- a/app/lib/tool/utils/pub_api_client.dart +++ b/app/lib/tool/utils/pub_api_client.dart @@ -174,30 +174,9 @@ bool _retryIf(Exception e) { extension PubApiClientExt on PubApiClient { @visibleForTesting - Future preparePackageUpload( - List bytes, { - List? attestationBytes, - }) async { + Future preparePackageUpload(List bytes) async { final uploadInfo = await getPackageUploadUrl(); - // Send the attestation bundle first, so we never accidentally upload a - // package without its attestation bundle. - if (attestationBytes != null) { - if (uploadInfo.attestationUrl == null || - uploadInfo.attestationFields == null) { - throw StateError( - 'Server does not support uploading package attestations.', - ); - } - final attRequest = - http.MultipartRequest('POST', Uri.parse(uploadInfo.attestationUrl!)) - ..headers[fakeClockHeaderName] = clock.now().toIso8601String() - ..fields.addAll(uploadInfo.attestationFields!) - ..files.add(http.MultipartFile.fromBytes('file', attestationBytes)) - ..followRedirects = false; - await attRequest.send(); - } - final request = http.MultipartRequest('POST', Uri.parse(uploadInfo.url)) ..headers[fakeClockHeaderName] = clock.now().toIso8601String() ..fields.addAll(uploadInfo.fields!) @@ -226,10 +205,29 @@ extension PubApiClientExt on PubApiClient { List bytes, { List? attestationBytes, }) async { - final uploadId = await preparePackageUpload( - bytes, - attestationBytes: attestationBytes, - ); - return await finishPackageUpload(uploadId); + final uploadId = await preparePackageUpload(bytes); + if (attestationBytes != null) { + List bodyBytes; + try { + final decoded = jsonDecode(utf8.decode(attestationBytes)); + if (decoded is Map && + decoded.containsKey('attestation')) { + bodyBytes = attestationBytes; + } else { + bodyBytes = utf8.encode(jsonEncode({'attestation': decoded})); + } + } catch (_) { + bodyBytes = attestationBytes; + } + final rsJson = await client.sendRaw( + verb: 'post', + path: '/api/packages/versions/newUploadFinish/$uploadId', + headers: {'content-type': 'application/json; charset="utf-8"'}, + bodyBytes: bodyBytes, + ); + return SuccessMessage.fromJson(rsJson); + } else { + return await finishPackageUpload(uploadId); + } } } diff --git a/app/test/package/upload_test.dart b/app/test/package/upload_test.dart index dde081e474..4dcd74f436 100644 --- a/app/test/package/upload_test.dart +++ b/app/test/package/upload_test.dart @@ -1643,22 +1643,6 @@ void main() { }, ); - testWithProfile( - 'getPackageUploadUrl returns attestationUrl and attestationFields', - fn: () async { - final client = createPubApiClient(authToken: adminClientToken); - final uploadInfo = await client.getPackageUploadUrl(); - expect(uploadInfo.url, isNotEmpty); - expect(uploadInfo.fields, isNotNull); - expect(uploadInfo.attestationUrl, isNotEmpty); - expect(uploadInfo.attestationFields, isNotNull); - expect( - uploadInfo.attestationFields!['key'], - endsWith('.sigstore.json'), - ); - }, - ); - testWithProfile( 'successful upload with attestation bundle and api retrieval', fn: () async { diff --git a/pkg/_pub_shared/lib/data/package_api.dart b/pkg/_pub_shared/lib/data/package_api.dart index cf1f3d7d76..1d40bcb372 100644 --- a/pkg/_pub_shared/lib/data/package_api.dart +++ b/pkg/_pub_shared/lib/data/package_api.dart @@ -18,18 +18,7 @@ class UploadInfo { /// The fields the uploader should add to the multipart upload. final Map? fields; - /// The endpoint where the optional package attestation bundle should be posted. - final String? attestationUrl; - - /// The fields the uploader should add to the attestation multipart upload. - final Map? attestationFields; - - UploadInfo({ - required this.url, - required this.fields, - this.attestationUrl, - this.attestationFields, - }); + UploadInfo({required this.url, required this.fields}); factory UploadInfo.fromJson(Map json) => _$UploadInfoFromJson(json); diff --git a/pkg/_pub_shared/lib/data/package_api.g.dart b/pkg/_pub_shared/lib/data/package_api.g.dart index a376d27365..8d18301bbe 100644 --- a/pkg/_pub_shared/lib/data/package_api.g.dart +++ b/pkg/_pub_shared/lib/data/package_api.g.dart @@ -11,19 +11,10 @@ UploadInfo _$UploadInfoFromJson(Map json) => UploadInfo( fields: (json['fields'] as Map?)?.map( (k, e) => MapEntry(k, e as String), ), - attestationUrl: json['attestationUrl'] as String?, - attestationFields: (json['attestationFields'] as Map?)?.map( - (k, e) => MapEntry(k, e as String), - ), ); Map _$UploadInfoToJson(UploadInfo instance) => - { - 'url': instance.url, - 'fields': instance.fields, - 'attestationUrl': instance.attestationUrl, - 'attestationFields': instance.attestationFields, - }; + {'url': instance.url, 'fields': instance.fields}; PkgOptions _$PkgOptionsFromJson(Map json) => PkgOptions( isDiscontinued: json['isDiscontinued'] as bool?, diff --git a/pkg/_pub_shared/lib/src/pubapi.client.dart b/pkg/_pub_shared/lib/src/pubapi.client.dart index 96e4ef6923..46923e9882 100644 --- a/pkg/_pub_shared/lib/src/pubapi.client.dart +++ b/pkg/_pub_shared/lib/src/pubapi.client.dart @@ -30,6 +30,8 @@ class PubApiClient { final _i2.Client _client; + _i2.Client get client => _client; + Future> listVersions(String package) async { return await _client.requestBytes( verb: 'get', diff --git a/pkg/api_builder/lib/_client_utils.dart b/pkg/api_builder/lib/_client_utils.dart index b799b28119..a22f4ddd3a 100644 --- a/pkg/api_builder/lib/_client_utils.dart +++ b/pkg/api_builder/lib/_client_utils.dart @@ -85,6 +85,28 @@ class Client { } throw RequestException(res.statusCode, res.headers, res.bodyBytes); }); + + Future> sendRaw({ + required String verb, + required String path, + Map? query, + Map? headers, + List? bodyBytes, + }) => _withClient((client) async { + final u = Uri.parse(_baseUrl + path).replace(queryParameters: query); + final req = http.Request(verb, u); + if (headers != null) { + req.headers.addAll(headers); + } + if (bodyBytes != null) { + req.bodyBytes = bodyBytes; + } + final res = await http.Response.fromStream(await client.send(req)); + if (200 <= res.statusCode && res.statusCode < 300) { + return json.fuse(utf8).decode(res.bodyBytes) as Map; + } + throw RequestException(res.statusCode, res.headers, res.bodyBytes); + }); } /// Utility method exported for use in generated code. diff --git a/pkg/api_builder/lib/src/client_library_generator.dart b/pkg/api_builder/lib/src/client_library_generator.dart index 218d213bb9..f7f219a5d8 100644 --- a/pkg/api_builder/lib/src/client_library_generator.dart +++ b/pkg/api_builder/lib/src/client_library_generator.dart @@ -118,6 +118,19 @@ code.Class _buildClientClass(ClassElement cls, List handlers) => ), ), ) + ..methods.add( + code.Method( + (b) => b + ..name = 'client' + ..type = code.MethodType.getter + ..returns = code.refer( + 'Client', + 'package:api_builder/_client_utils.dart', + ) + ..lambda = true + ..body = code.refer('_client').code, + ), + ) ..methods.addAll( _removeDuplicateHandlers(handlers).map(_buildClientMethod), ), From 6269086886ef1a767b4c618979d0a9d07afdda7d Mon Sep 17 00:00:00 2001 From: Moritz Date: Fri, 11 Sep 2026 09:26:10 +0000 Subject: [PATCH 9/9] Receive attestations on a dedicated upload endpoint '/api/packages/versions/new' now returns an 'attestationUrl' pointing at '/api/packages/versions/newUploadAttestation/', where the client POSTs the attestation bundle before uploading the archive. The bundle is stored in the incoming bucket next to the archive, and read, stored and (later) verified when the upload is finished. 'newUploadFinish' is a GET endpoint again. Also: * reject attestations over maxAttestationContentLength instead of silently truncating them with capContent(), as a truncated bundle could never be verified, and * validate that the upload id is a uuid before deriving an object name from it. --- app/lib/frontend/handlers/pubapi.client.dart | 9 ++ app/lib/frontend/handlers/pubapi.dart | 71 ++++++------ app/lib/frontend/handlers/pubapi.g.dart | 42 +++---- app/lib/package/backend.dart | 110 ++++++++++++++++--- app/lib/tool/utils/pub_api_client.dart | 74 ++++++------- app/test/package/upload_test.dart | 57 +++++++++- pkg/_pub_shared/lib/data/package_api.dart | 12 +- pkg/_pub_shared/lib/data/package_api.g.dart | 7 +- pkg/_pub_shared/lib/src/pubapi.client.dart | 9 ++ 9 files changed, 265 insertions(+), 126 deletions(-) diff --git a/app/lib/frontend/handlers/pubapi.client.dart b/app/lib/frontend/handlers/pubapi.client.dart index 46923e9882..a487e4a9b0 100644 --- a/app/lib/frontend/handlers/pubapi.client.dart +++ b/app/lib/frontend/handlers/pubapi.client.dart @@ -87,6 +87,15 @@ class PubApiClient { ); } + Future<_i3.SuccessMessage> uploadPackageAttestation(String uploadId) async { + return _i3.SuccessMessage.fromJson( + await _client.requestJson( + verb: 'post', + path: '/api/packages/versions/newUploadAttestation/$uploadId', + ), + ); + } + Future<_i3.SuccessMessage> packageUploadCallback() async { return _i3.SuccessMessage.fromJson( await _client.requestJson( diff --git a/app/lib/frontend/handlers/pubapi.dart b/app/lib/frontend/handlers/pubapi.dart index cd0d0d8a3a..98689c4a45 100644 --- a/app/lib/frontend/handlers/pubapi.dart +++ b/app/lib/frontend/handlers/pubapi.dart @@ -2,8 +2,6 @@ // 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 'package:_pub_shared/data/account_api.dart'; import 'package:_pub_shared/data/admin_api.dart'; import 'package:_pub_shared/data/advisories_api.dart'; @@ -160,14 +158,45 @@ class PubApi { // integration tests before we switch traffic. await packageBackend.startUpload( request.requestedUri.resolve('/api/packages/versions/newUploadFinish'), + attestationUrlPrefix: request.requestedUri.resolve( + '/api/packages/versions/newUploadAttestation', + ), ); + /// Upload an attestation for a package archive that is being uploaded. + /// + /// The attestation is uploaded before the package archive, and is verified + /// and stored when the upload is finished. + /// https://github.com/dart-lang/pub/blob/master/doc/repository-spec-v2.md#publishing-with-an-attestation + /// + /// POST /api/packages/versions/newUploadAttestation/ + /// + /// [200 OK] + /// { + /// "success" : { + /// "message": "Attestation uploaded.", + /// }, + /// } + @EndPoint.post('/api/packages/versions/newUploadAttestation/') + Future uploadPackageAttestation( + Request request, + String uploadId, + ) async { + final bytes = await request.read().expand((i) => i).toList(); + if (bytes.isEmpty) { + throw PackageRejectedException( + 'Invalid attestation bundle format: the request body is empty.', + ); + } + await packageBackend.uploadAttestation(uploadId, bytes); + return SuccessMessage(success: Message(message: 'Attestation uploaded.')); + } + /// Finish async upload. /// TODO: Link to the spec once it has the details updated: /// https://github.com/dart-lang/pub/blob/master/doc/repository-spec-v2.md#publishing-packages /// /// GET /api/packages/versions/newUploadFinish - /// POST /api/packages/versions/newUploadFinish /// [200 OK] /// { /// "success" : { @@ -175,7 +204,6 @@ class PubApi { /// }, /// } @EndPoint.get('/api/packages/versions/newUploadFinish') - @EndPoint.post('/api/packages/versions/newUploadFinish') Future packageUploadCallback(Request request) async { final uploadId = request.requestedUri.queryParameters['upload_id']; InvalidInputException.checkNotNull(uploadId, 'upload_id'); @@ -183,44 +211,11 @@ class PubApi { } @EndPoint.get('/api/packages/versions/newUploadFinish/') - @EndPoint.post('/api/packages/versions/newUploadFinish/') Future finishPackageUpload( Request request, String uploadId, ) async { - String? attestationContent; - if (request.method == 'POST') { - try { - final bytes = await request.read().expand((i) => i).toList(); - if (bytes.isEmpty) { - throw PackageRejectedException( - 'Invalid attestation bundle format: request body must contain an "attestation" object.', - ); - } - final bodyText = utf8.decode(bytes); - final bodyJson = jsonDecode(bodyText); - if (bodyJson is Map && - bodyJson.containsKey('attestation')) { - final attestation = bodyJson['attestation']; - if (attestation is! Map) { - throw PackageRejectedException( - 'Invalid attestation bundle format: attestation must be a JSON object.', - ); - } - attestationContent = jsonEncode(attestation); - } else { - throw PackageRejectedException( - 'Invalid attestation bundle format: request body must contain an "attestation" object.', - ); - } - } on FormatException catch (e) { - throw PackageRejectedException('Invalid attestation bundle format: $e'); - } - } - final messages = await packageBackend.publishUploadedBlob( - uploadId, - attestationContent: attestationContent, - ); + final messages = await packageBackend.publishUploadedBlob(uploadId); return SuccessMessage(success: Message(message: messages.join('\n'))); } diff --git a/app/lib/frontend/handlers/pubapi.g.dart b/app/lib/frontend/handlers/pubapi.g.dart index af02f44319..78242d07e9 100644 --- a/app/lib/frontend/handlers/pubapi.g.dart +++ b/app/lib/frontend/handlers/pubapi.g.dart @@ -117,6 +117,23 @@ Router _$PubApiRouter(PubApi service) { return $utilities.unhandledError(e, st); } }); + router.add( + 'POST', + r'/api/packages/versions/newUploadAttestation/', + (Request request, String uploadId) async { + try { + final _$result = await service.uploadPackageAttestation( + request, + uploadId, + ); + return $utilities.jsonResponse(_$result.toJson()); + } on ApiResponseException catch (e) { + return e.asApiResponse(); + } catch (e, st) { + return $utilities.unhandledError(e, st); + } + }, + ); router.add('GET', r'/api/packages/versions/newUploadFinish', ( Request request, ) async { @@ -129,18 +146,6 @@ Router _$PubApiRouter(PubApi service) { return $utilities.unhandledError(e, st); } }); - router.add('POST', r'/api/packages/versions/newUploadFinish', ( - Request request, - ) async { - try { - final _$result = await service.packageUploadCallback(request); - return $utilities.jsonResponse(_$result.toJson()); - } on ApiResponseException catch (e) { - return e.asApiResponse(); - } catch (e, st) { - return $utilities.unhandledError(e, st); - } - }); router.add('GET', r'/api/packages/versions/newUploadFinish/', ( Request request, String uploadId, @@ -154,19 +159,6 @@ Router _$PubApiRouter(PubApi service) { return $utilities.unhandledError(e, st); } }); - router.add('POST', r'/api/packages/versions/newUploadFinish/', ( - Request request, - String uploadId, - ) async { - try { - final _$result = await service.finishPackageUpload(request, uploadId); - return $utilities.jsonResponse(_$result.toJson()); - } on ApiResponseException catch (e) { - return e.asApiResponse(); - } catch (e, st) { - return $utilities.unhandledError(e, st); - } - }); router.add('POST', r'/api/packages//uploaders', ( Request request, String package, diff --git a/app/lib/package/backend.dart b/app/lib/package/backend.dart index 13ee83c1d5..a6a0b6f1e1 100644 --- a/app/lib/package/backend.dart +++ b/app/lib/package/backend.dart @@ -58,10 +58,22 @@ import 'upload_signer_service.dart'; // that is stored separately in the database. final maxAssetContentLength = 256 * 1024; +// The maximum length of an attestation bundle. +// +// Note: unlike other assets, attestations are rejected when they are longer +// than this, as a truncated attestation could never be verified. +final maxAttestationContentLength = 128 * 1024; + /// The maximum number of versions a package is allowed to have. final _defaultMaxVersionsPerPackage = 1000; final Logger _logger = Logger('pub.cloud_repository'); + +/// Matches the UUIDs created by `createUuid()`, used to identify uploads. +final _uuidRegExp = RegExp( + r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', + caseSensitive: false, +); final _validGitHubUserOrRepoRegExp = RegExp( r'^[a-z0-9\-\._]+$', caseSensitive: false, @@ -1037,7 +1049,16 @@ class PackageBackend { return pv.toApiVersionInfo(); } - Future startUpload(Uri redirectUrl) async { + /// Starts an upload, returning the parameters the client needs to upload the + /// package archive. + /// + /// [redirectUrl] is the URL the client is redirected to when the archive has + /// been uploaded, and [attestationUrlPrefix] is the URL prefix where the + /// client may upload an attestation of the archive. + Future startUpload( + Uri redirectUrl, { + required Uri attestationUrlPrefix, + }) async { final restriction = await getUploadRestrictionStatus(); if (restriction == UploadRestrictionStatus.noUploads) { throw PackageRejectedException.uploadRestricted(); @@ -1059,20 +1080,62 @@ class PackageBackend { _logger.info( 'Redirecting pub client to google cloud storage (uuid: $guid)', ); - return uploadSigner.buildUpload( + final uploadInfo = await uploadSigner.buildUpload( bucket, object, lifetime, successRedirectUrl: '$url', ); + return api.UploadInfo( + url: uploadInfo.url, + fields: uploadInfo.fields, + attestationUrl: '$attestationUrlPrefix/$guid', + ); + } + + /// Stores the attestation [bytes] uploaded for the upload with [uploadGuid]. + /// + /// The attestation is verified and stored with the package version when the + /// upload is finished, see [publishUploadedBlob]. + Future uploadAttestation(String uploadGuid, List bytes) async { + final restriction = await getUploadRestrictionStatus(); + if (restriction == UploadRestrictionStatus.noUploads) { + throw PackageRejectedException.uploadRestricted(); + } + await requireAuthenticatedClient(); + InvalidInputException.check( + _uuidRegExp.hasMatch(uploadGuid), + 'Invalid upload id.', + ); + if (bytes.length > maxAttestationContentLength) { + throw PackageRejectedException( + 'Attestation bundle is too large ' + '(max $maxAttestationContentLength bytes).', + ); + } + // Verify that the bundle is a JSON object before storing it. It can only be + // verified against the archive when the upload is finished. + try { + final decoded = jsonDecode(utf8.decode(bytes)); + if (decoded is! Map) { + throw FormatException('Attestation bundle must be a JSON object.'); + } + } on FormatException catch (e) { + throw PackageRejectedException('Invalid attestation bundle format: $e'); + } + + _logger.info('Uploading attestation (uuid: $uploadGuid).'); + await uploadWithRetry( + _incomingBucket, + tmpAttestationObjectName(uploadGuid), + bytes.length, + () => Stream.value(bytes), + ); } /// Finishes the upload of a package and returns the list of messages /// related to the publishing. - Future> publishUploadedBlob( - String uploadGuid, { - String? attestationContent, - }) async { + Future> publishUploadedBlob(String uploadGuid) async { final restriction = await getUploadRestrictionStatus(); if (restriction == UploadRestrictionStatus.noUploads) { throw PackageRejectedException.uploadRestricted(); @@ -1084,6 +1147,7 @@ class PackageBackend { return await withTempDirectory((Directory dir) async { // Check the existence of the uploaded file final uploadObjectName = tmpObjectName(uploadGuid); + final attestationObjectName = tmpAttestationObjectName(uploadGuid); final info = await _incomingBucket.tryInfo(uploadObjectName); if (info?.length == null) { throw PackageRejectedException.archiveEmpty(); @@ -1212,17 +1276,17 @@ class PackageBackend { throw PackageRejectedException.dependencyDoesNotExists(name); } - if (attestationContent != null) { - try { - final decoded = jsonDecode(attestationContent); - if (decoded is! Map) { - throw FormatException('Attestation bundle must be a JSON object.'); - } - } on FormatException catch (e) { - throw PackageRejectedException( - 'Invalid attestation bundle format: $e', - ); - } + // Read 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; + if (await _incomingBucket.tryInfo(attestationObjectName) != null) { + attestationContent = utf8.decode( + await _incomingBucket.readAsBytes( + attestationObjectName, + maxSize: maxAttestationContentLength, + ), + ); } sw.reset(); @@ -1247,6 +1311,9 @@ class PackageBackend { sw.reset(); await _incomingBucket.deleteWithRetry(uploadObjectName); await _incomingBucket.deleteWithRetry(workObjectName); + if (attestationContent != null) { + await _incomingBucket.deleteWithRetry(attestationObjectName); + } _logger.info('Temporary object removed in ${sw.elapsed}.'); return [ 'Successfully uploaded ' @@ -2483,7 +2550,9 @@ DerivedPackageVersionEntities derivePackageVersionEntities({ kind: AssetKind.attestation, versionCreated: versionCreated, path: '${key.package}-${key.version}.sigstore.json', - textContent: capContent(attestationContent), + // Note: not capped, a truncated attestation could never be verified. + // The length is checked when the attestation is uploaded. + textContent: attestationContent, ), ]; @@ -2503,6 +2572,11 @@ DerivedPackageVersionEntities derivePackageVersionEntities({ @visibleForTesting String tmpObjectName(String guid) => 'tmp/$guid'; +/// The GCS object name of the attestation uploaded for the temporary object +/// [guid] - excluding leading '/'. +@visibleForTesting +String tmpAttestationObjectName(String guid) => 'tmp/$guid.attestation.json'; + /// Verify that the [package] and the optional [version] parameter looks as acceptable input. void checkPackageVersionParams(String package, [String? version]) { InvalidInputException.checkPackageName(package); diff --git a/app/lib/tool/utils/pub_api_client.dart b/app/lib/tool/utils/pub_api_client.dart index b94fc417bd..9f369b6d94 100644 --- a/app/lib/tool/utils/pub_api_client.dart +++ b/app/lib/tool/utils/pub_api_client.dart @@ -175,29 +175,7 @@ bool _retryIf(Exception e) { extension PubApiClientExt on PubApiClient { @visibleForTesting Future preparePackageUpload(List bytes) async { - final uploadInfo = await getPackageUploadUrl(); - - final request = http.MultipartRequest('POST', Uri.parse(uploadInfo.url)) - ..headers[fakeClockHeaderName] = clock.now().toIso8601String() - ..fields.addAll(uploadInfo.fields!) - ..files.add(http.MultipartFile.fromBytes('file', bytes)) - ..followRedirects = false; - final uploadRs = await request.send(); - if (uploadRs.statusCode != 303) { - // NOTE: There are tests that fail with this on CI. - // TODO: figure out what is causing these issues. - final body = await uploadRs.stream.bytesToString(); - final headers = uploadRs.headers; - throw AssertionError( - 'Expected HTTP redirect, got ${uploadRs.statusCode}.' - '\nbody: $body\nheaders: $headers', - ); - } - - final callbackUri = Uri.parse( - uploadInfo.fields!['success_action_redirect']!, - ); - return callbackUri.queryParameters['upload_id']!; + return await _uploadArchive(await getPackageUploadUrl(), bytes); } @visibleForTesting @@ -205,29 +183,41 @@ extension PubApiClientExt on PubApiClient { List bytes, { List? attestationBytes, }) async { - final uploadId = await preparePackageUpload(bytes); + final uploadInfo = await getPackageUploadUrl(); if (attestationBytes != null) { - List bodyBytes; - try { - final decoded = jsonDecode(utf8.decode(attestationBytes)); - if (decoded is Map && - decoded.containsKey('attestation')) { - bodyBytes = attestationBytes; - } else { - bodyBytes = utf8.encode(jsonEncode({'attestation': decoded})); - } - } catch (_) { - bodyBytes = attestationBytes; - } - final rsJson = await client.sendRaw( + // The attestation is uploaded before the archive. + await client.sendRaw( verb: 'post', - path: '/api/packages/versions/newUploadFinish/$uploadId', + path: Uri.parse(uploadInfo.attestationUrl!).path, headers: {'content-type': 'application/json; charset="utf-8"'}, - bodyBytes: bodyBytes, + bodyBytes: attestationBytes, ); - return SuccessMessage.fromJson(rsJson); - } else { - return await finishPackageUpload(uploadId); } + final uploadId = await _uploadArchive(uploadInfo, bytes); + return await finishPackageUpload(uploadId); } } + +/// Uploads the package archive [bytes] with the parameters from [uploadInfo], +/// and returns the upload id to finish the upload with. +Future _uploadArchive(UploadInfo uploadInfo, List bytes) async { + final request = http.MultipartRequest('POST', Uri.parse(uploadInfo.url)) + ..headers[fakeClockHeaderName] = clock.now().toIso8601String() + ..fields.addAll(uploadInfo.fields!) + ..files.add(http.MultipartFile.fromBytes('file', bytes)) + ..followRedirects = false; + final uploadRs = await request.send(); + if (uploadRs.statusCode != 303) { + // NOTE: There are tests that fail with this on CI. + // TODO: figure out what is causing these issues. + final body = await uploadRs.stream.bytesToString(); + final headers = uploadRs.headers; + throw AssertionError( + 'Expected HTTP redirect, got ${uploadRs.statusCode}.' + '\nbody: $body\nheaders: $headers', + ); + } + + final callbackUri = Uri.parse(uploadInfo.fields!['success_action_redirect']!); + return callbackUri.queryParameters['upload_id']!; +} diff --git a/app/test/package/upload_test.dart b/app/test/package/upload_test.dart index 4dcd74f436..4b6af54e6d 100644 --- a/app/test/package/upload_test.dart +++ b/app/test/package/upload_test.dart @@ -41,6 +41,7 @@ void main() { fn: () async { final rs = packageBackend.startUpload( Uri.parse('http://example.com/'), + attestationUrlPrefix: Uri.parse('http://example.com/attestation'), ); await expectLater(rs, throwsA(isA())); }, @@ -50,14 +51,26 @@ void main() { 'successful', fn: () async { final redirectUri = Uri.parse('http://blobstore.com/upload'); + final attestationUrlPrefix = Uri.parse( + 'http://blobstore.com/attestation', + ); await accountBackend.withBearerToken(userClientToken, () async { - final info = await packageBackend.startUpload(redirectUri); + final info = await packageBackend.startUpload( + redirectUri, + attestationUrlPrefix: attestationUrlPrefix, + ); expect(info.url, startsWith('http://localhost:')); expect(info.url, contains('/fake-incoming-packages/tmp/')); expect(info.fields, { 'key': startsWith('fake-incoming-packages/tmp/'), 'success_action_redirect': startsWith('$redirectUri?upload_id='), }); + // The attestation is uploaded with the same upload id as the + // archive. + final uploadId = Uri.parse( + info.fields!['success_action_redirect']!, + ).queryParameters['upload_id']; + expect(info.attestationUrl, '$attestationUrlPrefix/$uploadId'); }); }, ); @@ -1773,5 +1786,47 @@ void main() { ); }, ); + testWithProfile( + 'attestation upload fails with an invalid upload id', + fn: () async { + final rs = createPubApiClient(authToken: adminClientToken).client + .sendRaw( + verb: 'post', + path: '/api/packages/versions/newUploadAttestation/not-a-uuid', + headers: {'content-type': 'application/json; charset="utf-8"'}, + bodyBytes: utf8.encode('{}'), + ); + await expectApiException( + rs, + status: 400, + code: 'InvalidInput', + message: 'Invalid upload id.', + ); + }, + ); + + testWithProfile( + 'upload fails when attestation bundle is too large', + fn: () async { + final pubspecContent = + 'name: large_attested_pkg\nversion: 1.0.0\ndescription: A package with a large attestation.\nenvironment:\n sdk: ">=2.12.0 <4.0.0"\n'; + final archiveBytes = await packageArchiveBytes( + pubspecContent: pubspecContent, + ); + final attestationBytes = utf8.encode( + jsonEncode({'payload': 'x' * maxAttestationContentLength}), + ); + + final rs = createPubApiClient( + authToken: adminClientToken, + ).uploadPackageBytes(archiveBytes, attestationBytes: attestationBytes); + await expectApiException( + rs, + status: 400, + code: 'PackageRejected', + message: 'Attestation bundle is too large', + ); + }, + ); }); } diff --git a/pkg/_pub_shared/lib/data/package_api.dart b/pkg/_pub_shared/lib/data/package_api.dart index 1d40bcb372..f345180986 100644 --- a/pkg/_pub_shared/lib/data/package_api.dart +++ b/pkg/_pub_shared/lib/data/package_api.dart @@ -18,7 +18,17 @@ class UploadInfo { /// The fields the uploader should add to the multipart upload. final Map? fields; - UploadInfo({required this.url, required this.fields}); + /// The endpoint where an attestation of the uploaded package may be posted. + /// + /// The attestation is uploaded with a `POST` of the JSON bundle to + /// [attestationUrl], before the package archive is uploaded to [url]. + /// + /// If this is `null`, the repository does not support publishing with + /// attestations. + @JsonKey(includeIfNull: false) + final String? attestationUrl; + + UploadInfo({required this.url, required this.fields, this.attestationUrl}); factory UploadInfo.fromJson(Map json) => _$UploadInfoFromJson(json); diff --git a/pkg/_pub_shared/lib/data/package_api.g.dart b/pkg/_pub_shared/lib/data/package_api.g.dart index 8d18301bbe..7a075bc4b9 100644 --- a/pkg/_pub_shared/lib/data/package_api.g.dart +++ b/pkg/_pub_shared/lib/data/package_api.g.dart @@ -11,10 +11,15 @@ UploadInfo _$UploadInfoFromJson(Map json) => UploadInfo( fields: (json['fields'] as Map?)?.map( (k, e) => MapEntry(k, e as String), ), + attestationUrl: json['attestationUrl'] as String?, ); Map _$UploadInfoToJson(UploadInfo instance) => - {'url': instance.url, 'fields': instance.fields}; + { + 'url': instance.url, + 'fields': instance.fields, + 'attestationUrl': ?instance.attestationUrl, + }; PkgOptions _$PkgOptionsFromJson(Map json) => PkgOptions( isDiscontinued: json['isDiscontinued'] as bool?, diff --git a/pkg/_pub_shared/lib/src/pubapi.client.dart b/pkg/_pub_shared/lib/src/pubapi.client.dart index 46923e9882..a487e4a9b0 100644 --- a/pkg/_pub_shared/lib/src/pubapi.client.dart +++ b/pkg/_pub_shared/lib/src/pubapi.client.dart @@ -87,6 +87,15 @@ class PubApiClient { ); } + Future<_i3.SuccessMessage> uploadPackageAttestation(String uploadId) async { + return _i3.SuccessMessage.fromJson( + await _client.requestJson( + verb: 'post', + path: '/api/packages/versions/newUploadAttestation/$uploadId', + ), + ); + } + Future<_i3.SuccessMessage> packageUploadCallback() async { return _i3.SuccessMessage.fromJson( await _client.requestJson(