diff --git a/flatpak/com.cypherstack.campfire.yaml b/flatpak/com.cypherstack.campfire.yaml index 9f8cd9efcb..f068b129d3 100644 --- a/flatpak/com.cypherstack.campfire.yaml +++ b/flatpak/com.cypherstack.campfire.yaml @@ -10,6 +10,7 @@ finish-args: - --socket=fallback-x11 - --socket=wayland - --device=dri + # Retained for legacy-data migration. - --filesystem=~/.campfire - --talk-name=org.freedesktop.secrets - --talk-name=org.freedesktop.Notifications diff --git a/flatpak/com.cypherstack.stackduo.yaml b/flatpak/com.cypherstack.stackduo.yaml index 195f674575..06eea47ea8 100644 --- a/flatpak/com.cypherstack.stackduo.yaml +++ b/flatpak/com.cypherstack.stackduo.yaml @@ -10,6 +10,7 @@ finish-args: - --socket=fallback-x11 - --socket=wayland - --device=dri + # Retained for legacy-data migration. - --filesystem=~/.stackduo - --talk-name=org.freedesktop.secrets - --talk-name=org.freedesktop.Notifications diff --git a/flatpak/com.cypherstack.stackwallet.yaml b/flatpak/com.cypherstack.stackwallet.yaml index f8836e6dee..67e920dd3a 100644 --- a/flatpak/com.cypherstack.stackwallet.yaml +++ b/flatpak/com.cypherstack.stackwallet.yaml @@ -10,6 +10,7 @@ finish-args: - --socket=fallback-x11 - --socket=wayland - --device=dri + # Retained for legacy-data migration. - --filesystem=~/.stackwallet - --talk-name=org.freedesktop.secrets - --talk-name=org.freedesktop.Notifications diff --git a/lib/utilities/flatpak_data_directory.dart b/lib/utilities/flatpak_data_directory.dart new file mode 100644 index 0000000000..347436310b --- /dev/null +++ b/lib/utilities/flatpak_data_directory.dart @@ -0,0 +1,217 @@ +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:path/path.dart' as path; + +typedef DirectoryCopy = + Future Function(Directory source, Directory destination); + +abstract class FlatpakDataDirectory { + static const migrationMarker = ".flatpak_data_migration_v1"; + + /// Copies legacy data before selecting Flatpak's app-private XDG root. + static Future resolve({ + required Map environment, + required String appDirectoryName, + DirectoryCopy copyDirectory = _copyDirectory, + void Function(Object error)? onError, + }) async { + if ((environment["FLATPAK_ID"] ?? "").isEmpty) { + return null; + } + + final xdgDataHome = environment["XDG_DATA_HOME"]; + if (xdgDataHome == null || xdgDataHome.isEmpty) { + return null; + } + + final destination = Directory(path.join(xdgDataHome, appDirectoryName)); + final home = environment["HOME"]; + if (home == null || home.isEmpty) { + await _initialize(destination); + return destination; + } + + final legacy = Directory(path.join(home, ".$appDirectoryName")); + final legacyType = await FileSystemEntity.type( + legacy.path, + followLinks: false, + ); + if (legacyType == FileSystemEntityType.notFound) { + await _initialize(destination); + return destination; + } + if (legacyType != FileSystemEntityType.directory) { + onError?.call( + FileSystemException( + "Legacy Flatpak data is not a directory", + legacy.path, + ), + ); + return legacy; + } + + final marker = File(path.join(destination.path, migrationMarker)); + if (await marker.exists()) { + return destination; + } + + if (await destination.exists()) { + onError?.call( + StateError( + "Flatpak data migration found both legacy and unverified data", + ), + ); + return legacy; + } + + final temporary = Directory("${destination.path}.migrating"); + RandomAccessFile? legacyLock; + try { + legacyLock = await _lockLegacyDatabase(legacy); + // Another instance may have completed a verified migration between the + // marker check above and taking the lock. + if (await marker.exists()) { + return destination; + } + final temporaryType = await FileSystemEntity.type( + temporary.path, + followLinks: false, + ); + if (temporaryType == FileSystemEntityType.directory) { + await temporary.delete(recursive: true); + } else if (temporaryType != FileSystemEntityType.notFound) { + throw FileSystemException( + "Flatpak migration path is not a directory", + temporary.path, + ); + } + + await Directory(path.dirname(destination.path)).create(recursive: true); + await copyDirectory(legacy, temporary); + await _verifyDirectoryCopy(legacy, temporary); + await File( + path.join(temporary.path, migrationMarker), + ).writeAsString("complete", flush: true); + await temporary.rename(destination.path); + return destination; + } catch (error) { + try { + if (await FileSystemEntity.type(temporary.path, followLinks: false) == + FileSystemEntityType.directory) { + await temporary.delete(recursive: true); + } + } catch (cleanupError) { + onError?.call(cleanupError); + } + // The rename loses to another instance that finished a verified + // migration first; use that data rather than diverging onto legacy. + if (await marker.exists()) { + return destination; + } + onError?.call(error); + return legacy; + } finally { + if (legacyLock != null) { + try { + await legacyLock.unlock(); + await legacyLock.close(); + } catch (error) { + onError?.call(error); + } + } + } + } + + static Future _lockLegacyDatabase(Directory legacy) async { + final lockFile = File(path.join(legacy.path, "hive", "dbinfo.lock")); + if (!await lockFile.exists()) { + return null; + } + + final handle = await lockFile.open(mode: FileMode.append); + try { + await handle.lock(FileLock.exclusive); + return handle; + } catch (_) { + await handle.close(); + rethrow; + } + } + + static Future _initialize(Directory destination) async { + await destination.create(recursive: true); + final marker = File(path.join(destination.path, migrationMarker)); + if (!await marker.exists()) { + await marker.writeAsString("complete", flush: true); + } + } + + static Future _copyDirectory( + Directory source, + Directory destination, + ) async { + await destination.create(recursive: true); + await for (final entity in source.list(followLinks: false)) { + final targetPath = path.join( + destination.path, + path.basename(entity.path), + ); + final type = await FileSystemEntity.type(entity.path, followLinks: false); + switch (type) { + case FileSystemEntityType.directory: + await _copyDirectory(entity as Directory, Directory(targetPath)); + case FileSystemEntityType.file: + await (entity as File).copy(targetPath); + final copied = await File(targetPath).open(mode: FileMode.append); + await copied.flush(); + await copied.close(); + default: + throw FileSystemException( + "Unsupported entry in Flatpak data directory", + entity.path, + ); + } + } + } + + static Future _verifyDirectoryCopy( + Directory source, + Directory destination, + ) async { + final sourceEntries = await _fileDigests(source); + final destinationEntries = await _fileDigests(destination); + if (sourceEntries.length != destinationEntries.length) { + throw const FileSystemException("Flatpak data copy is incomplete"); + } + + for (final entry in sourceEntries.entries) { + if (destinationEntries[entry.key] != entry.value) { + throw FileSystemException( + "Flatpak data copy verification failed", + entry.key, + ); + } + } + } + + static Future> _fileDigests(Directory root) async { + final entries = {}; + await for (final entity in root.list(recursive: true, followLinks: false)) { + final relativePath = path.relative(entity.path, from: root.path); + final type = await FileSystemEntity.type(entity.path, followLinks: false); + if (type == FileSystemEntityType.directory) { + entries[relativePath] = "directory"; + } else if (type == FileSystemEntityType.file) { + entries[relativePath] = + (await sha256.bind((entity as File).openRead()).first).toString(); + } else { + throw FileSystemException( + "Unsupported entry in Flatpak data directory", + entity.path, + ); + } + } + return entries; + } +} diff --git a/lib/utilities/stack_file_system.dart b/lib/utilities/stack_file_system.dart index 292dda1368..983632054f 100644 --- a/lib/utilities/stack_file_system.dart +++ b/lib/utilities/stack_file_system.dart @@ -14,6 +14,7 @@ import 'package:path/path.dart' as path; import 'package:path_provider/path_provider.dart'; import '../app_config.dart'; +import 'flatpak_data_directory.dart'; import 'prefs.dart'; import 'util.dart'; @@ -33,6 +34,30 @@ abstract class StackFileSystem { static bool get _createSubDirs => Util.isDesktop || AppConfig.appName == "Campfire"; + static Future? _linuxRootResolution; + + static Future _resolveLinuxRoot() async { + try { + return await FlatpakDataDirectory.resolve( + environment: Platform.environment, + appDirectoryName: AppConfig.appDefaultDataDirName, + onError: (error) => stderr.writeln( + "Flatpak data migration failed; using legacy data: $error", + ), + ) ?? + Directory( + path.join( + Platform.environment['HOME']!, + ".${AppConfig.appDefaultDataDirName}", + ), + ); + } catch (_) { + // Only a successful resolution is sticky. + _linuxRootResolution = null; + rethrow; + } + } + static Future applicationRootDirectory() async { Directory appDirectory; @@ -46,12 +71,13 @@ abstract class StackFileSystem { if (_overrideDesktopDirPath != null) { appDirectory = Directory(_overrideDesktopDirPath!); } else { - appDirectory = Directory( - path.join( - Platform.environment['HOME']!, - ".${AppConfig.appDefaultDataDirName}", - ), - ); + // Resolved once per process: the Flatpak migration must run before + // Hive opens and never again. A second resolution could switch the + // root out from under an already open Hive/Isar, redo the whole copy + // after a failure, and its unlock() would drop this process's own + // fcntl lock on hive/dbinfo.lock (those locks are per process), + // defeating the already-running guard in main(). + appDirectory = await (_linuxRootResolution ??= _resolveLinuxRoot()); } } else if (Platform.isWindows) { if (_overrideDesktopDirPath != null) { diff --git a/test/utilities/flatpak_data_directory_test.dart b/test/utilities/flatpak_data_directory_test.dart new file mode 100644 index 0000000000..0fa62a9399 --- /dev/null +++ b/test/utilities/flatpak_data_directory_test.dart @@ -0,0 +1,197 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as path; +import 'package:stackwallet/utilities/flatpak_data_directory.dart'; + +Future _copyTree(Directory source, Directory destination) async { + await destination.create(recursive: true); + await for (final entity in source.list(followLinks: false)) { + final target = path.join(destination.path, path.basename(entity.path)); + if (entity is Directory) { + await _copyTree(entity, Directory(target)); + } else if (entity is File) { + await entity.copy(target); + } + } +} + +void main() { + late Directory temporaryRoot; + + setUp(() async { + temporaryRoot = await Directory.systemTemp.createTemp("flatpak-data-test-"); + }); + + tearDown(() async { + await temporaryRoot.delete(recursive: true); + }); + + Map environment() => { + "FLATPAK_ID": "com.cypherstack.stackwallet", + "HOME": path.join(temporaryRoot.path, "home"), + "XDG_DATA_HOME": path.join(temporaryRoot.path, "xdg"), + }; + + test("ignores non-Flatpak environments", () async { + final result = await FlatpakDataDirectory.resolve( + environment: const {}, + appDirectoryName: "stackwallet", + ); + + expect(result, isNull); + }); + + test("falls back when Flatpak does not provide an XDG data root", () async { + final result = await FlatpakDataDirectory.resolve( + environment: const {"FLATPAK_ID": "com.cypherstack.stackwallet"}, + appDirectoryName: "stackwallet", + ); + + expect(result, isNull); + }); + + test("initializes an app-private root for a fresh install", () async { + final result = await FlatpakDataDirectory.resolve( + environment: environment(), + appDirectoryName: "stackwallet", + ); + + expect(result!.path, path.join(temporaryRoot.path, "xdg", "stackwallet")); + expect( + await File( + path.join(result.path, FlatpakDataDirectory.migrationMarker), + ).exists(), + isTrue, + ); + }); + + test("copies and verifies legacy data before switching roots", () async { + final env = environment(); + final legacy = Directory(path.join(env["HOME"]!, ".stackwallet")); + await Directory(path.join(legacy.path, "hive")).create(recursive: true); + await File( + path.join(legacy.path, "hive", "wallet.hive"), + ).writeAsString("wallet data"); + + final result = await FlatpakDataDirectory.resolve( + environment: env, + appDirectoryName: "stackwallet", + ); + + expect(result!.path, path.join(env["XDG_DATA_HOME"]!, "stackwallet")); + expect( + await File(path.join(result.path, "hive", "wallet.hive")).readAsString(), + "wallet data", + ); + expect(await legacy.exists(), isTrue); + }); + + test( + "uses a completed migration without recopying stale legacy data", + () async { + final env = environment(); + final legacyFile = File( + path.join(env["HOME"]!, ".stackwallet", "wallet"), + ); + await legacyFile.parent.create(recursive: true); + await legacyFile.writeAsString("first"); + + final first = await FlatpakDataDirectory.resolve( + environment: env, + appDirectoryName: "stackwallet", + ); + await legacyFile.writeAsString("stale"); + final second = await FlatpakDataDirectory.resolve( + environment: env, + appDirectoryName: "stackwallet", + ); + + expect(second!.path, first!.path); + expect( + await File(path.join(second.path, "wallet")).readAsString(), + "first", + ); + }, + ); + + test("falls back to legacy data when copying fails", () async { + final env = environment(); + final legacy = Directory(path.join(env["HOME"]!, ".stackwallet")); + await legacy.create(recursive: true); + Object? reportedError; + + final result = await FlatpakDataDirectory.resolve( + environment: env, + appDirectoryName: "stackwallet", + copyDirectory: (_, _) async => throw const FileSystemException("copy"), + onError: (error) => reportedError = error, + ); + + expect(result!.path, legacy.path); + expect(reportedError, isA()); + expect( + await Directory( + "${env["XDG_DATA_HOME"]!}/stackwallet.migrating", + ).exists(), + isFalse, + ); + }); + + test("does not replace unverified destination data", () async { + final env = environment(); + final legacy = Directory(path.join(env["HOME"]!, ".stackwallet")); + final destination = Directory( + path.join(env["XDG_DATA_HOME"]!, "stackwallet"), + ); + await legacy.create(recursive: true); + await destination.create(recursive: true); + await File(path.join(destination.path, "unexpected")).writeAsString("data"); + Object? reportedError; + + final result = await FlatpakDataDirectory.resolve( + environment: env, + appDirectoryName: "stackwallet", + onError: (error) => reportedError = error, + ); + + expect(result!.path, legacy.path); + expect(reportedError, isA()); + expect( + await File(path.join(destination.path, "unexpected")).readAsString(), + "data", + ); + }); + + test("adopts a migration another instance completed while copying", () async { + final env = environment(); + final legacy = Directory(path.join(env["HOME"]!, ".stackwallet")); + final destination = Directory( + path.join(env["XDG_DATA_HOME"]!, "stackwallet"), + ); + await Directory(path.join(legacy.path, "hive")).create(recursive: true); + await File( + path.join(legacy.path, "hive", "wallet.hive"), + ).writeAsString("wallet data"); + Object? reportedError; + + final result = await FlatpakDataDirectory.resolve( + environment: env, + appDirectoryName: "stackwallet", + copyDirectory: (source, temporary) async { + // A second instance finishes its own verified migration while this + // one copies, so the rename onto the destination loses the race. + await _copyTree(source, destination); + await File( + path.join(destination.path, FlatpakDataDirectory.migrationMarker), + ).writeAsString("complete", flush: true); + await _copyTree(source, temporary); + }, + onError: (error) => reportedError = error, + ); + + expect(result!.path, destination.path); + expect(reportedError, isNull); + expect(await Directory("${destination.path}.migrating").exists(), isFalse); + }); +} diff --git a/test/utilities/stack_file_system_flatpak_env_test.dart b/test/utilities/stack_file_system_flatpak_env_test.dart new file mode 100644 index 0000000000..5c2a08c72d --- /dev/null +++ b/test/utilities/stack_file_system_flatpak_env_test.dart @@ -0,0 +1,126 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as path; +import 'package:stackwallet/utilities/stack_file_system.dart'; + +// Process level tests for the Linux data root under a Flatpak environment. +// StackFileSystem reads Platform.environment directly, so these can only run +// in a test process launched with a simulated Flatpak environment: +// +// T=$(mktemp -d) +// FLATPAK_ID=com.cypherstack.stackwallet HOME=$T/home XDG_DATA_HOME=$T/xdg \ +// PUB_CACHE=$HOME/.pub-cache \ +// flutter test test/utilities/stack_file_system_flatpak_env_test.dart +// +// (PUB_CACHE expands before HOME is overridden.) Without that environment the +// file skips itself so it stays harmless in the normal suite. + +void main() { + final env = Platform.environment; + final enabled = + Platform.isLinux && + (env["FLATPAK_ID"] ?? "").isNotEmpty && + (env["XDG_DATA_HOME"] ?? "").isNotEmpty && + (env["HOME"] ?? "").isNotEmpty; + if (!enabled) { + test("skipped: not launched with a simulated Flatpak environment", () { + markTestSkipped("set FLATPAK_ID/XDG_DATA_HOME/HOME to a scratch tree"); + }); + return; + } + + final home = env["HOME"]!; + final xdg = Directory(env["XDG_DATA_HOME"]!); + final legacy = Directory(path.join(home, ".stackwallet")); + final destination = Directory(path.join(xdg.path, "stackwallet")); + final lockFile = File(path.join(legacy.path, "hive", "dbinfo.lock")); + + setUpAll(() async { + await Directory(path.join(legacy.path, "hive")).create(recursive: true); + await File( + path.join(legacy.path, "hive", "wallet.hive"), + ).writeAsString("wallet data"); + await lockFile.writeAsString("{}"); + }); + + tearDownAll(() async { + await Process.run("chmod", ["-R", "u+rwx", xdg.parent.path]); + }); + + /// Runs a separate process that tries to take the Hive lock, the way a + /// second app instance would. + Future otherInstanceLock() async { + final script = File(path.join(xdg.parent.path, "try_lock.dart")); + await script.writeAsString(''' +import 'dart:io'; +Future main(List a) async { + final h = await File(a[0]).open(mode: FileMode.append); + try { await h.lock(FileLock.exclusive); print('ACQUIRED'); await h.unlock(); } + catch (_) { print('REFUSED'); } finally { await h.close(); } +} +'''); + final result = await Process.run("dart", [ + "run", + script.path, + lockFile.path, + ]); + return result.stdout.toString().trim(); + } + + test( + "the root does not change between calls after a migration failure", + () async { + // First resolution (main(): applicationHiveDirectory) fails because + // XDG_DATA_HOME is not writable, so the app continues on legacy data. + await xdg.create(recursive: true); + await Process.run("chmod", ["0500", xdg.path]); + final first = await StackFileSystem.applicationRootDirectory(); + expect(first.path, legacy.path); + expect(await destination.exists(), false); + + // The failure clears; a later call (applicationTorDirectory(), a wallet + // path lookup, ...) must not switch roots with Hive and Isar already + // open on legacy. + await Process.run("chmod", ["0700", xdg.path]); + final second = await StackFileSystem.applicationRootDirectory(); + expect(second.path, first.path); + }, + ); + + test( + "a Hive lock held by this process survives later root lookups", + () async { + await Process.run("chmod", ["-R", "u+rwx", xdg.path]); + if (await destination.exists()) { + await destination.delete(recursive: true); + } + await Process.run("chmod", ["0500", xdg.path]); + + // main() opens Hive, which locks dbinfo.lock, after the first + // applicationRootDirectory() call. + final hiveLock = await lockFile.open(mode: FileMode.write); + await hiveLock.lock(); + try { + expect(await otherInstanceLock(), "REFUSED"); + + final root = await StackFileSystem.applicationRootDirectory(); + expect(root.path, legacy.path); + + // A second app instance must still be refused. + expect(await otherInstanceLock(), "REFUSED"); + } finally { + await hiveLock.close(); + } + }, + ); + + test("-d remains authoritative over the Flatpak environment", () async { + final override = Directory(path.join(xdg.parent.path, "override")); + StackFileSystem.setDesktopOverrideDir(override.path); + + final result = await StackFileSystem.applicationRootDirectory(); + + expect(result.path, override.path); + }); +}