The reading of the full reading's fixes found that writePrivately made the file, then set it to 0600: another local user could open it in that window and keep reading what was written after; and that where the path was a link or another user's file, chmod failed and the secret was written into it anyway. On Linux and macOS it now makes a new file beside the one chosen, 0600 as it is created (O_EXCL, O_NOFOLLOW), writes through the descriptor that made it, and renames it over the chosen path, so the old file is never written into; a symbolic link or another user's file is refused, with nothing written (lstat through FFI, by each system's layout). Windows writes in place, with its folder's rights. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
126 lines
4.5 KiB
Dart
126 lines
4.5 KiB
Dart
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;
|
|
|
|
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')));
|
|
|
|
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<FileSystemException>()),
|
|
);
|
|
|
|
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<FileSystemException>().having(
|
|
(error) => error.message,
|
|
'message',
|
|
"Another user's file",
|
|
),
|
|
),
|
|
);
|
|
expect(File(rootsFile).readAsStringSync(), before);
|
|
},
|
|
skip: !hasModes || !File('/etc/hostname').existsSync()
|
|
? 'no such file'
|
|
: false,
|
|
);
|
|
|
|
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);
|
|
});
|
|
}
|