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:
parent
a9bbda7017
commit
290f584329
7 changed files with 413 additions and 110 deletions
|
|
@ -4,7 +4,7 @@
|
||||||
<dict>
|
<dict>
|
||||||
<key>com.apple.security.app-sandbox</key>
|
<key>com.apple.security.app-sandbox</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.files.user-selected.read-only</key>
|
<key>com.apple.security.files.user-selected.read-write</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.cs.allow-jit</key>
|
<key>com.apple.security.cs.allow-jit</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
<dict>
|
<dict>
|
||||||
<key>com.apple.security.app-sandbox</key>
|
<key>com.apple.security.app-sandbox</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.files.user-selected.read-only</key>
|
<key>com.apple.security.files.user-selected.read-write</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.network.client</key>
|
<key>com.apple.security.network.client</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@
|
||||||
<dict>
|
<dict>
|
||||||
<key>com.apple.security.app-sandbox</key>
|
<key>com.apple.security.app-sandbox</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.files.user-selected.read-write</key>
|
||||||
|
<true/>
|
||||||
<key>com.apple.security.cs.allow-jit</key>
|
<key>com.apple.security.cs.allow-jit</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.network.client</key>
|
<key>com.apple.security.network.client</key>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@
|
||||||
<dict>
|
<dict>
|
||||||
<key>com.apple.security.app-sandbox</key>
|
<key>com.apple.security.app-sandbox</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.files.user-selected.read-write</key>
|
||||||
|
<true/>
|
||||||
<key>com.apple.security.network.client</key>
|
<key>com.apple.security.network.client</key>
|
||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
|
|
|
||||||
|
|
@ -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.
|
compiler and make: sodium's build hook compiles libsodium.
|
||||||
- **Files** (`FileSaver`, `PlatformFileSaver`): keeping a file the app made
|
- **Files** (`FileSaver`, `PlatformFileSaver`): keeping a file the app made
|
||||||
where the person chooses (a recovery phrase, recovery codes, an app's
|
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
|
exports): on Linux and macOS the own user's alone (`writePrivately`). On
|
||||||
(`writePrivately`: a new file beside it, made 0600 with `O_EXCL`, written
|
Linux a new file beside it, made 0600 with `O_EXCL`, written through its
|
||||||
through its descriptor, renamed over the one chosen, never written into;
|
descriptor, renamed over the one chosen, never written into, and the
|
||||||
a symbolic link or another user's file refused), on Windows written in
|
folder flushed. On macOS, whose sandbox lets an app reach the file chosen
|
||||||
place with its folder's rights, on iOS without the copy file_picker leaves
|
and nothing beside it, the file itself, opened without following a link
|
||||||
in the temporary folder (`forgetPickerCopy`).
|
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
|
- **Sync** (`lib/src/sync/`): `SyncService` (once at start, shortly after
|
||||||
local changes, on resume and every few minutes, one sync at a time, counting
|
local changes, on resume and every few minutes, one sync at a time, counting
|
||||||
the changes not on the server yet, `PendingChangesStore`), `AppCompatibility`
|
the changes not on the server yet, `PendingChangesStore`), `AppCompatibility`
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
import 'dart:developer';
|
import 'dart:developer';
|
||||||
import 'dart:ffi';
|
import 'dart:ffi';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:math' show Random;
|
import 'dart:math' show Random, min;
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:ffi/ffi.dart';
|
import 'package:ffi/ffi.dart';
|
||||||
import 'package:file_picker/file_picker.dart' as file_picker;
|
import 'package:file_picker/file_picker.dart' as file_picker;
|
||||||
import 'package:file_selector/file_selector.dart';
|
import 'package:file_selector/file_selector.dart';
|
||||||
|
import 'package:flutter/foundation.dart' show visibleForTesting;
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
/// Lets the person keep a file the app made, where they choose.
|
/// 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
|
/// 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):
|
/// (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
|
/// On Linux into a new file beside it, made 0600 as it is created (`O_EXCL`,
|
||||||
/// that made it, then renamed over [path]. So the file the person chose is
|
/// so never one another user made first) and written through the
|
||||||
/// replaced whole, never written into: whoever held the old one open, or a
|
/// descriptor that made it, then renamed over [path], and the folder
|
||||||
/// link to it, reads only what it held. Refused ([FileSystemException]),
|
/// flushed. So the file the person chose is replaced whole, never written
|
||||||
/// with nothing written, where [path] is a symbolic link (never followed)
|
/// into: whoever held the old one open, or a link to it, reads only what it
|
||||||
/// or a file another user owns. Windows' files keep their folder's rights,
|
/// held. Refused, with nothing written, where [path] is a symbolic link
|
||||||
/// a user's own folders being theirs alone, and are written in place.
|
/// (never followed) or a file another user owns.
|
||||||
Future<void> writePrivately(String path, Uint8List bytes) async {
|
///
|
||||||
|
/// 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) {
|
if (!Platform.isLinux && !Platform.isMacOS) {
|
||||||
await File(path).writeAsBytes(bytes, flush: true);
|
await File(path).writeAsBytes(bytes, flush: true);
|
||||||
return;
|
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);
|
_refuseLinkOrOthers(path);
|
||||||
final temporary = p.join(
|
// Named apart from [path], so a name as long as the folder takes still
|
||||||
p.dirname(path),
|
// leaves room (the short reading).
|
||||||
'.${p.basename(path)}.${_randomHex()}.tmp',
|
final temporary = p.join(p.dirname(path), '.halkyon-${_randomHex()}.tmp');
|
||||||
);
|
|
||||||
try {
|
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
|
// Checked once more: a link put there meanwhile is replaced by the
|
||||||
// rename, never followed, but the person is told.
|
// rename, never followed, but the person is told.
|
||||||
_refuseLinkOrOthers(path);
|
_refuseLinkOrOthers(path);
|
||||||
|
|
@ -89,6 +125,29 @@ Future<void> writePrivately(String path, Uint8List bytes) async {
|
||||||
_posix.unlinkQuietly(temporary);
|
_posix.unlinkQuietly(temporary);
|
||||||
rethrow;
|
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
|
/// 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();
|
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
|
/// The few calls of the C library [writePrivately] needs, through the
|
||||||
/// library already in the process: dart:io sets no modes, creates nothing
|
/// library already in the process: dart:io sets no modes, creates nothing
|
||||||
/// exclusively, and running chmod(1) would leave a sandboxed macOS app
|
/// 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 class _Posix() {
|
||||||
final DynamicLibrary _library = DynamicLibrary.process();
|
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
|
late final _open = _library
|
||||||
.lookupFunction<
|
.lookupFunction<
|
||||||
Int32 Function(Pointer<Utf8>, Int32, Uint32),
|
Int32 Function(Pointer<Utf8>, Int32, VarArgs<(Uint32,)>),
|
||||||
int Function(Pointer<Utf8>, int, int)
|
int Function(Pointer<Utf8>, int, int)
|
||||||
>('open');
|
>('open', isLeaf: true);
|
||||||
late final write = _library
|
late final _write = _library
|
||||||
.lookupFunction<
|
.lookupFunction<
|
||||||
IntPtr Function(Int32, Pointer<Uint8>, IntPtr),
|
IntPtr Function(Int32, Pointer<Uint8>, IntPtr),
|
||||||
int Function(int, Pointer<Uint8>, int)
|
int Function(int, Pointer<Uint8>, int)
|
||||||
>('write');
|
>('write', isLeaf: true);
|
||||||
late final fsync = _library
|
late final _fsync = _library
|
||||||
.lookupFunction<Int32 Function(Int32), int Function(int)>('fsync');
|
.lookupFunction<Int32 Function(Int32), int Function(int)>(
|
||||||
late final close = _library
|
'fsync',
|
||||||
.lookupFunction<Int32 Function(Int32), int Function(int)>('close');
|
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
|
late final _rename = _library
|
||||||
.lookupFunction<
|
.lookupFunction<
|
||||||
Int32 Function(Pointer<Utf8>, Pointer<Utf8>),
|
Int32 Function(Pointer<Utf8>, Pointer<Utf8>),
|
||||||
int Function(Pointer<Utf8>, Pointer<Utf8>)
|
int Function(Pointer<Utf8>, Pointer<Utf8>)
|
||||||
>('rename');
|
>('rename', isLeaf: true);
|
||||||
late final _unlink = _library
|
late final _unlink = _library
|
||||||
.lookupFunction<
|
.lookupFunction<
|
||||||
Int32 Function(Pointer<Utf8>),
|
Int32 Function(Pointer<Utf8>),
|
||||||
int Function(Pointer<Utf8>)
|
int Function(Pointer<Utf8>)
|
||||||
>('unlink');
|
>('unlink', isLeaf: true);
|
||||||
late final geteuid = _library
|
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
|
/// `O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC`, as each
|
||||||
/// system numbers them.
|
/// system numbers them: a new file, never one there or a link's.
|
||||||
int get _newFileFlags => Platform.isMacOS
|
int get newFileFlags => Platform.isMacOS
|
||||||
? 0x1 | 0x200 | 0x800 | 0x100 | 0x1000000
|
? 0x1 | 0x200 | 0x800 | 0x100 | 0x1000000
|
||||||
: 0x1 | 0x40 | 0x80 | _linuxNoFollow | 0x80000;
|
: 0x1 | 0x40 | 0x80 | _linuxNoFollow | 0x80000;
|
||||||
|
|
||||||
/// `O_NOFOLLOW`: Arm's Linux numbers it otherwise than the generic one.
|
/// `O_WRONLY | O_CREAT | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC`: the file
|
||||||
int get _linuxNoFollow => Abi.current() == Abi.linuxArm64 ? 0x8000 : 0x20000;
|
/// 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();
|
final native = path.toNativeUtf8();
|
||||||
try {
|
try {
|
||||||
final fd = _open(native, _newFileFlags, 0x180);
|
final fd = _again(() => _open(native, flags, 0x180));
|
||||||
if (fd < 0) {
|
if (fd < 0) {
|
||||||
throw FileSystemException('Creating it failed', path);
|
throw FileSystemException('Opening it failed', path);
|
||||||
}
|
}
|
||||||
return fd;
|
return fd;
|
||||||
} finally {
|
} 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) {
|
void rename(String from, String to) {
|
||||||
final nativeFrom = from.toNativeUtf8();
|
final nativeFrom = from.toNativeUtf8();
|
||||||
final nativeTo = to.toNativeUtf8();
|
final nativeTo = to.toNativeUtf8();
|
||||||
try {
|
try {
|
||||||
if (_rename(nativeFrom, nativeTo) != 0) {
|
if (_again(() => _rename(nativeFrom, nativeTo)) != 0) {
|
||||||
throw FileSystemException('Renaming failed', to);
|
throw FileSystemException('Renaming failed', to);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -248,58 +404,104 @@ final class _Posix() {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [path]'s type and owner, not following a link; null where nothing is.
|
/// [path]'s type and owner, not following a link; null where nothing is.
|
||||||
({bool isLink, int owner})? lstat(String path) {
|
_Found? 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),
|
|
||||||
};
|
|
||||||
final native = path.toNativeUtf8();
|
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);
|
final buffer = calloc<Uint8>(256);
|
||||||
try {
|
try {
|
||||||
if (_lstat(native, buffer) != 0) {
|
if (_again(() => call(buffer)) != 0) {
|
||||||
// Anything but "there is none" refuses.
|
// `ENOENT`, alike on Linux and macOS; anything else refuses.
|
||||||
if (FileSystemEntity.typeSync(path, followLinks: false) ==
|
if (_errnoAt().value == 2) {
|
||||||
FileSystemEntityType.notFound) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
throw FileSystemException('Its owner is unknown', path);
|
throw FileSystemException('Its owner is unknown', path);
|
||||||
}
|
}
|
||||||
final view = ByteData.sublistView(buffer.asTypedList(256));
|
final view = ByteData.sublistView(buffer.asTypedList(256));
|
||||||
final fileMode = modeBytes == 2
|
final type = view.getUint16(mode, Endian.host) & 0xf000;
|
||||||
? view.getUint16(mode, Endian.host)
|
|
||||||
: view.getUint32(mode, Endian.host);
|
|
||||||
return (
|
return (
|
||||||
isLink: fileMode & 0xf000 == 0xa000,
|
isLink: type == 0xa000,
|
||||||
|
isFile: type == 0x8000,
|
||||||
owner: view.getUint32(owner, Endian.host),
|
owner: view.getUint32(owner, Endian.host),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
calloc.free(buffer);
|
calloc.free(buffer);
|
||||||
malloc.free(native);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// lstat(2): on Intel Macs its 64-bit inode variant, whose layout Arm's
|
/// lstat(2): on Intel Macs its 64-bit inode variant, whose layout Arm's
|
||||||
/// has; on a glibc older than 2.33, `__lxstat`.
|
/// has; on Linux statx(2), whose layout is the same everywhere (glibc 2.28
|
||||||
late final int Function(Pointer<Utf8>, Pointer<Uint8>) _lstat = () {
|
/// on, older than any Flutter runs on), where `lstat` itself is a symbol
|
||||||
for (final name in [if (Platform.isMacOS) r'lstat$INODE64', 'lstat']) {
|
/// only from glibc 2.33 and `__lxstat`'s versions differ by architecture.
|
||||||
if (_library.providesSymbol(name)) {
|
late final int Function(Pointer<Utf8>, Pointer<Uint8>) _lstat =
|
||||||
return _library.lookupFunction<
|
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>),
|
Int32 Function(Pointer<Utf8>, Pointer<Uint8>),
|
||||||
int Function(Pointer<Utf8>, Pointer<Uint8>)
|
int Function(Pointer<Utf8>, Pointer<Uint8>)
|
||||||
>(name);
|
>(found, isLeaf: true);
|
||||||
}
|
return (at, buffer) => call(at as Pointer<Utf8>, buffer);
|
||||||
}
|
}
|
||||||
final lxstat = _library
|
|
||||||
|
late final int Function(int, Pointer<Utf8>, int, Pointer<Uint8>) _statx = () {
|
||||||
|
final statx = _library
|
||||||
.lookupFunction<
|
.lookupFunction<
|
||||||
Int32 Function(Int32, Pointer<Utf8>, Pointer<Uint8>),
|
Int32 Function(Int32, Pointer<Utf8>, Int32, Uint32, Pointer<Uint8>),
|
||||||
int Function(int, Pointer<Utf8>, Pointer<Uint8>)
|
int Function(int, Pointer<Utf8>, int, int, Pointer<Uint8>)
|
||||||
>('__lxstat');
|
>('statx', isLeaf: true);
|
||||||
final version = Abi.current() == Abi.linuxArm64 ? 0 : 1;
|
// `STATX_TYPE | STATX_MODE | STATX_UID`.
|
||||||
return (Pointer<Utf8> path, Pointer<Uint8> buffer) =>
|
return (int at, Pointer<Utf8> path, int flags, Pointer<Uint8> buffer) =>
|
||||||
lxstat(version, path, 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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,10 @@ void main() {
|
||||||
}, skip: Platform.isWindows ? 'no modes' : false);
|
}, skip: Platform.isWindows ? 'no modes' : false);
|
||||||
|
|
||||||
final hasModes = Platform.isLinux || Platform.isMacOS;
|
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: '
|
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 '
|
'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');
|
final elsewhere = p.join(folder.path, 'held-open');
|
||||||
await Process.run('ln', [path, elsewhere]);
|
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(await File(path).readAsString(), 'secret');
|
||||||
expect(File(path).statSync().mode & 0x1ff, 0x180, reason: '0600');
|
expect(File(path).statSync().mode & 0x1ff, 0x180, reason: '0600');
|
||||||
|
|
@ -96,9 +104,90 @@ void main() {
|
||||||
},
|
},
|
||||||
skip: !hasModes || !File('/etc/hostname').existsSync()
|
skip: !hasModes || !File('/etc/hostname').existsSync()
|
||||||
? 'no such file'
|
? 'no such file'
|
||||||
|
: isRoot
|
||||||
|
? "root writes anyone's files"
|
||||||
: false,
|
: 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 '
|
test('a new file is made 0600 from the start, with nothing left beside '
|
||||||
'it', () async {
|
'it', () async {
|
||||||
final path = p.join(folder.path, 'new.txt');
|
final path = p.join(folder.path, 'new.txt');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue