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>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-21 17:57:44 +02:00
commit 290f584329
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
7 changed files with 413 additions and 110 deletions

View file

@ -4,7 +4,7 @@
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.files.user-selected.read-only</key>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.cs.allow-jit</key>
<true/>

View file

@ -4,7 +4,7 @@
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.files.user-selected.read-only</key>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>

View file

@ -4,6 +4,8 @@
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.network.client</key>

View file

@ -4,6 +4,8 @@
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
</dict>

View file

@ -104,12 +104,20 @@ Written as the apps are: Dart 3.13's primary constructors, Material from
compiler and make: sodium's build hook compiles libsodium.
- **Files** (`FileSaver`, `PlatformFileSaver`): keeping a file the app made
where the person chooses (a recovery phrase, recovery codes, an app's
exports): on Linux and macOS the own user's alone from the start
(`writePrivately`: a new file beside it, made 0600 with `O_EXCL`, written
through its descriptor, renamed over the one chosen, never written into;
a symbolic link or another user's file refused), on Windows written in
place with its folder's rights, on iOS without the copy file_picker leaves
in the temporary folder (`forgetPickerCopy`).
exports): on Linux and macOS the own user's alone (`writePrivately`). On
Linux a new file beside it, made 0600 with `O_EXCL`, written through its
descriptor, renamed over the one chosen, never written into, and the
folder flushed. On macOS, whose sandbox lets an app reach the file chosen
and nothing beside it, the file itself, opened without following a link
or waiting on a pipe, refused unless it is a file of the own user's, made
0600 and emptied before anything is written; whoever held it open reads
what is saved. Each app's macOS entitlements grant reading and writing
the files the person chooses (`com.apple.security.files.user-selected.read-write`),
without which nothing could be saved there, Learn's exports neither. On
both, a symbolic link or another user's file is refused with nothing
written. On Windows written in place with its folder's rights, on iOS
without the copy file_picker leaves in the temporary folder
(`forgetPickerCopy`). None of it has run on a Mac yet.
- **Sync** (`lib/src/sync/`): `SyncService` (once at start, shortly after
local changes, on resume and every few minutes, one sync at a time, counting
the changes not on the server yet, `PendingChangesStore`), `AppCompatibility`

View file

