blob: b7587e73d5ff269b0e5db1a6d3c6cdffba2e7f8a [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:path/path.dart' as p;
import 'package:pub/src/crc32c.dart';
import 'package:pub/src/source/hosted.dart';
import 'package:pub/src/third_party/tar/tar.dart';
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 recieved up till now.
final List<String> requestedPaths = <String>[];
/// 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) {
_inner.mount((request) {
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}');
});
}
static final _versionInfoPattern = RegExp(r'/api/packages/([a-zA-Z_0-9]*)');
static final _downloadPattern =
RegExp(r'/packages/([^/]*)/versions/([^/]*).tar.gz');
static Future<PackageServer> start() async {
final server =
PackageServer._(await shelf_io.IOServer.bind('localhost', 0));
server.handle(
_versionInfoPattern,
(shelf.Request request) {
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': package.versions.values
.map((version) => packageVersionApiMap(
server._inner.url.toString(),
version.pubspec,
retracted: version.isRetracted,
))
.toList(),
if (package.isDiscontinued) 'isDiscontinued': true,
if (package.discontinuedReplacementText != null)
'replacedBy': package.discontinuedReplacementText,
}),
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');
/// 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,
Map<String, List<String>>? headers}) {
var pubspecFields = <String, dynamic>{'name': name, 'version': version};
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: () {
final entries = <TarEntry>[];
void addDescriptor(d.Descriptor descriptor, String path) {
if (descriptor is d.DirectoryDescriptor) {
for (final e in descriptor.contents) {
addDescriptor(e, p.posix.join(path, descriptor.name));
}
} else {
entries.add(
TarEntry(
TarHeader(
// Ensure paths in tar files use forward slashes
name: p.posix.join(path, descriptor.name),
// We want to keep executable bits, but otherwise use the default
// file mode
mode: 420,
// size: 100,
modified: DateTime.now(),
userName: 'pub',
groupName: 'pub',
),
(descriptor as d.FileDescriptor).readAsBytes(),
),
);
}
}
for (final e in contents ?? <d.Descriptor>[]) {
addDescriptor(e, '');
}
return Stream.fromIterable(entries)
.transform(tarWriterWith(format: OutputFormat.gnuLongName))
.transform(gzip.encoder);
},
);
}
// Mark a package discontinued.
void discontinue(String name,
{bool isDiscontinued = true, String? replacementText}) {
_packages[name]!
..isDiscontinued = isDiscontinued
..discontinuedReplacementText = replacementText;
}
/// Clears all existing packages from this builder.
void clearPackages() {
_packages.clear();
}
void retractPackageVersion(String name, String version) {
_packages[name]!.versions[version]!.isRetracted = true;
}
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'}) {
List<String> header = [];
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;
}
/// 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;
Version get version => Version.parse(pubspec['version']);
_ServedPackageVersion(this.pubspec, {required this.contents, this.headers});
Future<int> computeArchiveCrc32c() async {
return await Crc32c.computeByConsumingStream(contents());
}
}
class _PatternAndHandler {
Pattern pattern;
shelf.Handler handler;
_PatternAndHandler(this.pattern, this.handler);
}