// 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:io'; import 'dart:math'; import 'package:dtd/dtd.dart'; import 'package:path/path.dart' as p; import 'package:test/test.dart'; import 'utils.dart'; void main() { final fooDirContents = []; final barDirContents = []; late ToolingDaemonTestProcess toolingDaemonProcess; late Directory tmpDirectory; late Directory fooDirectory; late Directory barDirectory; late File fooPubspecFile; late File barPubspecFile; late File aFile; late File bFile; late Directory cDir; late File eFile; late File fFile; final aFileContents = 'These are the contents for aFile'; final eFileContents = 'These are the contents for eFile'; late DartToolingDaemon client; late String dtdSecret; late Uri dtdUri; setUp(() async { tmpDirectory = await Directory.systemTemp.createTemp(); // Test directory structure: // // foo/ // a.txt // b.txt // pubspec.yaml // C/ // d.txt // pubspec.yaml // bar/ // e.txt // f.txt // pubspec.yaml // Setup foo dir fooDirectory = Directory(p.join(tmpDirectory.path, 'foo'))..createSync(); aFile = File(p.join(fooDirectory.path, 'a.txt')) ..writeAsStringSync(aFileContents) ..createSync(); bFile = File(p.join(fooDirectory.path, 'b.txt'))..createSync(); fooPubspecFile = File(p.join(fooDirectory.path, 'pubspec.yaml')) ..createSync(); cDir = Directory(p.join(fooDirectory.path, 'C'))..createSync(); File(p.join(fooDirectory.path, 'C', 'd.txt')).createSync(); File(p.join(fooDirectory.path, 'C', 'pubspec.yaml')).createSync(); fooDirContents.clear(); fooDirContents.addAll([aFile.uri, bFile.uri, fooPubspecFile.uri, cDir.uri]); // Setup bar dir barDirectory = Directory(p.join(tmpDirectory.path, 'bar')); barDirectory.createSync(); eFile = File(p.join(barDirectory.path, 'e.txt')) ..writeAsStringSync(eFileContents) ..createSync(); fFile = File(p.join(barDirectory.path, 'f.txt'))..createSync(); barPubspecFile = File(p.join(barDirectory.path, 'pubspec.yaml')) ..createSync(); barDirContents.clear(); barDirContents.addAll([eFile.uri, fFile.uri, barPubspecFile.uri]); }); tearDown(() { tmpDirectory.deleteSync(recursive: true); toolingDaemonProcess.kill(); }); group('restricted', () { setUp(() async { toolingDaemonProcess = ToolingDaemonTestProcess(); await toolingDaemonProcess.start(); dtdUri = toolingDaemonProcess.uri; dtdSecret = toolingDaemonProcess.trustedSecret!; client = await DartToolingDaemon.connect(dtdUri); }); group(FileSystemServiceConstants.serviceName, () { group(FileSystemServiceConstants.setIDEWorkspaceRoots, () { test('wrong secret is unauthorized', () { expect( () => client.setIDEWorkspaceRoots('abc123', [Uri.directory('/')]), throwsAnRpcError(RpcErrorCodes.kPermissionDenied), ); expect( () => client.readFileAsString(aFile.uri), throwsAnRpcError(RpcErrorCodes.kPermissionDenied), ); }); test('root must have file scheme', () { expect( () => client .setIDEWorkspaceRoots(dtdSecret, [Uri.parse('/some/path/')]), throwsAnRpcError(RpcErrorCodes.kExpectsUriParamWithFileScheme), ); }); test('no IDE workspace roots', () { final file = File(p.join(fooDirectory.path, 'newfile.txt')); expect( () => client.listDirectoryContents(fooDirectory.uri), throwsAnRpcError(RpcErrorCodes.kPermissionDenied), ); expect( () => client.readFileAsString( file.uri, ), throwsAnRpcError(RpcErrorCodes.kPermissionDenied), ); expect( () => client.writeFileAsString( file.uri, 'this should not be written', ), throwsAnRpcError(RpcErrorCodes.kPermissionDenied), ); expect(file.existsSync(), false); }); test('one IDE workspace root', () async { await client.setIDEWorkspaceRoots(dtdSecret, [fooDirectory.uri]); final fileContents = 'New file contents'; final listResult = await client.listDirectoryContents( fooDirectory.uri, ); expect(listResult.uris, containsAll(fooDirContents)); await client.writeFileAsString(aFile.uri, fileContents); final readResult = await client.readFileAsString(aFile.uri); expect(readResult.content, fileContents); }); test('multiple IDE workspace roots', () async { await client.setIDEWorkspaceRoots(dtdSecret, [ fooDirectory.uri, barDirectory.uri, ]); // Operate in foo final newAFileContents = 'New afile contents'; final fooListResult = await client.listDirectoryContents( fooDirectory.uri, ); expect(fooListResult.uris, containsAll(fooDirContents)); await client.writeFileAsString(aFile.uri, newAFileContents); final readResult = await client.readFileAsString(aFile.uri); expect(readResult.content, newAFileContents); // Operate in bar final newEFileContents = 'New efile contents'; final barListResult = await client.listDirectoryContents( fooDirectory.uri, ); expect(barListResult.uris, containsAll(fooDirContents)); await client.writeFileAsString(aFile.uri, newEFileContents); final eReadResult = await client.readFileAsString(aFile.uri); expect(eReadResult.content, newEFileContents); }); test('remove an IDE workspace root', () async { await client.setIDEWorkspaceRoots(dtdSecret, [ fooDirectory.uri, barDirectory.uri, ]); await client.setIDEWorkspaceRoots(dtdSecret, [fooDirectory.uri]); final fileContents = 'New file contents'; final listResult = await client.listDirectoryContents( fooDirectory.uri, ); expect( listResult.uris, containsAll(fooDirContents), ); await client.writeFileAsString(aFile.uri, fileContents); final readResult = await client.readFileAsString(aFile.uri); expect(readResult.content, fileContents); expect( () => client.listDirectoryContents(barDirectory.uri), throwsAnRpcError(RpcErrorCodes.kPermissionDenied), ); expect( () => client.readFileAsString(eFile.uri), throwsAnRpcError(RpcErrorCodes.kPermissionDenied), ); expect( () => client.writeFileAsString(eFile.uri, fileContents), throwsAnRpcError(RpcErrorCodes.kPermissionDenied), ); }); }); group(FileSystemServiceConstants.getIDEWorkspaceRoots, () { test('empty IDE workspace roots', () async { final roots = await client.getIDEWorkspaceRoots(); expect(roots.ideWorkspaceRoots, isEmpty); }); test('multiple IDE workspace roots', () async { await client.setIDEWorkspaceRoots(dtdSecret, [ fooDirectory.uri, barDirectory.uri, ]); final roots = await client.getIDEWorkspaceRoots(); expect( roots.ideWorkspaceRoots.map((e) => p.normalize(e.path)), containsAll( [fooDirectory.uri, barDirectory.uri] .map((e) => p.normalize(e.path)), ), ); }); }); group(FileSystemServiceConstants.getProjectRoots, () { test('with empty IDE workspace roots', () async { final roots = await client.getIDEWorkspaceRoots(); expect(roots.ideWorkspaceRoots, isEmpty); final projectRoots = await client.getProjectRoots(); expect(projectRoots.uris, isEmpty); }); test('with a single IDE workspace root', () async { await client.setIDEWorkspaceRoots(dtdSecret, [fooDirectory.uri]); final projectRoots = await client.getProjectRoots(); final expected = [fooDirectory.uri, cDir.uri]; expect(projectRoots.uris, containsAll(expected)); expect(projectRoots.uris?.length, expected.length); }); test('with a multiple IDE workspace roots', () async { await client.setIDEWorkspaceRoots( dtdSecret, [fooDirectory.uri, barDirectory.uri], ); final projectRoots = await client.getProjectRoots(); final expected = [fooDirectory.uri, cDir.uri, barDirectory.uri]; expect(projectRoots.uris, containsAll(expected)); expect(projectRoots.uris?.length, expected.length); }); test('searches up to a specified depth', () async { await client.setIDEWorkspaceRoots( dtdSecret, [fooDirectory.uri, barDirectory.uri], ); final projectRoots = await client.getProjectRoots(depth: 1); final expected = [fooDirectory.uri, barDirectory.uri]; expect(projectRoots.uris, containsAll(expected)); expect(projectRoots.uris?.length, expected.length); }); test('does not follow symlinks', () async { // Add a symlink under [fooDirectory] that points to the // [tmpDirectory]. Since the [tmpDirectory] contains [fooDirectory] // and [barDirectory], [client.getProjectRoots] would contain // duplicates of each project root if the traversal followed symlinks. final symlinkDir = Link(p.join(fooDirectory.path, 'SomeDir')) ..createSync(tmpDirectory.path, recursive: true); final extraRoot = Directory(p.join(tmpDirectory.path, 'Extra')) ..createSync(); final symlinkFile = Link(p.join(extraRoot.path, 'pubspec.yaml')) ..createSync( p.join(tmpDirectory.path, 'pubspec.yaml'), recursive: true, ); await client.setIDEWorkspaceRoots( dtdSecret, [fooDirectory.uri, barDirectory.uri, extraRoot.uri], ); final projectRoots = await client.getProjectRoots(); final expectedUris = [fooDirectory.uri, cDir.uri, barDirectory.uri]; expect(projectRoots.uris, containsAll(expectedUris)); expect(projectRoots.uris?.length, expectedUris.length); symlinkDir.deleteSync(); symlinkFile.deleteSync(); extraRoot.deleteSync(); }); }); group(FileSystemServiceConstants.listDirectoryContents, () { test('listing a file should fail', () async { await client.setIDEWorkspaceRoots(dtdSecret, [fooDirectory.uri]); expect( () => client.listDirectoryContents(aFile.uri), throwsAnRpcError(RpcErrorCodes.kDirectoryDoesNotExist), ); }); test('non-existent directory should fail', () async { await client.setIDEWorkspaceRoots(dtdSecret, [fooDirectory.uri]); expect( () => client.listDirectoryContents( Uri.directory(p.join(fooDirectory.path, 'A')), ), throwsAnRpcError(RpcErrorCodes.kDirectoryDoesNotExist), ); }); test('should work for an empty directory', () async { await client.setIDEWorkspaceRoots(dtdSecret, [fooDirectory.uri]); final emptyFooDir = Directory(p.join(fooDirectory.path, 'emptyDir')); emptyFooDir.createSync(); final listResult = await client.listDirectoryContents(emptyFooDir.uri); expect(listResult.uris, isEmpty); }); test('must have a file scheme', () { expect( () => client.listDirectoryContents( Uri.parse('/some/path/'), ), throwsAnRpcError(RpcErrorCodes.kExpectsUriParamWithFileScheme), ); }); }); group(FileSystemServiceConstants.readFileAsString, () { test('fails on a non-existent file', () async { await client.setIDEWorkspaceRoots(dtdSecret, [fooDirectory.uri]); expect( () => client.readFileAsString( File(p.join(fooDirectory.path, 'nonExistentFile.txt')).uri, ), throwsAnRpcError(RpcErrorCodes.kFileDoesNotExist), ); }); test('must have a file scheme', () { expect( () => client.readFileAsString(Uri.parse('/some/path/')), throwsAnRpcError(RpcErrorCodes.kExpectsUriParamWithFileScheme), ); }); group( 'windows paths', () { // Test paths in various formats. We will test all combinations of // these both as the set roots and the readFile path to ensure the // calling client doesn't need to use the same escaping as the // editor. final roots = [ Uri.parse('file:///C:/foo'), Uri.parse('file:///C%3A/foo'), Uri.parse('file:///c:/foo'), ]; final files = roots .map((uri) => uri.replace(path: '${uri.path}/file.txt')) .toList(); for (final root in roots) { for (final file in files) { test('can read $file in $root', () async { await client.setIDEWorkspaceRoots(dtdSecret, [root]); expect( () => client.readFileAsString(file), // Expect file does not exist (NOT a permission error). throwsAnRpcError(RpcErrorCodes.kFileDoesNotExist), ); }); } } }, skip: !Platform.isWindows, ); }); group(FileSystemServiceConstants.writeFileAsString, () { final newFileContents = 'Some new file contents'; test('can overwrite an existing file', () async { await client.setIDEWorkspaceRoots(dtdSecret, [fooDirectory.uri]); expect(aFile.readAsStringSync(), aFileContents); await client.writeFileAsString(aFile.uri, newFileContents); expect(aFile.readAsStringSync(), newFileContents); }); test('creates the file if it does not exist', () async { final nonExistentFile = File( p.join(fooDirectory.path, 'nonExistentFile.txt'), ); await client.setIDEWorkspaceRoots(dtdSecret, [fooDirectory.uri]); expect(nonExistentFile.existsSync(), false); await client.writeFileAsString(nonExistentFile.uri, newFileContents); expect(nonExistentFile.existsSync(), true); expect(nonExistentFile.readAsStringSync(), newFileContents); }); test('creates sub directories if they don\'t exist', () async { final fileInNonExistentDirectory = File( p.join( fooDirectory.path, 'a', 'b', 'c', 'nonExistentFile.txt', ), ); await client.setIDEWorkspaceRoots(dtdSecret, [fooDirectory.uri]); expect(fileInNonExistentDirectory.existsSync(), false); await client.writeFileAsString( fileInNonExistentDirectory.uri, newFileContents, ); expect(fileInNonExistentDirectory.existsSync(), true); expect( fileInNonExistentDirectory.readAsStringSync(), newFileContents, ); }); test('must have a file scheme', () { expect( () => client.writeFileAsString( Uri.parse('/some/path/'), 'some contents', ), throwsAnRpcError(RpcErrorCodes.kExpectsUriParamWithFileScheme), ); }); }); }); group('relative paths', () { test('normalizes paths when setting pub root', () async { final relativePath = p.join(fooDirectory.path, '..', 'bar', 'a.txt'); final simplifiedPath = p.join(barDirectory.path, 'a.txt'); await client.call( FileSystemServiceConstants.serviceName, FileSystemServiceConstants.setIDEWorkspaceRoots, params: { DtdParameters.secret: dtdSecret, DtdParameters.roots: [ Uri.file(relativePath).toString(), ], }, ); final roots = await client.getIDEWorkspaceRoots(); expect( roots.ideWorkspaceRoots.map((e) => e.toFilePath()), [simplifiedPath], ); }); test('prevents access outside of workspace roots for relative paths', () async { await client.setIDEWorkspaceRoots(dtdSecret, [fooDirectory.uri]); expect( () => client.call( FileSystemServiceConstants.serviceName, FileSystemServiceConstants.readFileAsString, params: { DtdParameters.uri: p.join('${fooDirectory.uri}', '..', 'a.txt'), }, ), throwsAnRpcError(RpcErrorCodes.kPermissionDenied), ); expect( () => client.call( FileSystemServiceConstants.serviceName, FileSystemServiceConstants.writeFileAsString, params: { DtdParameters.uri: p.join('${fooDirectory.uri}', '..', 'a.txt'), DtdParameters.contents: 'abc', DtdParameters.encoding: 'utf-8', }, ), throwsAnRpcError(RpcErrorCodes.kPermissionDenied), ); expect( () => client.call( FileSystemServiceConstants.serviceName, FileSystemServiceConstants.listDirectoryContents, params: { DtdParameters.uri: p.join('${fooDirectory.uri}', '..'), }, ), throwsAnRpcError(RpcErrorCodes.kPermissionDenied), ); }); test('allows access to relative paths with ide workspace roots', () async { await client.setIDEWorkspaceRoots(dtdSecret, [fooDirectory.uri]); final writeResult = await client.call( FileSystemServiceConstants.serviceName, FileSystemServiceConstants.writeFileAsString, params: { DtdParameters.uri: p.join( fooDirectory.uri.toString(), 'C', 'D', '..', '..', 'C', 'd.txt', ), DtdParameters.contents: 'abc', DtdParameters.encoding: 'utf-8', }, ); expect(writeResult.result, {'type': 'Success'}); final readResult = await client.call( FileSystemServiceConstants.serviceName, FileSystemServiceConstants.readFileAsString, params: { DtdParameters.uri: p.join( fooDirectory.uri.toString(), 'C', 'D', '..', '..', 'C', 'd.txt', ), }, ); expect(readResult.result, {'type': 'FileContent', 'content': 'abc'}); final listResult = await client.call( FileSystemServiceConstants.serviceName, FileSystemServiceConstants.listDirectoryContents, params: { DtdParameters.uri: p.join( fooDirectory.uri.toString(), 'C', 'D', '..', '..', 'C', ), }, ); expect(listResult.result, { 'type': 'UriList', 'uris': containsAll([ '${fooDirectory.uri}C/pubspec.yaml', '${fooDirectory.uri}C/d.txt', ]), }); }); final invalidDirectories = [ { 'dir': './', 'error': throwsAnRpcError(RpcErrorCodes.kExpectsUriParamWithFileScheme), }, { 'dir': '/', 'error': throwsAnRpcError(RpcErrorCodes.kExpectsUriParamWithFileScheme), }, { 'dir': '../', 'error': throwsAnRpcError(RpcErrorCodes.kExpectsUriParamWithFileScheme), }, { 'dir': 'file:///~/', 'error': throwsAnRpcError(RpcErrorCodes.kPermissionDenied), }, ]; for (final invalidDirectory in invalidDirectories) { test('prevents use of invalid uri: ${invalidDirectory['dir']}', () { final dir = invalidDirectory['dir'] as String; final error = invalidDirectory['error'] as Matcher; expect( () => client.call( FileSystemServiceConstants.serviceName, FileSystemServiceConstants.readFileAsString, params: {DtdParameters.uri: '${dir}a.txt'}, ), error, ); expect( () => client.call( FileSystemServiceConstants.serviceName, FileSystemServiceConstants.writeFileAsString, params: { DtdParameters.uri: '${dir}a.txt', DtdParameters.contents: 'abc', DtdParameters.encoding: 'utf-8', }, ), error, ); expect( () => client.call( FileSystemServiceConstants.serviceName, FileSystemServiceConstants.listDirectoryContents, params: { DtdParameters.uri: dir, }, ), error, ); }); } }); }); group('unrestricted', () { setUp(() async { toolingDaemonProcess = ToolingDaemonTestProcess(unrestricted: true); await toolingDaemonProcess.start(); dtdUri = toolingDaemonProcess.uri; client = await DartToolingDaemon.connect(dtdUri); }); test('works when no roots set', () async { final fileContents = 'New file contents'; expect((await client.getIDEWorkspaceRoots()).ideWorkspaceRoots, isEmpty); final listResult = await client.listDirectoryContents(fooDirectory.uri); expect(listResult.uris, containsAll(fooDirContents)); await client.writeFileAsString(aFile.uri, fileContents); final readResult = await client.readFileAsString(aFile.uri); expect(readResult.content, fileContents); }); test( 'works when ide workspace roots set to a different directory', () async { await client.setIDEWorkspaceRoots( Random().nextInt(10000).toString(), [barDirectory.uri], ); final fileContents = 'New file contents'; final listResult = await client.listDirectoryContents(fooDirectory.uri); expect(listResult.uris, containsAll(fooDirContents)); await client.writeFileAsString(aFile.uri, fileContents); final readResult = await client.readFileAsString(aFile.uri); expect(readResult.content, fileContents); expect( (await client.getIDEWorkspaceRoots()) .ideWorkspaceRoots .map((e) => p.normalize(e.path)), [ p.normalize(barDirectory.uri.path), ]); }, ); }); }