Skip to content

Commit 69a048f

Browse files
authored
[flutter_tools, devicelab] Add safety filesystem guard to tests (flutter#186748)
Implement `FSGuardIOOverrides` along with custom wrapping proxies (`GuardedFile`, `GuardedDirectory`, and `GuardedLink`) to intercept and isolate filesystem modifications during test execution. This prevents tests in `flutter_tools` and `devicelab` from executing destructive filesystem operations (e.g., writes, deletes, and creations) outside the system temporary directory, while still permitting safe non-modifying operations (like reading templates and source files). Provides an unstageable environment variable bypass toggle `FLUTTER_TEST_DISABLE_FS_GUARD=true` to allow developers to safely deactivate the guard for local debugging and custom logging without the risk of committing configuration overrides to git. Fixes flutter#186419
1 parent 3c01cc8 commit 69a048f

9 files changed

Lines changed: 1456 additions & 10 deletions

File tree

dev/devicelab/lib/framework/fs_safety.dart

Lines changed: 661 additions & 0 deletions
Large diffs are not rendered by default.

dev/devicelab/test/common.dart

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,40 @@
22
// Use of this source code is governed by a BSD-style license that can be
33
// found in the LICENSE file.
44

5-
import 'package:test/test.dart';
5+
import 'dart:async';
6+
import 'dart:io' as io;
67

7-
export 'package:test/test.dart' hide isInstanceOf;
8+
import 'package:flutter_devicelab/framework/fs_safety.dart';
9+
import 'package:meta/meta.dart';
10+
import 'package:test/test.dart' as test_package show test;
11+
import 'package:test/test.dart' hide test;
12+
13+
export 'package:test/test.dart' hide isInstanceOf, test;
814

915
/// A matcher that compares the type of the actual value to the type argument T.
1016
TypeMatcher<T> isInstanceOf<T>() => isA<T>();
17+
18+
@isTest
19+
void test(
20+
String description,
21+
FutureOr<void> Function() body, {
22+
String? testOn,
23+
Timeout? timeout,
24+
dynamic skip,
25+
List<String>? tags,
26+
Map<String, dynamic>? onPlatform,
27+
int? retry,
28+
}) {
29+
test_package.test(
30+
description,
31+
() async {
32+
return io.IOOverrides.runWithIOOverrides(() => body(), FSGuardIOOverrides());
33+
},
34+
skip: skip,
35+
tags: tags,
36+
onPlatform: onPlatform,
37+
retry: retry,
38+
testOn: testOn,
39+
timeout: timeout,
40+
);
41+
}

dev/devicelab/test/utils_test.dart

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
// Use of this source code is governed by a BSD-style license that can be
33
// found in the LICENSE file.
44

5+
import 'dart:io' as io;
56
import 'package:flutter_devicelab/framework/utils.dart';
7+
import 'package:path/path.dart' as path;
68

79
import 'common.dart';
810

@@ -41,4 +43,35 @@ void main() {
4143
expect(localEngineSrcPathFromEnv, null);
4244
});
4345
});
46+
47+
group('filesystem safety guard', () {
48+
test('isolates modifications to system temp directory', () {
49+
final tempFile = io.File(
50+
path.join(io.Directory.systemTemp.path, 'devicelab_fs_guard_test_safe.txt'),
51+
);
52+
addTearDown(() {
53+
if (tempFile.existsSync()) {
54+
tempFile.deleteSync();
55+
}
56+
});
57+
// Writing under system temp should succeed
58+
tempFile.writeAsStringSync('safe-devicelab-content');
59+
expect(tempFile.readAsStringSync(), 'safe-devicelab-content');
60+
61+
// Modifying outside system temp should fail and throw our guarded exception
62+
final String root = path.rootPrefix(io.Directory.current.absolute.path);
63+
final unsafeFile = io.File(path.join(root, 'tmp_unsafe_devicelab.txt'));
64+
expect(unsafeFile.existsSync(), false);
65+
expect(
66+
() => unsafeFile.writeAsStringSync('unsafe-content'),
67+
throwsA(
68+
isA<io.FileSystemException>().having(
69+
(e) => e.message,
70+
'message',
71+
contains('Test attempted to modify file outside of temp directory'),
72+
),
73+
),
74+
);
75+
});
76+
});
4477
}

packages/flutter_tools/test/general.shard/base/io_test.dart

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@ import 'package:file/memory.dart';
99
import 'package:flutter_tools/src/base/exit.dart';
1010
import 'package:flutter_tools/src/base/io.dart';
1111
import 'package:flutter_tools/src/base/platform.dart';
12+
import 'package:path/path.dart' as path; // flutter_ignore: package_path_import
1213
import 'package:test/fake.dart';
1314

1415
import '../../src/common.dart';
16+
import '../../src/fs_safety.dart';
1517
import '../../src/io.dart';
1618

