| // Copyright (c) 2024, 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:collection/collection.dart'; |
| import 'package:dtd/dtd.dart'; |
| import 'package:json_rpc_2/json_rpc_2.dart'; |
| import 'package:path/path.dart' as path; |
| |
| import '../constants.dart'; |
| import '../dtd_client.dart'; |
| import 'internal_service.dart'; |
| |
| class FileSystemService extends InternalService { |
| FileSystemService({required this.secret, required this.unrestrictedMode}); |
| |
| final String secret; |
| final bool unrestrictedMode; |
| final List<Uri> _ideWorkspaceRoots = []; |
| |
| @override |
| String get serviceName => FileSystemServiceConstants.serviceName; |
| |
| static const int _defaultGetProjectRootsDepth = 4; |
| |
| @override |
| void register(DTDClient client) { |
| client |
| ..registerServiceMethod( |
| serviceName, |
| FileSystemServiceConstants.readFileAsString, |
| _readFileAsString, |
| ) |
| ..registerServiceMethod( |
| serviceName, |
| FileSystemServiceConstants.writeFileAsString, |
| _writeFileAsString, |
| ) |
| ..registerServiceMethod( |
| serviceName, |
| FileSystemServiceConstants.listDirectoryContents, |
| _listDirectoryContents, |
| ) |
| ..registerServiceMethod( |
| serviceName, |
| FileSystemServiceConstants.setIDEWorkspaceRoots, |
| _setIDEWorkspaceRoots, |
| ) |
| ..registerServiceMethod( |
| serviceName, |
| FileSystemServiceConstants.getIDEWorkspaceRoots, |
| _getIDEWorkspaceRoots, |
| ) |
| ..registerServiceMethod( |
| serviceName, |
| FileSystemServiceConstants.getProjectRoots, |
| _getProjectRoots, |
| ); |
| } |
| |
| @override |
| void shutdown() { |
| _ideWorkspaceRoots.clear(); |
| } |
| |
| Future<void> _ensureIDEWorkspaceRootsContainUri(Uri uri) async { |
| // If in unrestricted mode, no need to do these checks. |
| if (unrestrictedMode) return; |
| |
| final requestedPath = await _resolveNearestExistingPath(uri.toFilePath()); |
| final resolvedWorkspaceRoots = await Future.wait( |
| _ideWorkspaceRoots.map(_resolveWorkspaceRootPath), |
| ); |
| |
| if (resolvedWorkspaceRoots.any((rootPath) { |
| return path.isWithin(rootPath, requestedPath) || |
| path.equals(rootPath, requestedPath); |
| })) { |
| return; |
| } |
| |
| throw RpcErrorCodes.buildRpcException(RpcErrorCodes.kPermissionDenied); |
| } |
| |
| Future<String> _resolveWorkspaceRootPath(Uri root) async { |
| final rootPath = root.toFilePath(); |
| final type = await FileSystemEntity.type(rootPath, followLinks: true); |
| if (type == FileSystemEntityType.file) { |
| return File(rootPath).resolveSymbolicLinks(); |
| } |
| if (type == FileSystemEntityType.directory) { |
| return Directory(rootPath).resolveSymbolicLinks(); |
| } |
| if (type == FileSystemEntityType.link) { |
| return Link(rootPath).resolveSymbolicLinks(); |
| } |
| return path.normalize(rootPath); |
| } |
| |
| /// Safely resolves symbolic links for [requestedPath] by traversing up the |
| /// tree until an existing entity is found, resolving its links, and then |
| /// appending the relative path components that do not yet exist on disk. |
| /// This ensures that non-existent dummy paths (common in tests) do not lose |
| /// their skipped suffixes when their nearest existing ancestor is resolved, |
| /// which would otherwise cause incorrectly resolved roots during validations. |
| Future<String> _resolveNearestExistingPath(String requestedPath) async { |
| var currentPath = requestedPath; |
| while (true) { |
| final type = await FileSystemEntity.type(currentPath, followLinks: true); |
| if (type != FileSystemEntityType.notFound) { |
| String resolvedPath; |
| if (type == FileSystemEntityType.file) { |
| resolvedPath = await File(currentPath).resolveSymbolicLinks(); |
| } else if (type == FileSystemEntityType.directory) { |
| resolvedPath = await Directory(currentPath).resolveSymbolicLinks(); |
| } else if (type == FileSystemEntityType.link) { |
| resolvedPath = await Link(currentPath).resolveSymbolicLinks(); |
| } else { |
| throw StateError('Unexpected file system entity type: $type'); |
| } |
| |
| if (currentPath == requestedPath) { |
| return resolvedPath; |
| } |
| // Re-append the relative skipped components to the resolved base path. |
| // This preserves the paths used for mock dummy structures in tests. |
| final relative = path.relative(requestedPath, from: currentPath); |
| return path.join(resolvedPath, relative); |
| } |
| |
| final parentPath = path.dirname(currentPath); |
| if (path.equals(parentPath, currentPath)) { |
| // Reached the file system root without finding an existing parent |
| // entity. Append the fully skipped path components to the root path. |
| final relative = path.relative(requestedPath, from: currentPath); |
| return path.join(path.normalize(currentPath), relative); |
| } |
| currentPath = parentPath; |
| } |
| } |
| |
| Map<String, Object?> _setIDEWorkspaceRoots(Parameters parameters) { |
| final incomingSecret = parameters[DtdParameters.secret].asString; |
| |
| if (!unrestrictedMode && secret != incomingSecret) { |
| throw RpcErrorCodes.buildRpcException(RpcErrorCodes.kPermissionDenied); |
| } |
| final newRoots = <Uri>[]; |
| for (final root in parameters[DtdParameters.roots].asList.cast<String>()) { |
| final rootUri = Uri.parse(path.normalize(root)); |
| if (rootUri.scheme != 'file') { |
| throw RpcErrorCodes.buildRpcException( |
| RpcErrorCodes.kExpectsUriParamWithFileScheme, |
| ); |
| } |
| |
| newRoots.add(rootUri); |
| } |
| |
| _ideWorkspaceRoots.clear(); |
| _ideWorkspaceRoots.addAll(newRoots); |
| |
| return RPCResponses.success; |
| } |
| |
| Map<String, Object?> _getIDEWorkspaceRoots(Parameters _) { |
| return IDEWorkspaceRoots(ideWorkspaceRoots: _ideWorkspaceRoots).toJson(); |
| } |
| |
| Future<Map<String, Object?>> _getProjectRoots(Parameters parameters) async { |
| final searchDepth = parameters[DtdParameters.depth].asIntOr( |
| _defaultGetProjectRootsDepth, |
| ); |
| |
| final projectRoots = <Uri>[]; |
| |
| // Recursive helper method to find all project roots within [directory], up |
| // to a maximum depth of [maxSearchDepth]. |
| Future<void> findProjectRoots( |
| Directory dir, { |
| required int currentDepth, |
| }) async { |
| if (await dir.exists()) { |
| // Setting 'followLinks' to false means that any symbolic links returned |
| // in this list will have type [Link], and therefore will fail the type |
| // checks below for `whereType<File>` and `whereType<Directory>`. This |
| // ensures that we are not returning project roots that are outside of |
| // [_ideWorkspaceRoots]. |
| final directoryContents = await dir.list(followLinks: false).toList(); |
| final pubspec = directoryContents.whereType<File>().firstWhereOrNull( |
| (entity) => entity.path.endsWith('pubspec.yaml'), |
| ); |
| if (pubspec != null) { |
| projectRoots.add(dir.uri); |
| } |
| |
| final nextLevel = currentDepth + 1; |
| if (nextLevel < searchDepth) { |
| await Future.wait([ |
| for (final dir in directoryContents.whereType<Directory>()) |
| findProjectRoots(dir, currentDepth: nextLevel), |
| ]); |
| } |
| } |
| } |
| |
| await Future.wait([ |
| for (final workspaceRoot in _ideWorkspaceRoots) |
| findProjectRoots(Directory.fromUri(workspaceRoot), currentDepth: 0), |
| ]); |
| |
| return UriList(uris: projectRoots).toJson(); |
| } |
| |
| Future<Map<String, Object?>> _readFileAsString(Parameters parameters) async { |
| final uri = _extractUri(parameters); |
| await _ensureIDEWorkspaceRootsContainUri(uri); |
| final file = File.fromUri(uri); |
| |
| if (!(await file.exists())) { |
| throw RpcErrorCodes.buildRpcException(RpcErrorCodes.kFileDoesNotExist); |
| } |
| |
| final content = await file.readAsString(); |
| |
| return FileContent(content: content).toJson(); |
| } |
| |
| Uri _extractUri(Parameters parameters) { |
| final uriString = parameters['uri'].asString; |
| final uri = Uri.parse(path.normalize(uriString)); |
| if (uri.scheme != 'file') { |
| throw RpcErrorCodes.buildRpcException( |
| RpcErrorCodes.kExpectsUriParamWithFileScheme, |
| ); |
| } |
| return uri; |
| } |
| |
| Future<Map<String, Object?>> _writeFileAsString(Parameters parameters) async { |
| final uri = _extractUri(parameters); |
| final contents = parameters[DtdParameters.contents].asString; |
| final encoding = Encoding.getByName( |
| parameters[DtdParameters.encoding].asString, |
| )!; |
| |
| await _ensureIDEWorkspaceRootsContainUri(uri); |
| final file = File.fromUri(uri); |
| if (!(await file.exists())) { |
| await file.create(recursive: true); |
| } |
| |
| await file.writeAsString(contents, encoding: encoding); |
| |
| return RPCResponses.success; |
| } |
| |
| Future<Map<String, Object?>> _listDirectoryContents( |
| Parameters parameters, |
| ) async { |
| final uri = _extractUri(parameters); |
| await _ensureIDEWorkspaceRootsContainUri(uri); |
| final dir = Directory.fromUri(uri); |
| if (!(await dir.exists())) { |
| throw RpcErrorCodes.buildRpcException( |
| RpcErrorCodes.kDirectoryDoesNotExist, |
| data: {'directory': dir.uri.toFilePath()}, |
| ); |
| } |
| |
| final response = await dir.list().toList(); |
| |
| final uris = response.map((e) => e.uri).toList(); |
| |
| return UriList(uris: uris).toJson(); |
| } |
| } |