Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions pkg/pub_integration/lib/src/pub_http_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,23 @@ class PubHttpClient {
}
}

/// Get the Sigstore attestation bundle for a specific package version,
/// or null if it does not exist.
Future<String?> 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<String?> getLatestVersionPage(String package, {String? tab}) async {
Expand Down Expand Up @@ -295,6 +312,70 @@ class PubHttpClient {
}
}

/// Uploads a package tarball and optional attestation bundle to the server.
Future<Response> uploadPackage({
required String authToken,
required List<int> packageBytes,
List<int>? 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<String, dynamic>;
final uploadUrl = Uri.parse(initMap['url'] as String);
final fields = (initMap['fields'] as Map<String, dynamic>)
.cast<String, String>();

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<void> close() async {
_http.close();
Expand Down
17 changes: 17 additions & 0 deletions pkg/pub_integration/lib/src/pub_tool_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -176,4 +176,21 @@ class DartToolClient {
expectedOutputContains: expectedOutputContains,
);
}

Future<void> 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,
);
}
}
4 changes: 3 additions & 1 deletion pkg/pub_integration/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
245 changes: 245 additions & 0 deletions pkg/pub_integration/test/attestation_integration_test.dart
Original file line number Diff line number Diff line change
@@ -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<List<int>> _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<TarEntry>().transform(tarWriter).transform(gzip.encoder);
return await stream.expand((chunk) => chunk).toList();
}

Map<String, dynamic> _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 = <int>[
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/<pkg>/versions/<ver>/attestation
final retrievedContent = await pubHttpClient.getAttestation(
pkgName,
version,
);
expect(retrievedContent, isNotNull);
final retrievedJson =
jsonDecode(retrievedContent!) as Map<String, dynamic>;
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));
}
Loading