From 086cb1a441dff42e9267ad65a58843f35e546e61 Mon Sep 17 00:00:00 2001 From: Ben Konyi Date: Fri, 20 Mar 2026 15:23:38 -0700 Subject: [PATCH] [ Service ] Add HTTP and DevFS support to package:dart_runtime_service This change adds support for invoking RPCs via HTTP requests, allowing for interacting with the service without needing to establish a web socket connection. This change also adds support for the development file system, otherwise known as DevFS. DevFS is a (currently) undocumented feature provided by the VM service that gives clients limited file system access within a directory contained in the system's temp directory. This is currently used by Flutter to push kernel files to the device when performing a hot reload. The DevFS implementation for package:dart_runtime_service_vm removes the long deprecated support for `path` parameters, leaving `uri`s as the only supported format for specifying file system types. Existing DevFS tests have been updated to replace `path` with `uri` in preparation for dart_runtime_service_vm becoming the new default VM service. This change brings the package:vm_service test suite pass rate to ~95%. Change-Id: Ib7d95db5788c37408d3dec79926ca7206660a43f Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/488280 Reviewed-by: Jessy Yameogo Reviewed-by: Nicholas Shahan Commit-Queue: Ben Konyi --- .../lib/dart_runtime_service.dart | 1 + .../lib/src/dart_runtime_service.dart | 12 +- .../lib/src/dart_runtime_service_backend.dart | 13 + .../lib/src/dart_runtime_service_options.dart | 8 + pkg/dart_runtime_service/lib/src/devfs.dart | 316 ++++++++++++++++++ .../lib/src/handlers.dart | 106 ++++++ .../lib/src/rpc_exceptions.dart | 11 +- .../bin/vm_service_entrypoint.dart | 1 + .../lib/dart_runtime_service_vm.dart | 8 +- .../lib/src/dart_runtime_service_vm_rpcs.dart | 1 - .../lib/src/vm_dev_fs.dart | 246 ++++++++++++++ pkg/dart_runtime_service_vm/pubspec.yaml | 1 + .../private_rpcs/dev_fs_http_put_test.dart | 20 +- .../dev_fs_http_put_weird_char_test.dart | 142 -------- .../test/private_rpcs/dev_fs_test.dart | 31 +- .../test/private_rpcs/dev_fs_uri_test.dart | 8 +- .../private_rpcs/dev_fs_weird_char_test.dart | 93 ------ 17 files changed, 748 insertions(+), 270 deletions(-) create mode 100644 pkg/dart_runtime_service/lib/src/devfs.dart create mode 100644 pkg/dart_runtime_service_vm/lib/src/vm_dev_fs.dart delete mode 100644 pkg/vm_service/test/private_rpcs/dev_fs_http_put_weird_char_test.dart delete mode 100644 pkg/vm_service/test/private_rpcs/dev_fs_weird_char_test.dart diff --git a/pkg/dart_runtime_service/lib/dart_runtime_service.dart b/pkg/dart_runtime_service/lib/dart_runtime_service.dart index 03064b127d2..9cd7815a2ae 100644 --- a/pkg/dart_runtime_service/lib/dart_runtime_service.dart +++ b/pkg/dart_runtime_service/lib/dart_runtime_service.dart @@ -11,6 +11,7 @@ export 'src/dart_runtime_service_rpcs.dart' RpcHandlerWithNoParameters, RpcHandlerWithParameters, ServiceRpcHandler; +export 'src/devfs.dart'; export 'src/event_streams.dart'; export 'src/exceptions.dart'; export 'src/expression_evaluator.dart'; diff --git a/pkg/dart_runtime_service/lib/src/dart_runtime_service.dart b/pkg/dart_runtime_service/lib/src/dart_runtime_service.dart index a59fd7d8773..cdc4abfdfac 100644 --- a/pkg/dart_runtime_service/lib/src/dart_runtime_service.dart +++ b/pkg/dart_runtime_service/lib/src/dart_runtime_service.dart @@ -177,11 +177,11 @@ class DartRuntimeService { /// Creates an artificial client to process JSON-RPC requests from /// non-standard sources (e.g., from native code). - void addArtificialClient({ + Client addArtificialClient({ required StreamChannel connection, required String name, }) { - clientManager.addClient(connection: connection, name: name); + return clientManager.addClient(connection: connection, name: name); } Future _startServer() async { @@ -272,6 +272,11 @@ class DartRuntimeService { ); } + if (!config.disableOriginCheck) { + _logger.info('Origin checks are enabled. Adding CORS check handler.'); + pipeline = pipeline.addMiddleware(originCheckMiddleware(frontend: this)); + } + var handlerCascade = shelf.Cascade(); if (config.sseHandlerPath != null) { _logger.info( @@ -294,6 +299,9 @@ class DartRuntimeService { webSocketClientHandler(clientManager: clientManager), ); + _logger.info('HTTP requests are accepted. Adding HTTP request handler.'); + handlerCascade = handlerCascade.add(httpRequestHandler(frontend: this)); + _logger.info('Shelf handlers generated.'); return pipeline.addHandler(handlerCascade.handler); } diff --git a/pkg/dart_runtime_service/lib/src/dart_runtime_service_backend.dart b/pkg/dart_runtime_service/lib/src/dart_runtime_service_backend.dart index ec04c62d40a..acdf0d0330d 100644 --- a/pkg/dart_runtime_service/lib/src/dart_runtime_service_backend.dart +++ b/pkg/dart_runtime_service/lib/src/dart_runtime_service_backend.dart @@ -2,9 +2,11 @@ // 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 'package:meta/meta.dart'; +import 'package:shelf/shelf.dart'; import 'dart_runtime_service.dart'; import 'dart_runtime_service_rpcs.dart'; @@ -12,6 +14,8 @@ import 'event_streams.dart'; import 'expression_evaluator.dart'; import 'isolate_manager.dart'; +typedef OptionalHandler = FutureOr Function(Request); + /// A backend implementation of a service used to inject non-common /// functionality into a [DartRuntimeService]. abstract class DartRuntimeServiceBackend { @@ -84,4 +88,13 @@ abstract class DartRuntimeServiceBackend { /// any registered RPCs or service extensions provided by other clients. UnmodifiableListView get fallbacks => UnmodifiableListView(const []); + + /// A custom handler for handling HTTP requests. + /// + /// This handler is invoked before attempting to execute the HTTP request as + /// an RPC invocation or performing a redirection to a developer tool (e.g., + /// DevTools). Returning null from the handler indicates that the request was + /// not handled by the custom handler. + OptionalHandler get httpHandler => + (_) => null; } diff --git a/pkg/dart_runtime_service/lib/src/dart_runtime_service_options.dart b/pkg/dart_runtime_service/lib/src/dart_runtime_service_options.dart index 63255030b32..a90e78ff329 100644 --- a/pkg/dart_runtime_service/lib/src/dart_runtime_service_options.dart +++ b/pkg/dart_runtime_service/lib/src/dart_runtime_service_options.dart @@ -10,6 +10,7 @@ class DartRuntimeServiceOptions { this.enableLogging = false, this.port = 0, this.disableAuthCodes = false, + this.disableOriginCheck = false, this.sseHandlerPath, this.autoStart = true, }); @@ -28,6 +29,11 @@ class DartRuntimeServiceOptions { /// Defaults to false. final bool disableAuthCodes; + /// If true, CORS requests to the service will be accepted. + /// + /// Defaults to false. + final bool disableOriginCheck; + /// If non-null, allow for SSE connections to be established at /// [sseHandlerPath]. /// @@ -41,6 +47,7 @@ class DartRuntimeServiceOptions { bool? enableLogging, int? port, bool? disableAuthCodes, + bool? disableOriginCheck, String? sseHandlerPath, bool? autoStart, }) { @@ -48,6 +55,7 @@ class DartRuntimeServiceOptions { enableLogging: enableLogging ?? this.enableLogging, port: port ?? this.port, disableAuthCodes: disableAuthCodes ?? this.disableAuthCodes, + disableOriginCheck: disableOriginCheck ?? this.disableOriginCheck, sseHandlerPath: sseHandlerPath ?? this.sseHandlerPath, autoStart: autoStart ?? this.autoStart, ); diff --git a/pkg/dart_runtime_service/lib/src/devfs.dart b/pkg/dart_runtime_service/lib/src/devfs.dart new file mode 100644 index 00000000000..dba4b86a971 --- /dev/null +++ b/pkg/dart_runtime_service/lib/src/devfs.dart @@ -0,0 +1,316 @@ +// 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 readFile({required String uri}); + + /// Writes [bytes] to [uri]. + Future writeFile({required String uri, required List bytes}); + + /// Writes a stream of [bytes] to [uri]. + Future writeStreamFile({ + required String uri, + required Stream> 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 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 toJson() => { + 'type': 'FileSystem', + 'name': name, + 'uri': rootUri.toString(), + }; +} + +/// A collection of [DevelopmentFileSystem]s. +abstract base class DevelopmentFileSystemCollection { + List get fsNames; + + /// Destroys all [DevelopmentFileSystem]s in the collection. + Future cleanup(); + + /// Creates a new [DevelopmentFileSystem] named [name]. + /// + /// Throws a [RpcException.fileSystemAlreadyExists] if the file system has + /// already been created. + Future createFileSystem({required String name}); + + /// Destroys the [DevelopmentFileSystem] with name [name]. + /// + /// Throws a [RpcException.fileSystemDoesNotExist] if the file system does + /// not exist. + Future 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 { + 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([ + ('_listDevFS', listDevFS), + ('_createDevFS', createDevFS), + ('_deleteDevFS', deleteDevFS), + ('_readDevFSFile', readDevFSFile), + ('_writeDevFSFile', writeDevFSFile), + ('_writeDevFSFiles', writeDevFSFiles), + ('_listDevFSFiles', listDevFSFiles), + ]); + + // Destroy the development file systems. + Future 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 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>().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 _handlePutStream({ + required String fsName, + required String uri, + required Stream> 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 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 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 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 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 writeDevFSFiles(json_rpc.Parameters parameters) async { + final fs = _fileSystems.getFileSystem(name: parameters[_kFsName].asString); + final files = parameters[_kFiles].asList.cast(); + 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 = >[]; + 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 listDevFSFiles(json_rpc.Parameters parameters) async { + final fs = _fileSystems.getFileSystem(name: parameters[_kFsName].asString); + return await fs.listFiles(); + } +} diff --git a/pkg/dart_runtime_service/lib/src/handlers.dart b/pkg/dart_runtime_service/lib/src/handlers.dart index a3aae226053..84541cea7d5 100644 --- a/pkg/dart_runtime_service/lib/src/handlers.dart +++ b/pkg/dart_runtime_service/lib/src/handlers.dart @@ -2,13 +2,21 @@ // 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 'package:json_rpc_2/json_rpc_2.dart' as json_rpc; import 'package:logging/logging.dart'; import 'package:shelf/shelf.dart'; import 'package:shelf_web_socket/shelf_web_socket.dart'; import 'package:sse/server/sse_handler.dart'; +import 'package:stream_channel/stream_channel.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; import 'clients.dart'; +import 'dart_runtime_service.dart'; +import 'dart_runtime_service_backend.dart'; /// Creates [Middleware] responsible for logging the result of HTTP requests. /// @@ -54,6 +62,104 @@ Middleware authCodeVerificationMiddleware({required String authCode}) => return innerHandler(request.change(path: clientProvidedCode)); }; +Middleware originCheckMiddleware({required DartRuntimeService frontend}) => + (Handler innerHandler) => (Request request) { + // First check the web-socket specific origin. + var origins = request.headers['Sec-WebSocket-Origin']; + // Fall back to the general Origin field. + origins ??= request.headers['Origin']; + if (origins == null) { + // No origin sent. This is a non-browser client or a same-origin + // request. + return innerHandler(request); + } + + bool isAllowedOrigin(String origin) { + Uri uri; + try { + uri = Uri.parse(origin); + } catch (_) { + return false; + } + + // Explicitly add localhost and 127.0.0.1 on any port (necessary for + // adb port forwarding). + if ((uri.host == 'localhost') || + (uri.host == InternetAddress.loopbackIPv6.address) || + (uri.host == InternetAddress.loopbackIPv4.address)) { + return true; + } + + final serverUri = frontend.uri; + if (uri.port == serverUri.port && uri.host == serverUri.host) { + return true; + } + + return false; + } + + for (final origin in origins.split(',')) { + if (isAllowedOrigin(origin)) { + return innerHandler(request); + } + } + return Response.forbidden('forbidden origin'); + }; + +/// Creates a [Handler] responsible for processing HTTP requests. +/// +/// If [frontend] has a [DartRuntimeServiceBackend] with a +/// [DartRuntimeServiceBackend.httpHandler] override, the backend's handler +/// will be invoked first. Otherwise, the HTTP request is treated as a JSON-RPC +/// invocation. +Handler httpRequestHandler({required DartRuntimeService frontend}) => + (Request request) async { + final logger = Logger('HttpRequestHandler'); + final method = request.url.pathSegments.firstOrNull ?? ''; + final params = request.url.queryParameters; + logger.info('(${request.method}) ${request.url}'); + + try { + final backendResult = await frontend.backend.httpHandler(request); + if (backendResult != null) { + logger.info( + 'Returning backend provided result: ${backendResult.statusCode}', + ); + return backendResult; + } + + final httpClient = StreamChannelController(sync: true); + try { + frontend.addArtificialClient( + connection: httpClient.foreign, + name: 'HTTP request', + ); + + final jsonRpcClient = json_rpc.Client(httpClient.local); + unawaited(jsonRpcClient.listen()); + final result = await jsonRpcClient.sendRequest(method, params); + logger.info('HTTP result: $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, + }, + ); + } finally { + await Future.wait([ + httpClient.foreign.sink.close(), + httpClient.local.sink.close(), + ]); + } + } on json_rpc.RpcException catch (e) { + return Response.ok(json.encode(e.serialize(method))); + } catch (e) { + return Response.badRequest(body: e.toString()); + } + }; + /// Creates a [Handler] for incoming web socket connections. Handler webSocketClientHandler({required ClientManager clientManager}) => webSocketHandler((WebSocketChannel ws, _) { diff --git a/pkg/dart_runtime_service/lib/src/rpc_exceptions.dart b/pkg/dart_runtime_service/lib/src/rpc_exceptions.dart index b114a98fd48..0cfe632294c 100644 --- a/pkg/dart_runtime_service/lib/src/rpc_exceptions.dart +++ b/pkg/dart_runtime_service/lib/src/rpc_exceptions.dart @@ -8,6 +8,7 @@ import 'package:json_rpc_2/json_rpc_2.dart' as json_rpc; enum RpcException { // These error codes must be kept in sync with those in vm/json_stream.h and // vmservice.dart. + invalidParams(code: INVALID_PARAMS, message: 'Invalid parameter.'), serverError(code: SERVER_ERROR, message: 'Server error.'), methodNotFound(code: METHOD_NOT_FOUND, message: 'Method not found.'), internalError(code: INTERNAL_ERROR, message: 'Internal error.'), @@ -20,13 +21,21 @@ enum RpcException { expressionCompilationError( code: 113, message: 'Expression compilation error.', - ); + ), + fileSystemAlreadyExists(code: 1001, message: 'File system already exists.'), + fileSystemDoesNotExist(code: 1002, message: 'File system does not exist.'), + fileDoesNotExist(code: 1003, message: 'File does not exist.'); const RpcException({required this.code, required this.message}); /// Throws a [json_rpc.RpcException] with [code] and [message]. Never throwException({Object? data}) => throw toException(data: data); + /// Throws a [json_rpc.RpcException] with [code] and [message], with [details] + /// included in the exception's `data` field. + Never throwExceptionWithDetails({required String details}) => + throw toException(data: {'details': details}); + /// Builds a [json_rpc.RpcException] with [code] and [message] without /// throwing. json_rpc.RpcException toException({Object? data}) => diff --git a/pkg/dart_runtime_service_vm/bin/vm_service_entrypoint.dart b/pkg/dart_runtime_service_vm/bin/vm_service_entrypoint.dart index 4ee611d44bc..f108f7155f6 100644 --- a/pkg/dart_runtime_service_vm/bin/vm_service_entrypoint.dart +++ b/pkg/dart_runtime_service_vm/bin/vm_service_entrypoint.dart @@ -121,6 +121,7 @@ Future main([List args = const []]) async { enableLogging: true, port: _port, disableAuthCodes: _authCodesDisabled, + disableOriginCheck: _originCheckDisabled, autoStart: _autoStart, ), backendBuilder: (frontend) => DartRuntimeServiceVMBackend( diff --git a/pkg/dart_runtime_service_vm/lib/dart_runtime_service_vm.dart b/pkg/dart_runtime_service_vm/lib/dart_runtime_service_vm.dart index 10eec9ecd59..bbf1e46b08a 100644 --- a/pkg/dart_runtime_service_vm/lib/dart_runtime_service_vm.dart +++ b/pkg/dart_runtime_service_vm/lib/dart_runtime_service_vm.dart @@ -16,6 +16,7 @@ import 'package:stream_channel/stream_channel.dart'; import 'src/dart_runtime_service_vm_rpcs.dart'; import 'src/native_bindings.dart'; +import 'src/vm_dev_fs.dart'; import 'src/vm_expression_evaluator.dart'; import 'src/vm_isolate_manager.dart'; @@ -68,6 +69,7 @@ class DartRuntimeServiceVMBackend final _nativeBindings = NativeBindings(); final _logger = Logger('$DartRuntimeServiceVMBackend'); + final _devFs = VMDevelopmentFileSystemCollection.createDevFS(); @override final VmIsolateManager isolateManager; @@ -79,7 +81,7 @@ class DartRuntimeServiceVMBackend @override UnmodifiableListView get rpcs => - UnmodifiableListView(_vmServiceRpcs.rpcs); + UnmodifiableListView([..._vmServiceRpcs.rpcs, ..._devFs.rpcs]); @override UnmodifiableListView @@ -89,6 +91,9 @@ class DartRuntimeServiceVMBackend sendToRuntime, ]); + @override + OptionalHandler get httpHandler => _devFs.handlePutStreamRequest; + @override Future initialize() async { _logger.info('Initializing...'); @@ -116,6 +121,7 @@ class DartRuntimeServiceVMBackend await Future.wait([ _sigquitSubscription?.cancel() ?? Future.value(), _nativeRpcClientStreamChannelController.local.sink.close(), + _devFs.cleanup(), ]); isolateControlPort.close(); _nativeBindings.onExit(); diff --git a/pkg/dart_runtime_service_vm/lib/src/dart_runtime_service_vm_rpcs.dart b/pkg/dart_runtime_service_vm/lib/src/dart_runtime_service_vm_rpcs.dart index f5f9bc6854e..959b7cd42b1 100644 --- a/pkg/dart_runtime_service_vm/lib/src/dart_runtime_service_vm_rpcs.dart +++ b/pkg/dart_runtime_service_vm/lib/src/dart_runtime_service_vm_rpcs.dart @@ -15,7 +15,6 @@ import 'native_bindings.dart'; final class DartRuntimeServiceVmRpcs { final _logger = Logger('$DartRuntimeServiceVmRpcs'); final _nativeBindings = NativeBindings(); - late final rpcs = UnmodifiableListView([ ('getSupportedProtocols', getSupportedProtocols), ]); diff --git a/pkg/dart_runtime_service_vm/lib/src/vm_dev_fs.dart b/pkg/dart_runtime_service_vm/lib/src/vm_dev_fs.dart new file mode 100644 index 00000000000..293a5708d57 --- /dev/null +++ b/pkg/dart_runtime_service_vm/lib/src/vm_dev_fs.dart @@ -0,0 +1,246 @@ +// 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:convert'; +import 'dart:io'; + +import 'package:dart_runtime_service/dart_runtime_service.dart'; +import 'package:file/local.dart'; + +/// An outstanding write request managed by [_WriteLimiter]. +class _PendingWrite { + _PendingWrite({ + required this._localFs, + required this.uri, + required this.bytes, + }); + final completer = Completer(); + + final LocalFileSystem _localFs; + final Uri uri; + final Stream> bytes; + + Future write() async { + final file = _localFs.file(uri); + final parentDir = file.parent; + await parentDir.create(recursive: true); + if (await file.exists()) { + await file.delete(); + } + final sink = file.openWrite(); + await sink.addStream(bytes); + await sink.close(); + completer.complete(); + _WriteLimiter._writeCompleted(); + } +} + +/// A utility class to schedule and limit the number of concurrent file system +/// writes as non-rooted Android devices have a very low limit for the number +/// of open files. +abstract class _WriteLimiter { + static final pendingWrites = <_PendingWrite>[]; + + // Artificially cap ourselves to 16. + static const _kMaxOpenWrites = 16; + static int _openWrites = 0; + + static Future scheduleWrite({ + required LocalFileSystem localFs, + required Uri uri, + required List bytes, + }) => scheduleWriteStream( + localFs: localFs, + uri: uri, + bytes: Stream.fromIterable([bytes]), + ); + + static Future scheduleWriteStream({ + required LocalFileSystem localFs, + required Uri uri, + required Stream> bytes, + }) { + // Create a new pending write. + final pw = _PendingWrite(localFs: localFs, uri: uri, bytes: bytes); + pendingWrites.add(pw); + _maybeWriteFiles(); + return pw.completer.future; + } + + static void _maybeWriteFiles() { + while (_openWrites < _kMaxOpenWrites) { + if (pendingWrites.isEmpty) { + break; + } + final pw = pendingWrites.removeLast(); + pw.write(); + _openWrites++; + } + } + + static void _writeCompleted() { + _openWrites--; + assert(_openWrites >= 0); + _maybeWriteFiles(); + } +} + +/// A [DevelopmentFileSystem] rooted at [rootUri], providing restricted file +/// system access to clients. +final class VMDevelopmentFileSystem extends DevelopmentFileSystem { + VMDevelopmentFileSystem({ + required this._localFs, + required super.name, + required super.rootUri, + }); + + final LocalFileSystem _localFs; + + /// Reads the contents of the file at [uri]. + /// + /// If the file does not exist, a [RpcException.fileDoesNotExist] exception + /// is thrown. + @override + Future readFile({required String uri}) async { + try { + final bytes = await _localFs + .file(resolve(method: '_readDevFSFile', uri: uri)) + .readAsBytes(); + // TODO(bkonyi): create package:vm_service type if we make this public. + return {'type': 'FSFile', 'fileContents': base64.encode(bytes)}; + } on PathNotFoundException catch (e) { + RpcException.fileDoesNotExist.throwExceptionWithDetails( + details: '_readDevFSFile: $e', + ); + } + } + + /// Writes [bytes] to [uri]. + @override + Future writeFile({ + required String uri, + required List bytes, + }) async { + await _WriteLimiter.scheduleWrite( + localFs: _localFs, + uri: resolve(method: '_writeDevFSFile', uri: uri), + bytes: bytes, + ); + } + + /// Writes a stream of [bytes] to [uri]. + @override + Future writeStreamFile({ + required String uri, + required Stream> bytes, + }) async { + await _WriteLimiter.scheduleWriteStream( + localFs: _localFs, + uri: resolve(method: '_writeDevFSFile', uri: uri), + bytes: 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. + @override + Future listFiles() async { + final dir = _localFs.directory(rootUri); + final dirPathStr = dir.path; + final stream = dir.list(recursive: true); + final files = >[]; + await for (final fileEntity in stream) { + final filePath = Uri.file(fileEntity.path).path; + final stat = await fileEntity.stat(); + if (stat.type == FileSystemEntityType.file && + filePath.startsWith(dirPathStr)) { + files.add({ + // Remove any url-encoding in the filenames. + 'name': Uri.decodeFull('/${filePath.substring(dirPathStr.length)}'), + 'size': stat.size, + 'modified': stat.modified.millisecondsSinceEpoch, + }); + } + } + // TODO(bkonyi): create package:vm_service type if we make this public. + return {'type': 'FSFileList', 'files': files}; + } +} + +/// A collection of [DevelopmentFileSystem]s. +final class VMDevelopmentFileSystemCollection + extends DevelopmentFileSystemCollection { + /// Creates a [DevFS] instance with a [VMDevelopmentFileSystemCollection] + /// backend. + static DevFS createDevFS() => + DevFS(fileSystems: VMDevelopmentFileSystemCollection()); + + final _fsMap = {}; + final _localFs = const LocalFileSystem(); + + @override + List get fsNames => _fsMap.keys.toList(); + + /// Destroys all [DevelopmentFileSystem]s in the collection. + @override + Future cleanup() async { + await Future.wait(>[ + for (final fs in _fsMap.values) + _localFs.directory(fs.rootUri).delete(recursive: true), + ]); + _fsMap.clear(); + } + + /// Creates a new [DevelopmentFileSystem] named [name]. + /// + /// Throws a [RpcException.fileSystemAlreadyExists] if the file system has + /// already been created. + @override + Future createFileSystem({required String name}) async { + if (_fsMap.containsKey(name)) { + RpcException.fileSystemAlreadyExists.throwExceptionWithDetails( + details: "_createDevFS: file system '$name' already exists", + ); + } + final temp = await _localFs.systemTempDirectory.createTemp(name); + final uri = (await temp.childDirectory(name).create()).uri; + return _fsMap[name] = VMDevelopmentFileSystem( + localFs: _localFs, + name: name, + rootUri: uri, + ); + } + + /// Destroys the [DevelopmentFileSystem] with name [name]. + /// + /// Throws a [RpcException.fileSystemDoesNotExist] if the file system does + /// not exist. + @override + Future deleteFileSystem({required String name}) async { + final fs = _fsMap.remove(name); + if (fs == null) { + RpcException.fileSystemDoesNotExist.throwExceptionWithDetails( + details: "_deleteDevFS: file system '$name' does not exist", + ); + } + await _localFs.directory(fs.rootUri).delete(recursive: true); + } + + /// Retrieves an existing [DevelopmentFileSystem] based on a JSON-RPC + /// request. + /// + /// Throws a [RpcException.fileSystemDoesNotExist] if the file system does + /// not exist. + @override + DevelopmentFileSystem getFileSystem({required String name}) { + final fs = _fsMap[name]; + if (fs == null) { + RpcException.fileSystemDoesNotExist.throwException(); + } + return fs; + } +} diff --git a/pkg/dart_runtime_service_vm/pubspec.yaml b/pkg/dart_runtime_service_vm/pubspec.yaml index a2a044092d6..20f91bdb63a 100644 --- a/pkg/dart_runtime_service_vm/pubspec.yaml +++ b/pkg/dart_runtime_service_vm/pubspec.yaml @@ -10,6 +10,7 @@ resolution: workspace # Use 'any' constraints here; we get our versions from the DEPS file. dependencies: dart_runtime_service: any + file: any json_rpc_2: any logging: any stream_channel: any diff --git a/pkg/vm_service/test/private_rpcs/dev_fs_http_put_test.dart b/pkg/vm_service/test/private_rpcs/dev_fs_http_put_test.dart index c904089020f..22b3712cda5 100644 --- a/pkg/vm_service/test/private_rpcs/dev_fs_http_put_test.dart +++ b/pkg/vm_service/test/private_rpcs/dev_fs_http_put_test.dart @@ -25,12 +25,11 @@ Future readResponse(HttpClientResponse response) { } final tests = [ - // Write a file with a ? in the filename. (VmService service) async { const fsId = 'test'; - const filePath = '/foo/b?ar.dart'; + const fileUri = 'foo/bar.dart'; const fileContents = [0, 1, 2, 3, 4, 5, 6, 255]; - final filePathBase64 = base64Encode(utf8.encode(filePath)); + final fileUriBase64 = base64Encode(utf8.encode(fileUri)); final fileContentsBase64 = base64Encode(fileContents); Future> postToDevFS({ @@ -41,7 +40,7 @@ final tests = [ final request = await client.putUrl(Uri.parse(serviceHttpAddress)); request.headers.add('dev_fs_name', fsId); if (!omitDevFsPath) { - request.headers.add('dev_fs_path_b64', filePathBase64); + request.headers.add('dev_fs_uri_b64', fileUriBase64); } request.add(gzip.encode(content)); final response = await request.close(); @@ -81,7 +80,12 @@ final tests = [ } } }) { - expect(details.contains("expects the 'path' parameter"), true); + expect( + // TODO(bkonyi): remove the 'path' case once we move to the new VM + // service implementation. + details.contains(RegExp("expects the '(path|uri)' parameter")), + true, + ); } else { invalidResponse(result); } @@ -100,7 +104,7 @@ final tests = [ '_readDevFSFile', args: { 'fsName': fsId, - 'path': filePath, + 'uri': fileUri, }, ); if (result case {'type': 'FSFile', 'fileContents': final String contents}) { @@ -117,7 +121,9 @@ final tests = [ 'fsName': fsId, }, ); - if (result case {'type': 'FSFileList', 'files': [{'name': filePath}]}) { + if (result + case {'type': 'FSFileList', 'files': [{'name': final String uri}]} + when uri.endsWith(fileUri)) { // Expected } else { invalidResponse(result); diff --git a/pkg/vm_service/test/private_rpcs/dev_fs_http_put_weird_char_test.dart b/pkg/vm_service/test/private_rpcs/dev_fs_http_put_weird_char_test.dart deleted file mode 100644 index 986726029f5..00000000000 --- a/pkg/vm_service/test/private_rpcs/dev_fs_http_put_weird_char_test.dart +++ /dev/null @@ -1,142 +0,0 @@ -// Copyright (c) 2023, 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 'package:test/test.dart'; -import 'package:vm_service/vm_service.dart'; - -import '../common/test_helper.dart'; -import 'private_rpc_common.dart'; - -Future readResponse(HttpClientResponse response) { - final completer = Completer(); - final contents = StringBuffer(); - response.transform(utf8.decoder).listen( - (String data) { - contents.write(data); - }, - onDone: () => completer.complete(contents.toString()), - ); - return completer.future; -} - -final tests = [ - // Write a file with the \r character in the filename. - (VmService service) async { - const fsId = 'test'; - const filePath = '/foo/b\rar.dart'; - const fileContents = [0, 1, 2, 3, 4, 5, 6, 255]; - final filePathBase64 = base64Encode(utf8.encode(filePath)); - final fileContentsBase64 = base64Encode(fileContents); - - Future> postToDevFS({ - required List content, - bool omitDevFsPath = false, - }) async { - final client = HttpClient(); - final request = await client.putUrl(Uri.parse(serviceHttpAddress)); - request.headers.add('dev_fs_name', fsId); - if (!omitDevFsPath) { - request.headers.add('dev_fs_path_b64', filePathBase64); - } - request.add(gzip.encode(content)); - final response = await request.close(); - final responseBody = await readResponse(response); - client.close(); - return jsonDecode(responseBody); - } - - // Create DevFS. - Map result = await callMethod( - service, - '_createDevFS', - args: {'fsName': fsId}, - ); - - if (result case {'type': 'FileSystem', 'name': fsId, 'uri': String _}) { - // Expected - } else { - invalidResponse(result); - } - - // Write the file by issuing an HTTP PUT. - result = await postToDevFS(content: [9]); - if (result case {'result': final Map innerResult}) { - expectSuccess(innerResult); - } else { - invalidResponse(result); - } - - // Trigger an error by issuing an HTTP PUT. - result = await postToDevFS(content: fileContents, omitDevFsPath: true); - if (result - case { - 'error': { - 'data': { - 'details': final String details, - } - } - }) { - expect(details.contains("expects the 'path' parameter"), true); - } else { - invalidResponse(result); - } - - // Write the file again but this time with the true file contents. - result = await postToDevFS(content: fileContents); - if (result case {'result': final Map innerResult}) { - expectSuccess(innerResult); - } else { - invalidResponse(result); - } - - // Read the file back. - result = await callMethod( - service, - '_readDevFSFile', - args: { - 'fsName': fsId, - 'path': filePath, - }, - ); - if (result case {'type': 'FSFile', 'fileContents': final String contents}) { - expect(contents, fileContentsBase64); - } else { - invalidResponse(result); - } - - // List all the files in the file system. - result = await callMethod( - service, - '_listDevFSFiles', - args: { - 'fsName': fsId, - }, - ); - if (result case {'type': 'FSFileList', 'files': [{'name': filePath}]}) { - // Expected - } else { - invalidResponse(result); - } - - // Delete DevFS. - result = await callMethod( - service, - '_deleteDevFS', - args: { - 'fsName': fsId, - }, - ); - expectSuccess(result); - }, -]; - -void main(args) => runVMTests( - args, - tests, - 'dev_fs_http_put_weird_char_test.dart', - ); diff --git a/pkg/vm_service/test/private_rpcs/dev_fs_test.dart b/pkg/vm_service/test/private_rpcs/dev_fs_test.dart index bf18056f52d..360a1ecc51b 100644 --- a/pkg/vm_service/test/private_rpcs/dev_fs_test.dart +++ b/pkg/vm_service/test/private_rpcs/dev_fs_test.dart @@ -72,7 +72,7 @@ final tests = [ }, (VmService service) async { const fsId = 'banana'; - const filePath = '/foo/bar.dat'; + final fileUri = 'foo/bar.dat'; final fileContents = base64Encode(utf8.encode('fileContents')); // Create DevFS. @@ -93,7 +93,7 @@ final tests = [ '_readDevFSFile', args: { 'fsName': fsId, - 'path': filePath, + 'uri': fileUri, }, ); fail('Unreachable'); @@ -108,7 +108,7 @@ final tests = [ '_writeDevFSFile', args: { 'fsName': fsId, - 'path': filePath, + 'uri': fileUri, 'fileContents': fileContents, }, ); @@ -120,7 +120,7 @@ final tests = [ '_readDevFSFile', args: { 'fsName': fsId, - 'path': filePath, + 'uri': fileUri, }, ); if (result case {'type': 'FSFile', 'fileContents': final String contents}) { @@ -129,19 +129,6 @@ final tests = [ invalidResponse(result); } - // The leading '/' is optional. - result = await callMethod( - service, - '_readDevFSFile', - args: { - 'fsName': fsId, - 'path': filePath.substring(1), - }, - ); - if (result case {'type': 'FSFile', 'fileContents': final String contents}) { - expect(contents, fileContents); - } - // Read a file outside of the fs. try { await callMethod( @@ -149,13 +136,13 @@ final tests = [ '_readDevFSFile', args: { 'fsName': fsId, - 'path': '../foo', + 'uri': '../foo', }, ); fail('Unreachable'); } on RPCError catch (e) { expect(e.code, RPCErrorKind.kInvalidParams.code); - expect(e.details, "_readDevFSFile: invalid 'path' parameter: ../foo"); + expect(e.details, "_readDevFSFile: invalid 'uri' parameter: ../foo"); } // Write a set of files. @@ -165,8 +152,8 @@ final tests = [ args: { 'fsName': fsId, 'files': [ - ['/a', base64Encode(utf8.encode('a_contents'))], - ['/b', base64Encode(utf8.encode('b_contents'))], + ['a', base64Encode(utf8.encode('a_contents'))], + ['b', base64Encode(utf8.encode('b_contents'))], ], }, ); @@ -178,7 +165,7 @@ final tests = [ '_readDevFSFile', args: { 'fsName': fsId, - 'path': '/b', + 'uri': 'b', }, ); diff --git a/pkg/vm_service/test/private_rpcs/dev_fs_uri_test.dart b/pkg/vm_service/test/private_rpcs/dev_fs_uri_test.dart index e2ba0ff8681..a078e386629 100644 --- a/pkg/vm_service/test/private_rpcs/dev_fs_uri_test.dart +++ b/pkg/vm_service/test/private_rpcs/dev_fs_uri_test.dart @@ -74,7 +74,13 @@ final tests = [ // Trigger an error by issuing an HTTP PUT. result = await postToDevFS(content: fileContents, omitDevFsUri: true); if (result case {'error': {'data': {'details': final String details}}}) { - expect(details.contains("expects the 'path' parameter"), true); + print(details); + expect( + // TODO(bkonyi): remove the 'path' case once we move to the new VM + // service implementation. + details.contains(RegExp("expects the '(uri|path)' parameter")), + true, + ); } else { invalidResponse(result); } diff --git a/pkg/vm_service/test/private_rpcs/dev_fs_weird_char_test.dart b/pkg/vm_service/test/private_rpcs/dev_fs_weird_char_test.dart deleted file mode 100644 index 17171a2ee89..00000000000 --- a/pkg/vm_service/test/private_rpcs/dev_fs_weird_char_test.dart +++ /dev/null @@ -1,93 +0,0 @@ -// Copyright (c) 2023, 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 'package:test/test.dart'; -import 'package:vm_service/vm_service.dart'; - -import '../common/test_helper.dart'; -import 'private_rpc_common.dart'; - -final tests = [ - // Write a file with the ? character in the filename. - (VmService service) async { - const fsId = 'test'; - const filePath = '/foo/bar?dat'; - final fileContents = base64Encode(utf8.encode('fileContents')); - - // Create DevFS. - Map result = await callMethod( - service, - '_createDevFS', - args: {'fsName': fsId}, - ); - if (result case {'type': 'FileSystem', 'name': fsId, 'uri': String _}) { - // Expected - } else { - invalidResponse(result); - } - - // Write the file. - result = await callMethod( - service, - '_writeDevFSFile', - args: { - 'fsName': fsId, - 'path': filePath, - 'fileContents': fileContents, - }, - ); - expectSuccess(result); - - // Read the file back. - result = await callMethod( - service, - '_readDevFSFile', - args: { - 'fsName': fsId, - 'path': filePath, - }, - ); - if (result case {'type': 'FSFile', 'fileContents': final String contents}) { - expect(contents, fileContents); - } else { - invalidResponse(result); - } - - // List all the files in the file system. - result = await callMethod( - service, - '_listDevFSFiles', - args: { - 'fsName': fsId, - }, - ); - if (result - case { - 'type': 'FSFileList', - 'files': [{'name': '/foo/bar?dat'}], - }) { - // Expected - } else { - invalidResponse(result); - } - - // Delete DevFS. - result = await callMethod( - service, - '_deleteDevFS', - args: { - 'fsName': fsId, - }, - ); - expectSuccess(result); - }, -]; - -void main(List args) => runVMTests( - args, - tests, - 'dev_fs_weird_char_test.dart', - );