fix(app): saved files are the own user's alone, and iOS's copy goes

On the desktop a saved file (a recovery phrase, recovery codes, an
archive with progress) is made the own user's alone (0600, chmod(2)
through the C library already in the process) on the emptied file before
anything is written, so no other user of the machine reads it; Windows'
files keep their folder's rights. On iOS, file_picker (darwin v2.1.2)
writes a copy into the app's temporary folder for the system's picker and
never removes it, saved or cancelled: it goes once the picker is done.

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 14:44:10 +02:00
commit 001ab37d09
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -1,8 +1,12 @@
import 'dart:developer';
import 'dart:ffi';
import 'dart:io';
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:path/path.dart' as p;
/// Lets the person keep a file the app made, where they choose.
abstract interface class FileSaver() {
@ -19,6 +23,11 @@ abstract interface class FileSaver() {
/// [FileSaver] with the platform's own dialog: `file_selector`'s on the
/// desktop; on Android and iOS, where that has none, the system's through
/// `file_picker` (Android's Storage Access Framework, iOS's document picker).
///
/// What it saves may be secret (a recovery phrase, recovery codes, an
/// archive with a person's progress), so on the desktop the file is the own
/// user's alone ([writePrivately]), and on iOS the copy the picker leaves in
/// the app's temporary folder goes once it is done ([forgetPickerCopy]).
class PlatformFileSaver() implements FileSaver {
@override
Future<bool> save({
@ -27,18 +36,76 @@ class PlatformFileSaver() implements FileSaver {
required String mimeType,
}) async {
if (Platform.isAndroid || Platform.isIOS) {
return await file_picker.FilePicker.saveFile(
fileName: name,
bytes: bytes,
mimeType: mimeType,
) !=
null;
try {
return await file_picker.FilePicker.saveFile(
fileName: name,
bytes: bytes,
mimeType: mimeType,
) !=
null;
} finally {
if (Platform.isIOS) {
await forgetPickerCopy(name);
}
}
}
final location = await getSaveLocation(suggestedName: name);
if (location == null) {
return false;
}
await File(location.path).writeAsBytes(bytes);
await writePrivately(location.path, bytes);
return true;
}
}
/// Writes [bytes] to [path], where the platform has modes (Linux, macOS)
/// readable and writable by the own user alone (0600), set on the emptied
/// file before anything is written, so no other user of the machine ever
/// reads it. Windows' files keep its folders' rights.
Future<void> writePrivately(String path, Uint8List bytes) async {
final file = File(path);
await file.writeAsBytes(const [], flush: true);
if (Platform.isLinux || Platform.isMacOS) {
_ownUserOnly(path);
}
await file.writeAsBytes(bytes, flush: true);
}
/// Removes the copy of the file named [name] that file_picker (checked with
/// file_picker_darwin v2.1.2) writes into the app's temporary folder
/// ([temporary], `NSTemporaryDirectory()`, Dart's [Directory.systemTemp] on
/// iOS) for the system's picker to export, and never removes, whether the
/// person saved or cancelled.
Future<void> forgetPickerCopy(String name, {Directory? temporary}) async {
final copy = File(p.join((temporary ?? Directory.systemTemp).path, name));
try {
if (copy.existsSync()) {
await copy.delete();
}
} on FileSystemException catch (error) {
log(
"The picker's copy stayed: ${error.osError?.errorCode}",
name: 'Files',
);
}
}
typedef _ChmodNative = Int32 Function(Pointer<Utf8> path, Uint32 mode);
typedef _Chmod = int Function(Pointer<Utf8> path, int mode);
/// chmod(2) of [path] to 0600, through the C library already in the
/// process: dart:io sets no modes, and running chmod(1) would leave a
/// sandboxed macOS app without the file's access.
void _ownUserOnly(String path) {
final chmod = DynamicLibrary.process().lookupFunction<_ChmodNative, _Chmod>(
'chmod',
);
final native = path.toNativeUtf8();
try {
if (chmod(native, 0x180) != 0) {
log('The saved file keeps its mode', name: 'Files');
}
} finally {
malloc.free(native);
}
}