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:
parent
cf69e62ad4
commit
001ab37d09
3 changed files with 122 additions and 7 deletions
|
|
@ -1,8 +1,12 @@
|
||||||
|
import 'dart:developer';
|
||||||
|
import 'dart:ffi';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
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: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.
|
||||||
abstract interface class FileSaver() {
|
abstract interface class FileSaver() {
|
||||||
|
|
@ -19,6 +23,11 @@ abstract interface class FileSaver() {
|
||||||
/// [FileSaver] with the platform's own dialog: `file_selector`'s on the
|
/// [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
|
/// desktop; on Android and iOS, where that has none, the system's through
|
||||||
/// `file_picker` (Android's Storage Access Framework, iOS's document picker).
|
/// `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 {
|
class PlatformFileSaver() implements FileSaver {
|
||||||
@override
|
@override
|
||||||
Future<bool> save({
|
Future<bool> save({
|
||||||
|
|
@ -27,18 +36,76 @@ class PlatformFileSaver() implements FileSaver {
|
||||||
required String mimeType,
|
required String mimeType,
|
||||||
}) async {
|
}) async {
|
||||||
if (Platform.isAndroid || Platform.isIOS) {
|
if (Platform.isAndroid || Platform.isIOS) {
|
||||||
|
try {
|
||||||
return await file_picker.FilePicker.saveFile(
|
return await file_picker.FilePicker.saveFile(
|
||||||
fileName: name,
|
fileName: name,
|
||||||
bytes: bytes,
|
bytes: bytes,
|
||||||
mimeType: mimeType,
|
mimeType: mimeType,
|
||||||
) !=
|
) !=
|
||||||
null;
|
null;
|
||||||
|
} finally {
|
||||||
|
if (Platform.isIOS) {
|
||||||
|
await forgetPickerCopy(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
final location = await getSaveLocation(suggestedName: name);
|
final location = await getSaveLocation(suggestedName: name);
|
||||||
if (location == null) {
|
if (location == null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
await File(location.path).writeAsBytes(bytes);
|
await writePrivately(location.path, bytes);
|
||||||
return true;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ environment:
|
||||||
dependencies:
|
dependencies:
|
||||||
clock: ^1.1.3
|
clock: ^1.1.3
|
||||||
equatable: ^3.0.0
|
equatable: ^3.0.0
|
||||||
|
# chmod(2) for files saved on the desktop: the own user's alone (FileSaver).
|
||||||
|
ffi: ^2.2.0
|
||||||
# Keeping a file the app made where the person chooses (FileSaver): the
|
# Keeping a file the app made where the person chooses (FileSaver): the
|
||||||
# system's dialog on Android and iOS, file_selector's on the desktop.
|
# system's dialog on Android and iOS, file_selector's on the desktop.
|
||||||
file_picker: ^13.1.0
|
file_picker: ^13.1.0
|
||||||
|
|
|
||||||
46
packages/halkyon_app/test/files/file_saver_test.dart
Normal file
46
packages/halkyon_app/test/files/file_saver_test.dart
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
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);
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue