import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; import 'package:halkyon_app/halkyon_app.dart'; import 'package:path/path.dart' as p; void main() { late Directory folder; setUp(() async { folder = await Directory.systemTemp.createTemp('file_saver_test'); }); tearDown(() => folder.delete(recursive: true)); test("a saved file is the own user's alone where the platform has modes, " 'set before anything is written', () async { final path = p.join(folder.path, 'halkyon-recovery-codes.txt'); // One there before, readable by everyone. await File(path).writeAsString('old'); if (!Platform.isWindows) { await Process.run('chmod', ['644', path]); } await writePrivately(path, Uint8List.fromList([1, 2, 3])); expect(await File(path).readAsBytes(), [1, 2, 3]); if (Platform.isLinux || Platform.isMacOS) { expect(File(path).statSync().mode & 0x1ff, 0x180, reason: '0600'); } }, skip: Platform.isWindows ? 'no modes' : false); final hasModes = Platform.isLinux || Platform.isMacOS; // Root may write anyone's files: the refusals of another user's can't be // seen as root (the short reading, L6). final isRoot = hasModes && Process.runSync('id', ['-u']).stdout.toString().trim() == '0'; test('a saved file replaces the one there without writing into it: ' 'whoever held the old file open, or a link to it, never reads what is ' 'saved (the reading of the fixes)', () async { final path = p.join(folder.path, 'halkyon-recovery-codes.txt'); await File(path).writeAsString('old'); await Process.run('chmod', ['644', path]); // Another name for the same file, as a descriptor opened earlier is. final elsewhere = p.join(folder.path, 'held-open'); await Process.run('ln', [path, elsewhere]); await writePrivately( path, Uint8List.fromList(utf8.encode('secret')), inPlace: false, ); expect(await File(path).readAsString(), 'secret'); expect(File(path).statSync().mode & 0x1ff, 0x180, reason: '0600'); expect(await File(elsewhere).readAsString(), 'old'); // Nothing left beside it. expect( folder.listSync().map((entry) => p.basename(entry.path)).toSet(), {'halkyon-recovery-codes.txt', 'held-open'}, ); }, skip: hasModes ? false : 'no modes'); test('never follows a symbolic link where the file is to be saved: ' 'refused, the file it points to untouched', () async { final target = p.join(folder.path, 'someone-elses.txt'); await File(target).writeAsString('theirs'); final path = p.join(folder.path, 'halkyon-recovery-phrase.txt'); await Link(path).create(target); await expectLater( writePrivately(path, Uint8List.fromList(utf8.encode('secret'))), throwsA(isA()), ); expect(await File(target).readAsString(), 'theirs'); expect(FileSystemEntity.isLinkSync(path), isTrue); expect( folder.listSync().map((entry) => p.basename(entry.path)).toSet(), {'someone-elses.txt', 'halkyon-recovery-phrase.txt'}, ); }, skip: hasModes ? false : 'no modes'); test( "refuses a file another user owns, writing nothing: a root's file " 'stands in for one in a shared folder', () async { const rootsFile = '/etc/hostname'; final before = File(rootsFile).readAsStringSync(); await expectLater( writePrivately(rootsFile, Uint8List.fromList([1])), throwsA( isA().having( (error) => error.message, 'message', "Another user's file", ), ), ); expect(File(rootsFile).readAsStringSync(), before); }, skip: !hasModes || !File('/etc/hostname').existsSync() ? 'no such file' : isRoot ? "root writes anyone's files" : false, ); test('a name as long as a folder takes is saved: the file made beside it ' 'is named apart from it (the short reading)', () async { final path = p.join(folder.path, '${'a' * 245}.txt'); await writePrivately(path, Uint8List.fromList([7]), inPlace: false); expect(await File(path).readAsBytes(), [7]); expect(folder.listSync(), hasLength(1)); }, skip: hasModes ? false : 'no modes'); group("in place, as macOS's sandbox allows only the file chosen (the short " 'reading, M1)', () { test('the chosen file is made 0600 before anything is written, and ' 'what it held goes whole', () async { final path = p.join(folder.path, 'halkyon-recovery-codes.txt'); await File(path).writeAsString('old, and longer than what is saved'); await Process.run('chmod', ['644', path]); await writePrivately(path, Uint8List.fromList([1, 2, 3]), inPlace: true); expect(await File(path).readAsBytes(), [1, 2, 3]); expect(File(path).statSync().mode & 0x1ff, 0x180, reason: '0600'); expect(folder.listSync(), hasLength(1)); }); test('a new file is made 0600', () async { final path = p.join(folder.path, 'new.txt'); await writePrivately(path, Uint8List.fromList([4, 5]), inPlace: true); expect(await File(path).readAsBytes(), [4, 5]); expect(File(path).statSync().mode & 0x1ff, 0x180, reason: '0600'); }); test('never follows a symbolic link, to a file or to none', () async { final target = p.join(folder.path, 'someone-elses.txt'); await File(target).writeAsString('theirs'); final link = p.join(folder.path, 'halkyon-recovery-phrase.txt'); await Link(link).create(target); final dangling = p.join(folder.path, 'dangling.txt'); await Link(dangling).create(p.join(folder.path, 'nowhere.txt')); for (final path in [link, dangling]) { await expectLater( writePrivately(path, Uint8List.fromList([1]), inPlace: true), throwsA(isA()), reason: path, ); } expect(await File(target).readAsString(), 'theirs'); expect(File(p.join(folder.path, 'nowhere.txt')).existsSync(), isFalse); }); test('refuses what is no file, never waiting on a pipe', () async { final pipe = p.join(folder.path, 'pipe'); await Process.run('mkfifo', [pipe]); for (final path in [pipe, folder.path]) { await expectLater( writePrivately(path, Uint8List.fromList([1]), inPlace: true), throwsA(isA()), reason: path, ); } }); test("refuses another user's file, writing nothing", () async { const rootsFile = '/etc/hostname'; final before = File(rootsFile).readAsStringSync(); await expectLater( writePrivately(rootsFile, Uint8List.fromList([1]), inPlace: true), throwsA(isA()), ); expect(File(rootsFile).readAsStringSync(), before); }, skip: isRoot || !File('/etc/hostname').existsSync() ? 'root' : false); }, skip: hasModes ? false : 'no modes'); test('a new file is made 0600 from the start, with nothing left beside ' 'it', () async { final path = p.join(folder.path, 'new.txt'); await writePrivately(path, Uint8List.fromList([4, 5, 6])); expect(await File(path).readAsBytes(), [4, 5, 6]); expect(File(path).statSync().mode & 0x1ff, 0x180, reason: '0600'); expect(folder.listSync(), hasLength(1)); }, skip: hasModes ? false : 'no modes'); test("the system picker's copy in the temporary folder is removed, " 'others left', () async { final copy = File(p.join(folder.path, 'halkyon-recovery-phrase.txt')); final other = File(p.join(folder.path, 'something-else.txt')); await copy.writeAsString('the phrase'); await other.writeAsString('other'); await forgetPickerCopy('halkyon-recovery-phrase.txt', temporary: folder); await forgetPickerCopy('not-there.txt', temporary: folder); expect(copy.existsSync(), isFalse); expect(other.existsSync(), isTrue); }); }