blob: 28ba4adbc50e66dccdc472473761996857d11bf2 [file]
// 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();
}
}