halkyon/packages/halkyon_app/test/files/file_saver_test.dart

215 lines
7.9 KiB
Dart
Raw Normal View History

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;
fix(app): a saved secret is 0600 on Apple's Arm too, and macOS saves at all The short reading's H1: open(2) takes its mode among variable arguments, which Apple's Arm passes on the stack, so bound with three fixed ones it read the mode from whatever the stack held: a phrase or recovery codes saved on an Apple silicon Mac could be readable by everyone. It is bound as variadic now (VarArgs), and every file written is made 0600 through its descriptor (fchmod), checked. M1: macOS's sandbox lets an app reach the file the person chose and nothing beside it, so the file made beside it was refused and nothing could be saved; Learn's sandbox allowed reading chosen files alone, so its exports never saved on macOS either. There, writePrivately now writes the chosen file itself (decided by the developer on 21 Oct): opened without following a link or waiting on a pipe and without truncating, refused with nothing written unless fstat finds a file of the own user's, then made 0600 and emptied. Both apps' entitlements grant reading and writing the files the person chooses. Linux keeps the new file renamed over it. The reading's nits: the buffer is wiped whatever happens, an interrupted call is made again (EINTR), the file made beside a chosen one is named apart from it so a name as long as a folder takes still saves, the folder is flushed after the rename, and Linux reads a file's type and owner with statx, alike on every architecture, where __lxstat's version differed (riscv64's was wrong). The test of another user's file is skipped as root. The in-place path is tested on Linux; none of it has run on a Mac. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 17:57:44 +02:00
// 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]);
fix(app): a saved secret is 0600 on Apple's Arm too, and macOS saves at all The short reading's H1: open(2) takes its mode among variable arguments, which Apple's Arm passes on the stack, so bound with three fixed ones it read the mode from whatever the stack held: a phrase or recovery codes saved on an Apple silicon Mac could be readable by everyone. It is bound as variadic now (VarArgs), and every file written is made 0600 through its descriptor (fchmod), checked. M1: macOS's sandbox lets an app reach the file the person chose and nothing beside it, so the file made beside it was refused and nothing could be saved; Learn's sandbox allowed reading chosen files alone, so its exports never saved on macOS either. There, writePrivately now writes the chosen file itself (decided by the developer on 21 Oct): opened without following a link or waiting on a pipe and without truncating, refused with nothing written unless fstat finds a file of the own user's, then made 0600 and emptied. Both apps' entitlements grant reading and writing the files the person chooses. Linux keeps the new file renamed over it. The reading's nits: the buffer is wiped whatever happens, an interrupted call is made again (EINTR), the file made beside a chosen one is named apart from it so a name as long as a folder takes still saves, the folder is flushed after the rename, and Linux reads a file's type and owner with statx, alike on every architecture, where __lxstat's version differed (riscv64's was wrong). The test of another user's file is skipped as root. The in-place path is tested on Linux; none of it has run on a Mac. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 17:57:44 +02:00
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<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'
fix(app): a saved secret is 0600 on Apple's Arm too, and macOS saves at all The short reading's H1: open(2) takes its mode among variable arguments, which Apple's Arm passes on the stack, so bound with three fixed ones it read the mode from whatever the stack held: a phrase or recovery codes saved on an Apple silicon Mac could be readable by everyone. It is bound as variadic now (VarArgs), and every file written is made 0600 through its descriptor (fchmod), checked. M1: macOS's sandbox lets an app reach the file the person chose and nothing beside it, so the file made beside it was refused and nothing could be saved; Learn's sandbox allowed reading chosen files alone, so its exports never saved on macOS either. There, writePrivately now writes the chosen file itself (decided by the developer on 21 Oct): opened without following a link or waiting on a pipe and without truncating, refused with nothing written unless fstat finds a file of the own user's, then made 0600 and emptied. Both apps' entitlements grant reading and writing the files the person chooses. Linux keeps the new file renamed over it. The reading's nits: the buffer is wiped whatever happens, an interrupted call is made again (EINTR), the file made beside a chosen one is named apart from it so a name as long as a folder takes still saves, the folder is flushed after the rename, and Linux reads a file's type and owner with statx, alike on every architecture, where __lxstat's version differed (riscv64's was wrong). The test of another user's file is skipped as root. The in-place path is tested on Linux; none of it has run on a Mac. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 17:57:44 +02:00
: isRoot
? "root writes anyone's files"
: false,
);
fix(app): a saved secret is 0600 on Apple's Arm too, and macOS saves at all The short reading's H1: open(2) takes its mode among variable arguments, which Apple's Arm passes on the stack, so bound with three fixed ones it read the mode from whatever the stack held: a phrase or recovery codes saved on an Apple silicon Mac could be readable by everyone. It is bound as variadic now (VarArgs), and every file written is made 0600 through its descriptor (fchmod), checked. M1: macOS's sandbox lets an app reach the file the person chose and nothing beside it, so the file made beside it was refused and nothing could be saved; Learn's sandbox allowed reading chosen files alone, so its exports never saved on macOS either. There, writePrivately now writes the chosen file itself (decided by the developer on 21 Oct): opened without following a link or waiting on a pipe and without truncating, refused with nothing written unless fstat finds a file of the own user's, then made 0600 and emptied. Both apps' entitlements grant reading and writing the files the person chooses. Linux keeps the new file renamed over it. The reading's nits: the buffer is wiped whatever happens, an interrupted call is made again (EINTR), the file made beside a chosen one is named apart from it so a name as long as a folder takes still saves, the folder is flushed after the rename, and Linux reads a file's type and owner with statx, alike on every architecture, where __lxstat's version differed (riscv64's was wrong). The test of another user's file is skipped as root. The in-place path is tested on Linux; none of it has run on a Mac. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-21 17:57:44 +02:00
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<FileSystemException>()),
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<FileSystemException>()),
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<FileSystemException>()),
);
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);
});
}