blob: dba4b86a971d4b705145434f5c0b82cd71a538c7 [file] [log] [blame]
// 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:async';
import 'dart:collection';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:json_rpc_2/json_rpc_2.dart' as json_rpc;
import 'package:logging/logging.dart';
import 'package:shelf/shelf.dart';
import 'package:vm_service/vm_service.dart' hide Response;
import 'dart_runtime_service.dart';
import 'dart_runtime_service_rpcs.dart';
import 'rpc_exceptions.dart';
/// A [DevelopmentFileSystem] rooted at [rootUri], providing restricted file
/// system access to clients.
abstract base class DevelopmentFileSystem {
DevelopmentFileSystem({required this.name, required this.rootUri});
final String name;
final Uri rootUri;
static Never throwInvalidUriParameter({
required String method,
required Object? uri,
}) => RpcException.invalidParams.throwExceptionWithDetails(
details: "$method: invalid 'uri' parameter: $uri",
);
static Never throwMissingUriParameter({required String method}) =>
RpcException.invalidParams.throwExceptionWithDetails(
details: "$method: expects the 'uri' parameter",
);
/// Reads the contents of the file at [uri].
///
/// If the file does not exist, a [RpcException.fileDoesNotExist] exception
/// is thrown.
Future<RpcResponse> readFile({required String uri});
/// Writes [bytes] to [uri].
Future<void> writeFile({required String uri, required List<int> bytes});
/// Writes a stream of [bytes] to [uri].
Future<void> writeStreamFile({
required String uri,
required Stream<List<int>> bytes,
});
/// Lists all files contained in the [DevelopmentFileSystem].
///
/// Each file is reported with its size in bytes and last modified timestamp
/// in milliseconds since epoch.
Future<RpcResponse> listFiles();
/// Resolves the [uri] against the [rootUri] of the [DevelopmentFileSystem].
///
/// [uri] must be a valid file URI with an optional leading `/`. If the
/// resolved URI is not within the file systems [rootUri], an
/// [RpcException.invalidParams] exception is thrown.
Uri resolve({required String method, required String uri}) {
// The leading '/' is optional but must be removed before resolving the
// URI, otherwise it will be treated as the file system root.
if (uri.startsWith('/')) {
uri = uri.substring(1);
}
final parsedUri = Uri.tryParse(uri);
if (parsedUri == null) {
throwInvalidUriParameter(method: method, uri: uri);
}
try {
// Make sure that this pathUri can be converted to a file path.
parsedUri.toFilePath();
// ignore: avoid_catching_errors
} on UnsupportedError {
throwInvalidUriParameter(method: method, uri: uri);
}
final resolvedUri = rootUri.resolveUri(parsedUri);
if (!resolvedUri.toString().startsWith(rootUri.toString())) {
// Resolved uri must be within the filesystem's base uri.
throwInvalidUriParameter(method: method, uri: uri);
}
return resolvedUri;
}
Map<String, String> toJson() => {
'type': 'FileSystem',
'name': name,
'uri': rootUri.toString(),
};
}
/// A collection of [DevelopmentFileSystem]s.
abstract base class DevelopmentFileSystemCollection {
List<String> get fsNames;
/// Destroys all [DevelopmentFileSystem]s in the collection.
Future<void> cleanup();
/// Creates a new [DevelopmentFileSystem] named [name].
///
/// Throws a [RpcException.fileSystemAlreadyExists] if the file system has
/// already been created.
Future<DevelopmentFileSystem> createFileSystem({required String name});
/// Destroys the [DevelopmentFileSystem] with name [name].
///
/// Throws a [RpcException.fileSystemDoesNotExist] if the file system does
/// not exist.
Future<void> deleteFileSystem({required String name});
/// Retrieves an existing [DevelopmentFileSystem] based on a JSON-RPC
/// request.
///
/// Throws a [RpcException.fileSystemDoesNotExist] if the file system does
/// not exist.
DevelopmentFileSystem getFileSystem({required String name});
}
/// A development file system used by service clients to upload compilation
/// artifacts and assets for use by the runtime.
class DevFS<DevFSBackend extends DevelopmentFileSystemCollection> {
DevFS({required this._fileSystems});
final DevFSBackend _fileSystems;
final _logger = Logger('$DevFS');
static const _kFsName = 'fsName';
static const _kUri = 'uri';
static const _kFiles = 'files';
static const _kFileContents = 'fileContents';
late final rpcs = UnmodifiableListView<ServiceRpcHandler>([
('_listDevFS', listDevFS),
('_createDevFS', createDevFS),
('_deleteDevFS', deleteDevFS),
('_readDevFSFile', readDevFSFile),
('_writeDevFSFile', writeDevFSFile),
('_writeDevFSFiles', writeDevFSFiles),
('_listDevFSFiles', listDevFSFiles),
]);
// Destroy the development file systems.
Future<void> cleanup() => _fileSystems.cleanup();
/// Responsible for processing file system writes initiated via an HTTP PUT
/// request.
///
/// In order to write a file, the HTTP PUT request must include the following
/// query parameters:
/// - `dev_fs_name`: the name of the [DevelopmentFileSystem] to write to.
/// - `dev_fs_uri_b64`: the base-64 encoded URI for the file to be written.
///
/// The request body will be treated as the contents of the file and written
/// to the provided URI rooted in the [DevelopmentFileSystem].
Future<Response?> handlePutStreamRequest(Request request) async {
if (request.method != 'PUT') {
return null;
}
_logger.info('Handling DevFS PUT request: ${request.headers}');
String? fsUri;
const kDevFsName = 'dev_fs_name';
const kDevFsUriBase64 = 'dev_fs_uri_b64';
// Extract the fs name and fs path from the request headers.
final fsName = request.headers[kDevFsName];
if (fsName == null) {
_logger.info('Invalid $kDevFsName. Returning.');
// TODO(bkonyi): this is wrong
return Response.internalServerError(body: 'Invalid $kDevFsName.');
}
if (request.headers[kDevFsUriBase64] case final String base64Uri) {
fsUri = utf8.decode(base64.decode(base64Uri));
}
if (fsUri == null) {
DevelopmentFileSystem.throwMissingUriParameter(method: '_writeDevFSFile');
}
_logger.info('Invoking handlePutStream.');
final result = await _handlePutStream(
fsName: fsName,
uri: fsUri,
bytes: request.read().cast<List<int>>().transform(gzip.decoder),
);
_logger.info('handlePutStream response: $result');
return Response.ok(
json.encode({'result': result}),
headers: {
// We closed the connection for bad origins earlier.
'Access-Control-Allow-Origin': '*',
'content-type': ContentType.json.mimeType,
},
);
}
Future<RpcResponse> _handlePutStream({
required String fsName,
required String uri,
required Stream<List<int>> bytes,
}) async {
_logger.info('Handling PUT write to $uri in $fsName');
final fs = _fileSystems.getFileSystem(name: fsName);
await fs.writeStreamFile(uri: uri, bytes: bytes);
return Success().toJson();
}
/// Lists the names of all active [DevelopmentFileSystem]s.
RpcResponse listDevFS() =>
// TODO(bkonyi): create package:vm_service type if we make this public.
{'type': 'FileSystemList', 'fsNames': _fileSystems.fsNames};
/// Creates a new [DevelopmentFileSystem] with a given `fsName`.
///
/// If a [DevelopmentFileSystem] with `fsName` already exists, an error is
/// returned.
Future<RpcResponse> createDevFS(json_rpc.Parameters parameters) async {
final fs = await _fileSystems.createFileSystem(
name: parameters[_kFsName].asString,
);
return fs.toJson();
}
/// Deletes the [DevelopmentFileSystem] with name `fsName`.
///
/// If a [DevelopmentFileSystem] with `fsName` does not exist, an error is
/// returned.
Future<RpcResponse> deleteDevFS(json_rpc.Parameters parameters) async {
await _fileSystems.deleteFileSystem(name: parameters[_kFsName].asString);
return Success().toJson();
}
/// Reads a file from `uri` within the [DevelopmentFileSystem] `fsName`.
///
/// If a [DevelopmentFileSystem] with `fsName` does not exist, or `uri` is
/// does not point to a valid file, an error is returned.
Future<RpcResponse> readDevFSFile(json_rpc.Parameters parameters) async {
final fs = _fileSystems.getFileSystem(name: parameters[_kFsName].asString);
final uri = parameters[_kUri].asString;
return await fs.readFile(uri: uri);
}
/// Writes `fileContents` to `uri` within the [DevelopmentFileSystem]
/// `fsName`.
///
/// If a [DevelopmentFileSystem] with `fsName` does not exist, an error is
/// returned.
Future<RpcResponse> writeDevFSFile(json_rpc.Parameters parameters) async {
final fs = _fileSystems.getFileSystem(name: parameters[_kFsName].asString);
final path = parameters[_kUri].asString;
final fileContents = parameters[_kFileContents].asString;
final decodedFileContents = base64.decode(fileContents);
await fs.writeFile(uri: path, bytes: decodedFileContents);
return Success().toJson();
}
/// Writes multiple `files` within the [DevelopmentFileSystem] `fsName`.
///
/// Each entry in `files` is a list with two entries:
/// - The URI of the file to be written to.
/// - The contents of the file.
///
/// If a [DevelopmentFileSystem] with `fsName` does not exist, an error is
/// returned.
Future<RpcResponse> writeDevFSFiles(json_rpc.Parameters parameters) async {
final fs = _fileSystems.getFileSystem(name: parameters[_kFsName].asString);
final files = parameters[_kFiles].asList.cast<Object?>();
final processed = <(String, Uint8List)>[];
Never throwInvalidFiles({required int index, required Object? fileInfo}) =>
RpcException.invalidParams.throwExceptionWithDetails(
details:
"_writeDevFSFiles: invalid '$_kFiles' parameter at index $index: "
'$fileInfo',
);
for (var i = 0; i < files.length; i++) {
final fileInfo = files[i];
if (fileInfo case [final String uriString, final String contents]) {
try {
fs.resolve(method: '_writeDevFSFiles', uri: uriString);
processed.add((uriString, base64.decode(contents)));
} catch (_) {
throwInvalidFiles(index: i, fileInfo: fileInfo);
}
} else {
throwInvalidFiles(index: i, fileInfo: fileInfo);
}
}
final pendingWrites = <Future<void>>[];
for (final (path, decodedContents) in processed) {
pendingWrites.add(fs.writeFile(uri: path, bytes: decodedContents));
}
await Future.wait(pendingWrites);
return Success().toJson();
}
/// Lists the set of files contained within the [DevelopmentFileSystem],
/// `fsName`.
///
/// If a [DevelopmentFileSystem] with `fsName` does not exist, an error is
/// returned.
Future<RpcResponse> listDevFSFiles(json_rpc.Parameters parameters) async {
final fs = _fileSystems.getFileSystem(name: parameters[_kFsName].asString);
return await fs.listFiles();
}
}