devtools_shared: Use the file package for a file system abstraction for testing (#9872)
diff --git a/packages/devtools_app_shared/pubspec.yaml b/packages/devtools_app_shared/pubspec.yaml
index 15b3067..4ed27ca 100644
--- a/packages/devtools_app_shared/pubspec.yaml
+++ b/packages/devtools_app_shared/pubspec.yaml
@@ -15,7 +15,7 @@
dependencies:
collection: ^1.15.0
dds_service_extensions: ^2.0.0
- devtools_shared: ^13.0.0
+ devtools_shared: ^14.0.0-wip
dtd: ^4.0.0
flutter:
sdk: flutter
diff --git a/packages/devtools_extensions/pubspec.yaml b/packages/devtools_extensions/pubspec.yaml
index 7135b64..a23c14d 100644
--- a/packages/devtools_extensions/pubspec.yaml
+++ b/packages/devtools_extensions/pubspec.yaml
@@ -18,7 +18,7 @@
dependencies:
args: ^2.4.2
- devtools_shared: ^13.0.0
+ devtools_shared: ^14.0.0-wip
devtools_app_shared: ^0.5.1
flutter:
sdk: flutter
diff --git a/packages/devtools_shared/CHANGELOG.md b/packages/devtools_shared/CHANGELOG.md
index eaf9a53..a3c2483 100644
--- a/packages/devtools_shared/CHANGELOG.md
+++ b/packages/devtools_shared/CHANGELOG.md
@@ -3,8 +3,33 @@
Use of this source code is governed by a BSD-style license that can be
found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd.
-->
+# 14.0.0-wip
+
+* **Breaking changes**: `LocalFileSystem`, an extension which provided some handy
+ helpers, has been refactored into an extension on the `file` package's
+ `FileSystem` abstraction. In detail:
+ * `fileSystem` is a new top-level constant which represents the local (real)
+ file system.
+ * `LocalFileSystem.devToolsDir()` is now a static getter,
+ `FileSystemExtension.devToolsDir`.
+ * `LocalFileSystem.maybeMoveLegacyDevToolsStore()` is now an instance method,
+ `FileSystemExtension.maybeMoveLegacyDevToolsStore()`.
+ * `LocalFileSystem.devToolsStoreLocation()` is now a static getter,
+ `FileSystemExtension.devToolsStoreLocation`.
+ * `LocalFileSystem.ensureDevToolsDirectory()` is now private.
+ * `LocalFileSystem.devToolsFileFromPath()` is now an instance method,
+ `FileSystemExtension.devToolsFileFromPath()`.
+ * `LocalFileSystem.devToolsFileAsJson()` is now an instance method,
+ `FileSystemExtension.devToolsFileAsJson()`.
+ * `LocalFileSystem.flutterStoreExists()` is now an instance getter,
+ `FileSystemExtension.flutterStoreExists`.
+ * `IOPersistentProperties.new` accepts a new optional `FileSystem fs`
+ argument.
+* Update `LocalFileSystem` and `IOPersistentProperties` to use `package:file`
+ instead of `dart:io` to allow mocking the file system.
# 13.0.2
+
* Validate the `devtoolsOptionsUri` query parameter in the extension enabled
state handler so it must be a `file:` URI named `devtools_options.yaml`,
preventing arbitrary file writes by the DevTools server process.
diff --git a/packages/devtools_shared/lib/src/server/devtools_store.dart b/packages/devtools_shared/lib/src/server/devtools_store.dart
index 5efdf5d..5df2442 100644
--- a/packages/devtools_shared/lib/src/server/devtools_store.dart
+++ b/packages/devtools_shared/lib/src/server/devtools_store.dart
@@ -21,10 +21,11 @@
/// Provides access to the local DevTools store (~/.flutter-devtools/.devtools).
class DevToolsUsage {
DevToolsUsage() {
- LocalFileSystem.maybeMoveLegacyDevToolsStore();
+ // TODO(srawlins): Accept a FileSystem parameter during tests.
+ fileSystem.maybeMoveLegacyDevToolsStore();
properties = IOPersistentProperties(
storeName,
- documentDirPath: LocalFileSystem.devToolsDir(),
+ documentDirPath: FileSystemExtension.devToolsDir,
);
_removeLegacyKeys();
}
diff --git a/packages/devtools_shared/lib/src/server/file_system.dart b/packages/devtools_shared/lib/src/server/file_system.dart
index 6a2036f..450790b 100644
--- a/packages/devtools_shared/lib/src/server/file_system.dart
+++ b/packages/devtools_shared/lib/src/server/file_system.dart
@@ -3,108 +3,117 @@
// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd.
import 'dart:convert';
-import 'dart:io';
+import 'dart:io' as io show Platform, File;
+import 'package:file/file.dart';
+import 'package:file/local.dart';
import 'package:path/path.dart' as path;
import 'devtools_store.dart';
-/// A namespace local file system utlities.
-extension LocalFileSystem on Never {
- static String _userHomeDir() {
- final envKey = Platform.operatingSystem == 'windows' ? 'APPDATA' : 'HOME';
- return Platform.environment[envKey] ?? '.';
+/// The real, local file system, which can be avoided in tests.
+const FileSystem fileSystem = LocalFileSystem();
+
+extension FileSystemExtension on FileSystem {
+ static String get _userHomeDir {
+ final envKey = io.Platform.operatingSystem == 'windows'
+ ? 'APPDATA'
+ : 'HOME';
+ return io.Platform.environment[envKey] ?? '.';
}
- /// Returns the path to the DevTools storage directory.
- static String devToolsDir() {
- return path.join(_userHomeDir(), '.flutter-devtools');
+ /// The path to the DevTools storage directory.
+ static String get devToolsDir {
+ return path.join(_userHomeDir, '.flutter-devtools');
}
- /// Moves the .devtools file to ~/.flutter-devtools/.devtools if the .devtools
- /// file exists in the user's home directory.
- static void maybeMoveLegacyDevToolsStore() {
- final file = File(path.join(_userHomeDir(), DevToolsUsage.storeName));
- if (file.existsSync()) {
- ensureDevToolsDirectory();
- file.copySync(devToolsStoreLocation());
- file.deleteSync();
+ /// Moves the `.devtools` file to `~/.flutter-devtools/.devtools` if the
+ /// `.devtools` file exists in the user's home directory.
+ void maybeMoveLegacyDevToolsStore() {
+ final storeFile = file(path.join(_userHomeDir, DevToolsUsage.storeName));
+ if (storeFile.existsSync()) {
+ _ensureDevToolsDirectory();
+ storeFile.copySync(devToolsStoreLocation);
+ storeFile.deleteSync();
}
}
- static String devToolsStoreLocation() {
- return path.join(devToolsDir(), DevToolsUsage.storeName);
+ static String get devToolsStoreLocation {
+ return path.join(devToolsDir, DevToolsUsage.storeName);
}
- /// Creates the ~/.flutter-devtools directory if it does not already exist.
- static void ensureDevToolsDirectory() {
- Directory(devToolsDir()).createSync();
+ /// Creates the `~/.flutter-devtools` directory if it does not already exist.
+ void _ensureDevToolsDirectory() {
+ directory(devToolsDir).createSync();
}
/// Returns a DevTools file from the given path.
///
/// Only files within ~/.flutter-devtools/ can be accessed.
- static File? devToolsFileFromPath(String pathFromDevToolsDir) {
- if (pathFromDevToolsDir.contains('..') ||
- path.isAbsolute(pathFromDevToolsDir)) {
+ File? devToolsFileFromPath(String relativePath) {
+ if (relativePath.contains('..') || path.isAbsolute(relativePath)) {
// The passed in path should not be able to walk up the directory tree
- // outside of the ~/.flutter-devtools/ directory. It must also not be an
- // absolute path: path.join() discards the base directory when its second
- // argument is absolute, which would otherwise allow reading an arbitrary
- // file on disk (e.g. an absolute path to a credentials .json file).
+ // outside of the `~/.flutter-devtools/` directory. It must also not be an
+ // absolute path: `path.join()` discards the base directory when its
+ // second argument is absolute, which would otherwise allow reading an
+ // arbitrary file on disk (e.g. an absolute path to a credentials `.json`
+ // file).
return null;
}
- ensureDevToolsDirectory();
- final devToolsDirPath = devToolsDir();
- final file = File(path.join(devToolsDirPath, pathFromDevToolsDir));
+ _ensureDevToolsDirectory();
+ final targetFile = file(path.join(devToolsDir, relativePath));
// Defense in depth: ensure the resolved path is actually contained within
// the DevTools directory.
- if (!path.isWithin(devToolsDirPath, file.path)) {
- return null;
- }
- if (!file.existsSync()) {
- return null;
- }
- return file;
+ if (!path.isWithin(devToolsDir, targetFile.path)) return null;
+ if (!targetFile.existsSync()) return null;
+ return targetFile;
}
- /// Returns a DevTools file from the given path as encoded json.
+ /// Returns a DevTools file from the given path as encoded JSON.
///
- /// Only files within ~/.flutter-devtools/ can be accessed.
- static String? devToolsFileAsJson(String pathFromDevToolsDir) {
- final file = devToolsFileFromPath(pathFromDevToolsDir);
- if (file == null) return null;
+ /// Only files within `~/.flutter-devtools/` can be accessed.
+ String? devToolsFileAsJson(String relativePath) {
+ final targetFile = devToolsFileFromPath(relativePath);
+ if (targetFile == null) return null;
- final fileName = path.basename(file.path);
+ final fileName = path.basename(targetFile.path);
if (!fileName.endsWith('.json')) return null;
- final content = file.readAsStringSync();
+ final content = targetFile.readAsStringSync();
final json = jsonDecode(content) as Map;
- json['lastModifiedTime'] = file.lastModifiedSync().toString();
+ json['lastModifiedTime'] = targetFile.lastModifiedSync().toString();
return jsonEncode(json);
}
/// Whether the flutter store file exists.
- static bool flutterStoreExists() {
- final flutterStore = File(path.join(_userHomeDir(), '.flutter'));
+ bool get flutterStoreExists {
+ final flutterStore = file(path.join(_userHomeDir, '.flutter'));
return flutterStore.existsSync();
}
}
class IOPersistentProperties {
- IOPersistentProperties(this.name, {String? documentDirPath}) {
+ IOPersistentProperties(
+ this.name, {
+ String? documentDirPath,
+ FileSystem fs = fileSystem,
+ }) : _fs = fs {
final fileName = name.replaceAll(' ', '_');
- documentDirPath ??= LocalFileSystem._userHomeDir();
- _file = File(path.join(documentDirPath, fileName));
+ documentDirPath ??= FileSystemExtension._userHomeDir;
+ _file = _fs.file(path.join(documentDirPath, fileName));
if (!_file.existsSync()) {
_file.createSync(recursive: true);
}
syncSettings();
}
- IOPersistentProperties.fromFile(File file) : name = path.basename(file.path) {
- _file = file;
+ // TODO(srawlins): This is unused in any devtools code. Even in tests. Either
+ // test it, if it is needed by users, or remove it.
+ IOPersistentProperties.fromFile(io.File file)
+ : name = path.basename(file.path),
+ _fs = file is File ? file.fileSystem : fileSystem {
+ _file = file is File ? file : _fs.file(file.path);
if (!_file.existsSync()) {
_file.createSync(recursive: true);
}
@@ -113,7 +122,9 @@
final String name;
- late File _file;
+ final FileSystem _fs;
+
+ late final File _file;
late Map<String, Object?> _map;
diff --git a/packages/devtools_shared/lib/src/server/handlers/_app_size.dart b/packages/devtools_shared/lib/src/server/handlers/_app_size.dart
index f7f31a5..f6e61dc 100644
--- a/packages/devtools_shared/lib/src/server/handlers/_app_size.dart
+++ b/packages/devtools_shared/lib/src/server/handlers/_app_size.dart
@@ -19,7 +19,7 @@
if (missingRequiredParams != null) return missingRequiredParams;
final filePath = queryParams[AppSizeApi.baseAppSizeFilePropertyName]!;
- final fileJson = LocalFileSystem.devToolsFileAsJson(filePath);
+ final fileJson = fileSystem.devToolsFileAsJson(filePath);
if (fileJson == null) {
return api.badRequest('No JSON file available at $filePath.');
}
@@ -39,7 +39,7 @@
if (missingRequiredParams != null) return missingRequiredParams;
final filePath = queryParams[AppSizeApi.testAppSizeFilePropertyName]!;
- final fileJson = LocalFileSystem.devToolsFileAsJson(filePath);
+ final fileJson = fileSystem.devToolsFileAsJson(filePath);
if (fileJson == null) {
return api.badRequest('No JSON file available at $filePath.');
}
diff --git a/packages/devtools_shared/lib/src/server/server_api.dart b/packages/devtools_shared/lib/src/server/server_api.dart
index cc96620..b4d6b7e 100644
--- a/packages/devtools_shared/lib/src/server/server_api.dart
+++ b/packages/devtools_shared/lib/src/server/server_api.dart
@@ -72,9 +72,7 @@
// ----- Flutter Tool GA store. -----
case apiGetFlutterGAClientId:
return _encodeResponse(
- LocalFileSystem.flutterStoreExists()
- ? _flutterStore.flutterClientId
- : '',
+ fileSystem.flutterStoreExists ? _flutterStore.flutterClientId : '',
api: api,
);
@@ -223,16 +221,16 @@
: null;
}
- /// Accessing DevTools store file e.g., ~/.flutter-devtools/.devtools
+ /// Accessing DevTools store file e.g., `~/.flutter-devtools/.devtools`.
static final _devToolsStore = DevToolsUsage();
- /// Accessing Flutter store file e.g., ~/.flutter
+ /// Accessing Flutter store file e.g., `~/.flutter`.
static final _flutterStore = FlutterStore();
static DevToolsUsage get devToolsPreferences => _devToolsStore;
/// Provides read and write access to DevTools options files
- /// (e.g. path/to/app/root/devtools_options.yaml).
+ /// (e.g. `path/to/app/root/devtools_options.yaml`).
static final _devToolsOptions = DevToolsOptions();
/// Logs a page view in the DevTools server.
diff --git a/packages/devtools_shared/pubspec.yaml b/packages/devtools_shared/pubspec.yaml
index 332adaa..781cf03 100644
--- a/packages/devtools_shared/pubspec.yaml
+++ b/packages/devtools_shared/pubspec.yaml
@@ -4,7 +4,7 @@
name: devtools_shared
description: Package of shared Dart structures between devtools_app, dds, and other tools.
-version: 13.0.2
+version: 14.0.0-wip
repository: https://github.com/flutter/devtools/tree/master/packages/devtools_shared
@@ -18,6 +18,7 @@
collection: ^1.15.0
dtd: ^4.0.0
extension_discovery: ^2.1.0
+ file: ^7.0.0
meta: ^1.9.1
path: ^1.8.0
shelf: ^1.1.0
diff --git a/packages/devtools_shared/test/server/file_system_test.dart b/packages/devtools_shared/test/server/file_system_test.dart
index 78069ec..b7c2a0d 100644
--- a/packages/devtools_shared/test/server/file_system_test.dart
+++ b/packages/devtools_shared/test/server/file_system_test.dart
@@ -2,35 +2,110 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd.
-import 'package:devtools_shared/src/server/file_system.dart';
+import 'dart:convert';
+
+import 'package:devtools_shared/src/server/file_system.dart' hide fileSystem;
+import 'package:file/memory.dart';
+import 'package:path/path.dart' as path;
import 'package:test/test.dart';
void main() {
- group('LocalFileSystem.devToolsFileFromPath path validation', () {
- // These inputs must be rejected before any filesystem access so that reads
- // stay confined to the ~/.flutter-devtools/ directory.
+ group('LocalFileSystem.devToolsFileFromPath', () {
+ late MemoryFileSystem fs;
- test('rejects absolute paths', () {
- // path.join() discards the base directory when its second argument is
- // absolute, so an absolute path would otherwise escape the DevTools
- // directory and read an arbitrary file on disk.
- expect(LocalFileSystem.devToolsFileFromPath('/etc/passwd'), isNull);
- expect(
- LocalFileSystem.devToolsFileFromPath('/absolute/path/to/file.json'),
- isNull,
- );
+ setUp(() {
+ fs = MemoryFileSystem();
+ fs
+ .directory(FileSystemExtension.devToolsDir)
+ .parent
+ .createSync(recursive: true);
});
- test('rejects paths containing ".."', () {
- expect(LocalFileSystem.devToolsFileFromPath('..'), isNull);
- expect(
- LocalFileSystem.devToolsFileFromPath('../../../etc/passwd'),
- isNull,
+ group('path validation', () {
+ // These inputs must be rejected before any filesystem access so that reads
+ // stay confined to the ~/.flutter-devtools/ directory.
+
+ test('rejects absolute paths', () {
+ final root = path.style == .windows ? 'C:\\' : '/';
+ // `path.join()` discards the base directory when its second argument is
+ // absolute, so an absolute path would otherwise escape the DevTools
+ // directory and read an arbitrary file on disk.
+ expect(
+ fs.devToolsFileFromPath(path.join('${root}etc', 'passwd')),
+ isNull,
+ );
+ expect(
+ fs.devToolsFileFromPath(
+ path.join('${root}absolute', 'path', 'to', 'file.json'),
+ ),
+ isNull,
+ );
+ });
+
+ test('rejects paths containing ".."', () {
+ expect(fs.devToolsFileFromPath('..'), isNull);
+ expect(
+ fs.devToolsFileFromPath(path.join('..', '..', '..', 'etc', 'passwd')),
+ isNull,
+ );
+ expect(
+ fs.devToolsFileFromPath(
+ path.join('subdir', '..', '..', 'escape.json'),
+ ),
+ isNull,
+ );
+ });
+ });
+
+ test('returns file when path is valid and file exists', () {
+ final testFile = fs.file(
+ path.join(FileSystemExtension.devToolsDir, 'sub', 'file.json'),
+ )..createSync(recursive: true);
+
+ final file = fs.devToolsFileFromPath(path.join('sub', 'file.json'))!;
+ expect(file.path, testFile.path);
+ });
+
+ test('returns null when path is valid but file does not exist', () {
+ final file = fs.devToolsFileFromPath(
+ path.join('sub', 'non_existent.json'),
);
- expect(
- LocalFileSystem.devToolsFileFromPath('subdir/../../escape.json'),
- isNull,
- );
+ expect(file, isNull);
+ });
+ });
+
+ group('LocalFileSystem.devToolsFileAsJson', () {
+ late MemoryFileSystem fs;
+
+ setUp(() {
+ fs = MemoryFileSystem();
+ fs
+ .directory(FileSystemExtension.devToolsDir)
+ .parent
+ .createSync(recursive: true);
+ });
+
+ test('returns null if file does not exist', () {
+ expect(fs.devToolsFileAsJson('test.json'), isNull);
+ });
+
+ test('returns null if file is not json', () {
+ fs.file(path.join(FileSystemExtension.devToolsDir, 'test.txt'))
+ ..createSync(recursive: true)
+ ..writeAsStringSync('hello');
+ expect(fs.devToolsFileAsJson('test.txt'), isNull);
+ });
+
+ test('returns json content with lastModifiedTime', () {
+ final file = fs.file(
+ path.join(FileSystemExtension.devToolsDir, 'test.json'),
+ )..createSync(recursive: true);
+ file.writeAsStringSync('{"key": "value"}');
+
+ final jsonStr = fs.devToolsFileAsJson('test.json')!;
+ final json = jsonDecode(jsonStr) as Map;
+ expect(json['key'], 'value');
+ expect(json['lastModifiedTime'], file.lastModifiedSync().toString());
});
});
}
diff --git a/packages/devtools_shared/test/server/persistent_properties_test.dart b/packages/devtools_shared/test/server/persistent_properties_test.dart
index 4f78b19..fd337ef 100644
--- a/packages/devtools_shared/test/server/persistent_properties_test.dart
+++ b/packages/devtools_shared/test/server/persistent_properties_test.dart
@@ -3,36 +3,37 @@
// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd.
import 'dart:convert';
-import 'dart:io';
import 'package:devtools_shared/src/server/file_system.dart';
+import 'package:file/file.dart';
+import 'package:file/memory.dart';
+import 'package:path/path.dart' as path;
import 'package:test/test.dart';
void main() {
group('IOPersistentProperties', () {
+ late MemoryFileSystem fs;
late Directory tempDir;
late IOPersistentProperties properties;
const storeName = 'test_store';
setUp(() {
- tempDir = Directory.systemTemp.createTempSync(
+ fs = MemoryFileSystem();
+ tempDir = fs.systemTempDirectory.createTempSync(
'persistent_properties_test',
);
properties = IOPersistentProperties(
storeName,
documentDirPath: tempDir.path,
+ fs: fs,
);
});
- tearDown(() {
- tempDir.deleteSync(recursive: true);
- });
-
test('remove persists changes to disk', () {
properties['key1'] = 'value1';
properties['key2'] = 'value2';
- final file = File('${tempDir.path}/$storeName');
+ final file = fs.file(path.join(tempDir.path, storeName));
expect(file.existsSync(), isTrue);
var content = file.readAsStringSync();
diff --git a/pubspec.lock b/pubspec.lock
index cc8b58e..78ac48a 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -181,10 +181,10 @@
dependency: transitive
description:
name: cross_file
- sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937"
+ sha256: d687bec93342bf6a764a116d15c8694ebeff10e633dc28a39dd3144f7195024e
url: "https://pub.dev"
source: hosted
- version: "0.3.5+2"
+ version: "0.3.5+3"
crypto:
dependency: transitive
description: