blob: 1b142b2b8e5962488e943265451fced28f56572d [file] [log] [blame]
// Copyright (c) 2016, 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:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:path/path.dart' as p;
import 'package:pub/src/crc32c.dart';
import 'package:pub/src/source/hosted.dart';
import 'package:pub/src/utils.dart' show hexEncode;
import 'package:pub_semver/pub_semver.dart';
import 'package:shelf/shelf.dart' as shelf;
import 'package:shelf/shelf_io.dart' as shelf_io;
import 'package:test/test.dart';
import 'package:test/test.dart' as test show expect;
import 'descriptor.dart' as d;
import 'test_pub.dart';
class PackageServer {
/// The inner [IOServer] that this uses to serve its descriptors.
final shelf_io.IOServer _inner;
/// Handlers of requests. Last matching handler will be used.
final List<_PatternAndHandler> _handlers = [];
// A list of all the requests received up till now.
final List<String> requestedPaths = <String>[];
// Setting this to false will disable automatic calculation of content-hashes.
bool serveContentHashes = true;
/// Whether the [IOServer] should compress the content, if possible.
/// The default value is `false` (compression disabled).
/// See [HttpServer.autoCompress] for details.
bool get autoCompress => _inner.server.autoCompress;
set autoCompress(bool shouldAutoCompress) =>
_inner.server.autoCompress = shouldAutoCompress;
// Setting this to false will disable automatic calculation of checksums.
bool serveChecksums = true;
PackageServer._(this._inner) {
final outerZone = Zone.current;
_inner.mount((request) {
try {
final path = request.url.path;
requestedPaths.add(path);
final pathWithInitialSlash = '/$path';
for (final entry in _handlers.reversed) {
final match = entry.pattern.matchAsPrefix(pathWithInitialSlash);
if (match != null && match.end == pathWithInitialSlash.length) {
final a = entry.handler(request);
return a;
}
}
return shelf.Response.notFound('Could not find ${request.url}');
} catch (e, st) {
// Because shelf swallows all errors we catch here and redirect to the
// zone error handler.
outerZone.handleUncaughtError(e, st);
_inner.close();
rethrow;
}
});
}
static final _versionInfoPattern = RegExp(r'/api/packages/([a-zA-Z_0-9]*)');
static final _advisoriesPattern =
RegExp(r'/api/packages/([a-zA-Z_0-9]*)/advisories');
static final _downloadPattern =
RegExp(r'/packages/([^/]*)/versions/([^/]*).tar.gz');
static Future<PackageServer> start() async {
final server = PackageServer._(
await shelf_io.IOServer.bind(InternetAddress.loopbackIPv4, 0),
);
server.handle(
_versionInfoPattern,
(shelf.Request request) async {
final parts = request.url.pathSegments;
assert(parts[0] == 'api');
assert(parts[1] == 'packages');
final name = parts[2];
final package = server._packages[name];
if (package == null) {
return shelf.Response.notFound('No package named $name');
}
return shelf.Response.ok(
jsonEncode({
'name': name,
'uploaders': ['nweiz@google.com'],
'versions': [
for (final version in package.versions.values)
{
'pubspec': version.pubspec,
'version': version.version.toString(),
'archive_url':
'${server.url}/packages/$name/versions/${version.version}.tar.gz',
if (version.isRetracted) 'retracted': true,
if (version.sha256 != null || server.serveContentHashes)
'archive_sha256': version.sha256 ??
hexEncode(
(await sha256.bind(version.contents()).first).bytes,
),
},
],
if (package.isDiscontinued) 'isDiscontinued': true,
if (package.advisoriesUpdated != null)
'advisoriesUpdated': package.advisoriesUpdated!.toIso8601String(),
if (package.discontinuedReplacementText != null)
'replacedBy': package.discontinuedReplacementText,
}),
headers: {
HttpHeaders.contentTypeHeader: 'application/vnd.pub.v2+json',
},
);
},
);
server.handle(
_advisoriesPattern,
(shelf.Request request) async {
final parts = request.url.pathSegments;
assert(parts[0] == 'api');
assert(parts[1] == 'packages');
final name = parts[2];
assert(parts[3] == 'advisories');
final package = server._packages[name];
if (package == null) {
return shelf.Response.notFound('No package named $name');
}
return shelf.Response.ok(
jsonEncode({
'advisoriesUpdated': defaultAdvisoriesUpdated.toIso8601String(),
'advisories': [
for (final advisory in package.advisories.values)
{
'id': advisory.id,
'summary': 'Example',
'aliases': [...advisory.aliases],
'details': 'This is a dummy example.',
'modified': defaultAdvisoriesUpdated.toIso8601String(),
'published': defaultAdvisoriesUpdated.toIso8601String(),
'affected': [
for (final package in advisory.affectedPackages)
{
'package': {
'name': package.name,
'ecosystem': package.ecosystem,
},
'versions': [...package.versions],
},
],
if (advisory.displayUrl != null)
'database_specific': {
'pub_display_url': advisory.displayUrl,
},
},
],
}),
headers: {
HttpHeaders.contentTypeHeader: 'application/vnd.pub.v2+json',
},
);
},
);
server.handle(
_downloadPattern,
(shelf.Request request) async {
final parts = request.url.pathSegments;
assert(parts[0] == 'packages');
final name = parts[1];
assert(parts[2] == 'versions');
final package = server._packages[name];
if (package == null) {
return shelf.Response.notFound('No package $name');
}
final version = Version.parse(
parts[3].substring(0, parts[3].length - '.tar.gz'.length),
);
assert(parts[3].endsWith('.tar.gz'));
for (final packageVersion in package.versions.values) {
if (packageVersion.version == version) {
final headers = packageVersion.headers ?? {};
headers[HttpHeaders.contentTypeHeader] ??= [
'application/octet-stream',
];
// This gate enables tests to validate the CRC32C parser by
// passing in arbitrary values for the checksum header.
if (server.serveChecksums &&
!headers.containsKey(checksumHeaderName)) {
headers[checksumHeaderName] = composeChecksumHeader(
crc32c: await packageVersion.computeArchiveCrc32c(),
);
}
return shelf.Response.ok(
packageVersion.contents(),
headers: headers,
);
}
}
return shelf.Response.notFound('No version $version of $name');
},
);
return server;
}
Future<void> close() async {
await _inner.close();
}
/// The port used for the server.
int get port => _inner.url.port;
/// The URL for the server.
String get url => _inner.url.toString();
/// From now on report errors on any request.
void serveErrors() => _handlers
..clear()
..add(
_PatternAndHandler(
RegExp('.*'),
(request) {
fail('The HTTP server received an unexpected request:\n'
'${request.method} ${request.requestedUri}');
},
),
);
void handle(Pattern pattern, shelf.Handler handler) {
_handlers.add(
_PatternAndHandler(
pattern,
handler,
),
);
}
// Installs a handler at [pattern] that expects to be called exactly once with
// the given [method].
//
// The handler is installed as the start to give it priority over more general
// handlers.
void expect(String method, Pattern pattern, shelf.Handler handler) {
handle(
pattern,
expectAsync1(
(request) {
test.expect(request.method, method);
return handler(request);
},
),
);
}
/// Returns the path of [package] at [version], installed from this server, in
/// the pub cache.
String pathInCache(String package, String version) =>
p.join(cachingPath, '$package-$version');
/// The location where pub will store the cache for this server.
String get cachingPath =>
p.join(d.sandbox, cachePath, 'hosted', 'localhost%58$port');
String get hashesCachingPath =>
p.join(d.sandbox, cachePath, 'hosted-hashes', 'localhost%58$port');
/// A map from package names to the concrete packages to serve.
final _packages = <String, _ServedPackage>{};
/// Specifies that a package named [name] with [version] should be served.
///
/// If [deps] is passed, it's used as the "dependencies" field of the pubspec.
/// If [pubspec] is passed, it's used as the rest of the pubspec.
///
/// If [contents] is passed, it's used as the contents of the package. By
/// default, a package just contains a dummy lib directory.
void serve(
String name,
String version, {
Map<String, dynamic>? deps,
Map<String, dynamic>? pubspec,
List<d.Descriptor>? contents,
String? sdk,
Map<String, List<String>>? headers,
}) {
var pubspecFields = <String, dynamic>{
'name': name,
'version': version,
'environment': {'sdk': sdk ?? '^3.0.0'},
};
if (pubspec != null) pubspecFields.addAll(pubspec);
if (deps != null) pubspecFields['dependencies'] = deps;
contents ??= [d.libDir(name, '$name $version')];
contents = [d.file('pubspec.yaml', yaml(pubspecFields)), ...contents];
var package = _packages.putIfAbsent(name, _ServedPackage.new);
package.versions[version] = _ServedPackageVersion(
pubspecFields,
headers: headers,
contents: () => tarFromDescriptors(contents ?? []),
);
}
// Mark a package discontinued.
void discontinue(
String name, {
bool isDiscontinued = true,
String? replacementText,
}) {
_packages[name]!
..isDiscontinued = isDiscontinued
..discontinuedReplacementText = replacementText;
}
static final defaultAdvisoriesUpdated =
DateTime.fromMicrosecondsSinceEpoch(0);
/// Add a security advisory which affects [affectedVersions] versions of
/// package [packageName].
void addAdvisory({
required String advisoryId,
String? displayUrl,
DateTime? advisoriesUpdated,
List<String> aliases = const <String>[],
required List<AffectedPackage> affectedPackages,
}) {
for (final package in affectedPackages) {
_packages[package.name]!.advisoriesUpdated =
advisoriesUpdated ?? defaultAdvisoriesUpdated;
_packages[package.name]!.advisories.putIfAbsent(
advisoryId,
() => _ServedAdvisory(
advisoryId,
affectedPackages,
aliases,
displayUrl,
),
);
}
}
/// Clears all existing packages from this builder.
void clearPackages() {
_packages.clear();
}
void retractPackageVersion(String name, String version) {
_packages[name]!.versions[version]!.isRetracted = true;
}
/// Useful for testing handling of a wrong hash.
void overrideArchiveSha256(String name, String version, String sha256) {
_packages[name]!.versions[version]!.sha256 = sha256;
}
Future<String> peekArchiveSha256(String name, String version) async {
final v = _packages[name]!.versions[version]!;
return v.sha256 ?? hexEncode((await sha256.bind(v.contents()).first).bytes);
}
Future<String?> peekArchiveChecksumHeader(String name, String version) async {
final v = _packages[name]!.versions[version]!;
// If the test configured an overriding header value.
var checksumHeader = v.headers?[checksumHeaderName];
// Otherwise, compute from package contents.
if (serveChecksums) {
checksumHeader ??=
composeChecksumHeader(crc32c: await v.computeArchiveCrc32c());
}
return checksumHeader?.join(',');
}
static List<String> composeChecksumHeader({
int? crc32c,
String? md5 = '5f4dcc3b5aa765d61d8327deb882cf99',
}) {
final header = <String>[];
if (crc32c != null) {
final bytes = Uint8List(4)..buffer.asByteData().setUint32(0, crc32c);
header.add('crc32c=${base64.encode(bytes)}');
}
if (md5 != null) {
header.add('md5=${base64.encode(utf8.encode(md5))}');
}
return header;
}
}
class _ServedPackage {
final versions = <String, _ServedPackageVersion>{};
bool isDiscontinued = false;
String? discontinuedReplacementText;
DateTime? advisoriesUpdated;
final advisories = <String, _ServedAdvisory>{};
}
/// A package that's intended to be served.
class _ServedPackageVersion {
final Map pubspec;
final Stream<List<int>> Function() contents;
final Map<String, List<String>>? headers;
bool isRetracted = false;
// Overrides the calculated sha256.
String? sha256;
Version get version => Version.parse(pubspec['version'] as String);
_ServedPackageVersion(this.pubspec, {required this.contents, this.headers});
Future<int> computeArchiveCrc32c() async {
return await Crc32c.computeByConsumingStream(contents());
}
}
class _ServedAdvisory {
String id;
List<String> aliases;
String? displayUrl;
List<AffectedPackage> affectedPackages;
_ServedAdvisory(
this.id,
this.affectedPackages,
this.aliases,
this.displayUrl,
);
}
class AffectedPackage {
String name;
String ecosystem;
List<String> versions;
AffectedPackage({
required this.name,
this.ecosystem = 'Pub',
required this.versions,
});
}
class _PatternAndHandler {
Pattern pattern;
shelf.Handler handler;
_PatternAndHandler(this.pattern, this.handler);
}