1719
void main() {
@@ -115,6 +117,38 @@ void main() {
115117
expect(await PosixProcessSignal(fakeSignalA, platform: windows).watch().isEmpty, true);
116118
expect(await PosixProcessSignal(fakeSignalB, platform: linux).watch().first, isNotNull);
117119
});
120+
121+
testWithoutContext(
122+
'FSGuardIOOverrides isolates filesystem modifications to system temp directory',
123+
() {
124+
io.IOOverrides.runWithIOOverrides(() {
125+
final tempFile = io.File(path.join(io.Directory.systemTemp.path, 'fs_guard_test_safe.txt'));
126+
addTearDown(() {
127+
if (tempFile.existsSync()) {
128+
tempFile.deleteSync();
129+
}
130+
});
131+
// Writing under system temp should succeed
132+
tempFile.writeAsStringSync('safe-content');
133+
expect(tempFile.readAsStringSync(), 'safe-content');
134+
135+
// Modifying outside system temp should fail and throw our guarded exception
136+
final String root = path.rootPrefix(io.Directory.current.absolute.path);
137+
final unsafeFile = io.File(path.join(root, 'tmp_unsafe_outside_temp.txt'));
138+
expect(unsafeFile.existsSync(), false);
139+
expect(
140+
() => unsafeFile.writeAsStringSync('unsafe-content'),
141+
throwsA(
142+
isA<io.FileSystemException>().having(
143+
(e) => e.message,
144+
'message',
145+
contains('Test attempted to modify file outside of temp directory'),
146+
),
147+
),
148+
);
149+
}, FSGuardIOOverrides());
150+
},
151+
);
118152
}
119153

120154
class FakeProcessSignal extends Fake implements io.ProcessSignal {

packages/flutter_tools/test/general.shard/isolated/fake_native_assets_build_runner.dart

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
// Use of this source code is governed by a BSD-style license that can be
33
// found in the LICENSE file.
44

5+
import 'dart:io' as io;
6+
57
import 'package:code_assets/code_assets.dart';
68
import 'package:data_assets/data_assets.dart';
79
import 'package:file/file.dart';
@@ -40,13 +42,23 @@ class FakeFlutterNativeAssetsBuildRunner implements FlutterNativeAssetsBuildRunn
4042
required bool linkingEnabled,
4143
}) async {
4244
BuildResult? result = buildResult;
45+
final io.Directory tempDir = io.Directory.systemTemp.createTempSync(
46+
'flutter_native_assets_test.',
47+
);
48+
final String tempPath = tempDir.path;
4349
for (final String package in packagesWithNativeAssetsResult) {
50+
final packageDir = io.Platform.isWindows ? '$tempPath\\$package' : '$tempPath/$package';
51+
final sharedDir = io.Platform.isWindows
52+
? '$tempPath\\build-out-dir-shared'
53+
: '$tempPath/build-out-dir-shared';
4454
final input = BuildInputBuilder()
4555
..setupShared(
46-
packageRoot: Uri.parse('$package/'),
56+
packageRoot: Uri.file('$packageDir/'),
4757
packageName: package,
48-
outputDirectoryShared: Uri.parse('build-out-dir-shared'),
49-
outputFile: Uri.file('output.json'),
58+
outputDirectoryShared: Uri.file('$sharedDir/'),
59+
outputFile: Uri.file(
60+
io.Platform.isWindows ? '$packageDir\\output.json' : '$packageDir/output.json',
61+
),
5062
)
5163
..setupBuildInput()
5264
..config.setupBuild(linkingEnabled: linkingEnabled);

packages/flutter_tools/test/integration.shard/xcode_backend_test.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ void main() {
123123
'add keys in $buildConfiguration under ${verbose ? 'verbose' : 'non-verbose'} mode',
124124
() async {
125125
infoPlist.writeAsStringSync(emptyPlist);
126-
final File pipe = fileSystem.file('/tmp/pipe')..createSync(recursive: true);
126+
final File pipe = buildDirectory.childFile('pipe')..createSync(recursive: true);
127127

128128
final ProcessResult result = await Process.run(
129129
xcodeBackendPath,

packages/flutter_tools/test/src/common.dart

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// found in the LICENSE file.
44

55
import 'dart:async';
6+
import 'dart:io' as io show IOOverrides;
67

78
import 'package:args/command_runner.dart';
89
import 'package:collection/collection.dart';
@@ -20,6 +21,7 @@ import 'package:test/test.dart' hide test;
2021
import 'package:unified_analytics/unified_analytics.dart';
2122

2223
import 'fakes.dart';
24+
import 'fs_safety.dart';
2325

2426
export 'package:path/path.dart' show Context; // flutter_ignore: package_path_import
2527
export 'package:test/test.dart' hide isInstanceOf, test;
@@ -189,7 +191,7 @@ void test(
189191
await globals.localFileSystem.dispose();
190192
});
191193

192-
return body();
194+
return io.IOOverrides.runWithIOOverrides(() => body(), FSGuardIOOverrides());
193195
},
194196
skip: skip,
195197
tags: tags,

0 commit comments

Comments
 (0)