@ -1,12 +1,13 @@
import 'dart:developer';
import 'dart:ffi';
import 'dart:io';
import 'dart:math' show Random;
import 'dart:math' show Random, min;
import 'dart:typed_data';
import 'package:ffi/ffi.dart';
import 'package:file_picker/file_picker.dart' as file_picker;
import 'package:file_selector/file_selector.dart';
import 'package:flutter/foundation.dart' show visibleForTesting;
import 'package:path/path.dart' as p;
/// Lets the person keep a file the app made, where they choose.
@ -60,27 +61,62 @@ class PlatformFileSaver() implements FileSaver {
}
/// Writes [bytes] to [path] so that nobody but the own user ever reads them
/// (the reading of the fixes), where the platform has modes (Linux, macOS):
/// into a new file beside it, made 0600 as it is created (`O_EXCL`, so
/// never one another user made first) and written through the descriptor
/// that made it, then renamed over [path]. So the file the person chose is
/// replaced whole, never written into: whoever held the old one open, or a
/// link to it, reads only what it held. Refused ([FileSystemException]),
/// with nothing written, where [path] is a symbolic link (never followed)
/// or a file another user owns. Windows' files keep their folder's rights,
/// a user's own folders being theirs alone, and are written in place.
Future<void> writePrivately(String path, Uint8List bytes) async {
/// (the reading of the fixes), where the platform has modes (Linux, macOS).
///
/// On Linux into a new file beside it, made 0600 as it is created (`O_EXCL`,
/// so never one another user made first) and written through the
/// descriptor that made it, then renamed over [path], and the folder
/// flushed. So the file the person chose is replaced whole, never written
/// into: whoever held the old one open, or a link to it, reads only what it
/// held. Refused, with nothing written, where [path] is a symbolic link
/// (never followed) or a file another user owns.
///
/// On macOS in place ([inPlace]; the short reading's M1), as its sandbox
/// lets the app reach the file the person chose and nothing beside it: the
/// file is opened without following a link or waiting on a pipe, refused
/// with nothing written unless it is a file of the own user's, then made
/// 0600 and emptied before anything is written. Whoever held it open, or
/// a link to it, reads what is saved. The apps' sandbox grants reading and
/// writing the files the person chooses
/// (`com.apple.security.files.user-selected.read-write`), which Learn's
/// exports needed too.
///
/// Windows' files keep their folder's rights, a user's own folders being
/// theirs alone, and are written in place.
///
/// [FileSystemException] for a refusal or a failure.
Future<void> writePrivately(
String path,
Uint8List bytes, {
@visibleForTesting bool? inPlace,
}) async {
if (!Platform.isLinux && !Platform.isMacOS) {
await File(path).writeAsBytes(bytes, flush: true);
return;
}
if (inPlace ?? Platform.isMacOS) {
_writeInPlace(path, bytes);
} else {
_writeAndRename(path, bytes);
}
}
/// [writePrivately] on Linux: a new file beside [path], renamed over it.
void _writeAndRename(String path, Uint8List bytes) {
_refuseLinkOrOthers(path);
final temporary = p.join(
p.dirname(path),
'.${p.basename(path)}.${_randomHex()}.tmp',
);
// Named apart from [path], so a name as long as the folder takes still
// leaves room (the short reading).
final temporary = p.join(p.dirname(path), '.halkyon-${_randomHex()}.tmp');
try {
_writeNew(temporary, bytes);
final fd = _posix.open(temporary, _posix.newFileFlags);
try {
_posix
..makePrivate(fd, temporary)
..writeAll(fd, bytes, temporary)
..flush(fd, temporary);
} finally {
_posix.close(fd);
}
// Checked once more: a link put there meanwhile is replaced by the
// rename, never followed, but the person is told.
_refuseLinkOrOthers(path);
@ -89,6 +125,29 @@ Future<void> writePrivately(String path, Uint8List bytes) async {
_posix.unlinkQuietly(temporary);
rethrow;
}
_posix.flushFolder(p.dirname(path));
}
/// [writePrivately] on macOS: [path] itself, checked through the
/// descriptor that opened it.
void _writeInPlace(String path, Uint8List bytes) {
final fd = _posix.open(path, _posix.chosenFileFlags);
try {
final found = _posix.fstat(fd, path);
if (!found.isFile) {
throw FileSystemException('Not a file', path);
}
if (found.owner != _posix.geteuid()) {
throw FileSystemException("Another user's file", path);
}
_posix
..makePrivate(fd, path)
..empty(fd, path)
..writeAll(fd, bytes, path)
..flush(fd, path);
} finally {
_posix.close(fd);
}
}
/// Removes the copy of the file named [name] that file_picker (checked with
@ -133,90 +192,118 @@ void _refuseLinkOrOthers(String path) {
}
}
/// Makes [path], which mustn't exist, 0600 as it is created, and writes
/// [bytes] through the descriptor that made it.
void _writeNew(String path, Uint8List bytes) {
final fd = _posix.openNew(path);
try {
final buffer = malloc<Uint8>(bytes.isEmpty ? 1 : bytes.length);
try {
buffer.asTypedList(bytes.length).setAll(0, bytes);
var written = 0;
while (written < bytes.length) {
final wrote = _posix.write(
fd,
buffer + written,
bytes.length - written,
);
if (wrote <= 0) {
throw FileSystemException('Writing failed', path);
}
written += wrote;
}
// Wiped, as it may hold a secret.
buffer.asTypedList(bytes.length).fillRange(0, bytes.length, 0);
} finally {
malloc.free(buffer);
}
if (_posix.fsync(fd) != 0) {
throw FileSystemException('Flushing failed', path);
}
} finally {
_posix.close(fd);
}
}
final _posix = _Posix();
/// A file's type and owner, as `stat` reads them.
typedef _Found = ({bool isLink, bool isFile, int owner});
/// The few calls of the C library [writePrivately] needs, through the
/// library already in the process: dart:io sets no modes, creates nothing
/// exclusively, and running chmod(1) would leave a sandboxed macOS app
/// without the file's access.
/// without the file's access. Each is a leaf call, so no Dart runs between
/// a call and reading its `errno`.
final class _Posix() {
final DynamicLibrary _library = DynamicLibrary.process();
/// open(2), whose mode comes among its variable arguments: Apple's Arm
/// passes those on the stack, not in a register, so a mode bound as a
/// fixed argument reached it as whatever the stack held (the short
/// reading's H1).
late final _open = _library
.lookupFunction<
Int32 Function(Pointer<Utf8>, Int32, Uint32),
Int32 Function(Pointer<Utf8>, Int32, VarArgs<(Uint32,)>),
int Function(Pointer<Utf8>, int, int)
>('open');
late final write = _library
>('open', isLeaf: true);
late final _write = _library
.lookupFunction<
IntPtr Function(Int32, Pointer<Uint8>, IntPtr),
int Function(int, Pointer<Uint8>, int)
>('write');
late final fsync = _library
.lookupFunction<Int32 Function(Int32), int Function(int)>('fsync');
late final close = _library
.lookupFunction<Int32 Function(Int32), int Function(int)>('close');
>('write', isLeaf: true);
late final _fsync = _library
.lookupFunction<Int32 Function(Int32), int Function(int)>(
'fsync',
isLeaf: true,
);
late final _fchmod = _library
.lookupFunction<Int32 Function(Int32, Uint32), int Function(int, int)>(
'fchmod',
isLeaf: true,
);
late final _ftruncate = _library
.lookupFunction<Int32 Function(Int32, Int64), int Function(int, int)>(
'ftruncate',
isLeaf: true,
);
late final _close = _library
.lookupFunction<Int32 Function(Int32), int Function(int)>(
'close',
isLeaf: true,
);
late final _rename = _library
.lookupFunction<
Int32 Function(Pointer<Utf8>, Pointer<Utf8>),
int Function(Pointer<Utf8>, Pointer<Utf8>)
>('rename');
>('rename', isLeaf: true);
late final _unlink = _library
.lookupFunction<
Int32 Function(Pointer<Utf8>),
int Function(Pointer<Utf8>)
>('unlink');
>('unlink', isLeaf: true);
late final geteuid = _library
.lookupFunction<Uint32 Function(), int Function()>('geteuid');
.lookupFunction<Uint32 Function(), int Function()>(
'geteuid',
isLeaf: true,
);
/// Where this thread's `errno` is.
late final _errnoAt = _library
.lookupFunction<Pointer<Int32> Function(), Pointer<Int32> Function()>(
Platform.isMacOS ? '__error' : '__errno_location',
isLeaf: true,
);
/// `EINTR`, alike on Linux and macOS.
static const _interrupted = 4;
/// [call] again while a signal interrupts it, a few hundred times at most.
int _again(int Function() call) {
for (var tries = 0; ; tries++) {
final result = call();
if (result != -1 || tries >= 300 || _errnoAt().value != _interrupted) {
return result;
}
}
}
bool get _arm64Linux => Abi.current() == Abi.linuxArm64;
/// `O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC`, as each
/// system numbers them.
int get _newFileFlags => Platform.isMacOS
/// system numbers them: a new file, never one there or a link's.
int get newFileFlags => Platform.isMacOS
? 0x1 | 0x200 | 0x800 | 0x100 | 0x1000000
: 0x1 | 0x40 | 0x80 | _linuxNoFollow | 0x80000;
/// `O_NOFOLLOW`: Arm's Linux numbers it otherwise than the generic one.
int get _linuxNoFollow => Abi.current() == Abi.linuxArm64 ? 0x8000 : 0x20000;
/// `O_WRONLY | O_CREAT | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC`: the file
/// chosen, made if none is there, never a link's, and a pipe without a
/// reader refused rather than waited on. Not `O_TRUNC`: nothing of a file
/// that is refused is touched.
int get chosenFileFlags => Platform.isMacOS
? 0x1 | 0x200 | 0x100 | 0x4 | 0x1000000
: 0x1 | 0x40 | _linuxNoFollow | 0x800 | 0x80000;
int openNew(String path) {
/// `O_NOFOLLOW`: Arm's Linux numbers it otherwise than the generic one.
int get _linuxNoFollow => _arm64Linux ? 0x8000 : 0x20000;
/// `O_RDONLY | O_CLOEXEC`, for a folder.
int get _folderFlags => Platform.isMacOS ? 0x1000000 : 0x80000;
/// A descriptor of [path] opened with [flags], a file it makes 0600.
int open(String path, int flags) {
final native = path.toNativeUtf8();
try {
final fd = _open(native, _newFileFlags, 0x180);
final fd = _again(() => _open(native, flags, 0x180));
if (fd < 0) {
throw FileSystemException('Creating it failed', path);
throw FileSystemException('Opening it failed', path);
}
return fd;
} finally {
@ -224,11 +311,80 @@ final class _Posix() {
}
}
/// Makes [fd]'s file 0600, whatever the umask or the mode it had.
void makePrivate(int fd, String path) {
if (_again(() => _fchmod(fd, 0x180)) != 0) {
throw FileSystemException('Making it private failed', path);
}
}
/// Empties [fd]'s file.
void empty(int fd, String path) {
if (_again(() => _ftruncate(fd, 0)) != 0) {
throw FileSystemException('Emptying it failed', path);
}
}
/// Writes all of [bytes] through [fd], a mebibyte a call, and wipes the
/// copy made for it, which may hold a secret, whatever happens.
void writeAll(int fd, Uint8List bytes, String path) {
final buffer = malloc<Uint8>(bytes.isEmpty ? 1 : bytes.length);
final copy = buffer.asTypedList(bytes.length);
try {
copy.setAll(0, bytes);
var written = 0;
while (written < bytes.length) {
final chunk = min(bytes.length - written, 1 << 20);
final wrote = _again(() => _write(fd, buffer + written, chunk));
if (wrote <= 0) {
throw FileSystemException('Writing failed', path);
}
written += wrote;
}
} finally {
copy.fillRange(0, bytes.length, 0);
malloc.free(buffer);
}
}
/// Waits until [fd]'s file is on the disk.
void flush(int fd, String path) {
if (_again(() => _fsync(fd)) != 0) {
throw FileSystemException('Flushing failed', path);
}
}
/// Waits until [folder]'s entries, a file renamed into it, are on the
/// disk; a folder that can't be read (one written to without reading) is
/// left as it is, the file in it saved all the same.
void flushFolder(String folder) {
final native = folder.toNativeUtf8();
try {
final fd = _again(() => _open(native, _folderFlags, 0));
if (fd < 0) {
log('The folder could not be flushed', name: 'Files');
return;
}
try {
if (_again(() => _fsync(fd)) != 0) {
log('The folder could not be flushed', name: 'Files');
}
} finally {
_close(fd);
}
} finally {
malloc.free(native);
}
}
/// Closes [fd]; never again on an interruption, which closed it already.
void close(int fd) => _close(fd);
void rename(String from, String to) {
final nativeFrom = from.toNativeUtf8();
final nativeTo = to.toNativeUtf8();
try {
if (_rename(nativeFrom, nativeTo) != 0) {
if (_again(() => _rename(nativeFrom, nativeTo)) != 0) {
throw FileSystemException('Renaming failed', to);
}
} finally {
@ -248,58 +404,104 @@ final class _Posix() {
}
/// [path]'s type and owner, not following a link; null where nothing is.
({bool isLink, int owner})? lstat(String path) {
// Where each system's `struct stat` keeps the mode and the owner.
final (:mode, :modeBytes, :owner) = switch (Abi.current()) {
Abi.macosArm64 || Abi.macosX64 => (mode: 4, modeBytes: 2, owner: 16),
Abi.linuxX64 => (mode: 24, modeBytes: 4, owner: 28),
// The kernel's generic layout.
Abi.linuxArm64 || Abi.linuxRiscv64 => (mode: 16, modeBytes: 4, owner: 24),
_ => throw FileSystemException('Its owner is unknown here', path),
};
_Found? lstat(String path) {
final native = path.toNativeUtf8();
try {
return _stat(path, (buffer) => _lstat(native, buffer));
} finally {
malloc.free(native);
}
}
/// The type and owner of [fd]'s file, [path].
_Found fstat(int fd, String path) =>
_stat(path, (buffer) => _fstat(fd, buffer)) ??
(throw FileSystemException('Its owner is unknown', path));
/// What [call] reads into a buffer of `stat`'s; null where nothing is.
_Found? _stat(String path, int Function(Pointer<Uint8>) call) {
// Where `struct stat` (macOS) or `struct statx` (Linux, alike on every
// architecture) keeps the mode and the owner.
final (:mode, :owner) = Platform.isMacOS
? (mode: 4, owner: 16)
: (mode: 28, owner: 20);
final buffer = calloc<Uint8>(256);
try {
if (_lstat(native, buffer) != 0) {
// Anything but "there is none" refuses.
if (FileSystemEntity.typeSync(path, followLinks: false) ==
FileSystemEntityType.notFound) {
if (_again(() => call(buffer)) != 0) {
// `ENOENT`, alike on Linux and macOS; anything else refuses.
if (_errnoAt().value == 2) {
return null;
}
throw FileSystemException('Its owner is unknown', path);
}
final view = ByteData.sublistView(buffer.asTypedList(256));
final fileMode = modeBytes == 2
? view.getUint16(mode, Endian.host)
: view.getUint32(mode, Endian.host);
final type = view.getUint16(mode, Endian.host) & 0xf000;
return (
isLink: fileMode & 0xf000 == 0xa000,
isLink: type == 0xa000,
isFile: type == 0x8000,
owner: view.getUint32(owner, Endian.host),
);
} finally {
calloc.free(buffer);
malloc.free(native);
}
}
/// lstat(2): on Intel Macs its 64-bit inode variant, whose layout Arm's
/// has; on a glibc older than 2.33, `__lxstat`.
late final int Function(Pointer<Utf8>, Pointer<Uint8>) _lstat = () {
for (final name in [if (Platform.isMacOS) r'lstat$INODE64', 'lstat']) {
if (_library.providesSymbol(name)) {
return _library.lookupFunction<
/// has; on Linux statx(2), whose layout is the same everywhere (glibc 2.28
/// on, older than any Flutter runs on), where `lstat` itself is a symbol
/// only from glibc 2.33 and `__lxstat`'s versions differ by architecture.
late final int Function(Pointer<Utf8>, Pointer<Uint8>) _lstat =
Platform.isMacOS
? _macStat<Pointer<Utf8>>('lstat')
: (path, buffer) => _statx(_atCurrentFolder, path, _noFollow, buffer);
/// fstat(2), as [_lstat].
late final int Function(int, Pointer<Uint8>) _fstat = Platform.isMacOS
? _macStat<int>('fstat')
: (fd, buffer) => _statx(fd, _noPath, _emptyPath, buffer);
/// The Mac's [name] (`lstat` or `fstat`), its 64-bit inode variant on
/// Intel.
int Function(T, Pointer<Uint8>) _macStat<T>(String name) {
final found = _library.providesSymbol('$name\$INODE64')
? '$name\$INODE64'
: name;
if (T == int) {
final call = _library
.lookupFunction<
Int32 Function(Int32, Pointer<Uint8>),
int Function(int, Pointer<Uint8>)
>(found, isLeaf: true);
return (at, buffer) => call(at as int, buffer);
}
final call = _library
.lookupFunction<
Int32 Function(Pointer<Utf8>, Pointer<Uint8>),
int Function(Pointer<Utf8>, Pointer<Uint8>)
>(name);
}
}
final lxstat = _library
>(found, isLeaf: true);
return (at, buffer) => call(at as Pointer<Utf8>, buffer);
}
late final int Function(int, Pointer<Utf8>, int, Pointer<Uint8>) _statx = () {
final statx = _library
.lookupFunction<
Int32 Function(Int32, Pointer<Utf8>, Pointer<Uint8>),
int Function(int, Pointer<Utf8>, Pointer<Uint8>)
>('__lxstat');
final version = Abi.current() == Abi.linuxArm64 ? 0 : 1;
return (Pointer<Utf8> path, Pointer<Uint8> buffer) =>
lxstat(version, path, buffer);
Int32 Function(Int32, Pointer<Utf8>, Int32, Uint32, Pointer<Uint8>),
int Function(int, Pointer<Utf8>, int, int, Pointer<Uint8>)
>('statx', isLeaf: true);
// `STATX_TYPE | STATX_MODE | STATX_UID`.
return (int at, Pointer<Utf8> path, int flags, Pointer<Uint8> buffer) =>
statx(at, path, flags, 0x1 | 0x2 | 0x8, buffer);
}();
/// `AT_FDCWD`.
static const _atCurrentFolder = -100;
/// `AT_SYMLINK_NOFOLLOW`.
static const _noFollow = 0x100;
/// `AT_EMPTY_PATH`, the descriptor's own file.
static const _emptyPath = 0x1000;
/// The empty path [_emptyPath] reads, kept for the process.
late final Pointer<Utf8> _noPath = ''.toNativeUtf8();
}

View file

@ -32,6 +32,10 @@ void main() {
}, 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 '
@ -43,7 +47,11 @@ void main() {
final elsewhere = p.join(folder.path, 'held-open');
await Process.run('ln', [path, elsewhere]);
await writePrivately(path, Uint8List.fromList(utf8.encode('secret')));
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');
@ -96,9 +104,90 @@ void main() {
},
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<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');