From d4b7ab975122da01bab8363a0324ad9e8ead0268 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 24 Aug 2026 16:10:11 -0500 Subject: [PATCH 1/9] feat: use XDG_DATA_HOME for Flatpak data directory Inside Flatpak the data root becomes $XDG_DATA_HOME/ instead of ~/., so it lives in the sandbox's own persistent directory. Existing data is migrated once per process, before Hive opens: lock the legacy Hive db, copy the tree to .migrating, flush it, verify every file by SHA-256, write a completion marker and rename it into place. Legacy data is never modified or deleted and any failure falls back to it, so the manifests keep --filesystem=~/. to keep that fallback readable. The -d launch flag still takes precedence over all of this. closes #1249 --- flatpak/com.cypherstack.campfire.yaml | 1 + flatpak/com.cypherstack.stackduo.yaml | 1 + flatpak/com.cypherstack.stackwallet.yaml | 1 + lib/utilities/flatpak_data_directory.dart | 217 ++++++++++++++++++ lib/utilities/stack_file_system.dart | 38 ++- .../flatpak_data_directory_test.dart | 197 ++++++++++++++++ .../stack_file_system_flatpak_env_test.dart | 126 ++++++++++ 7 files changed, 575 insertions(+), 6 deletions(-) create mode 100644 lib/utilities/flatpak_data_directory.dart create mode 100644 test/utilities/flatpak_data_directory_test.dart create mode 100644 test/utilities/stack_file_system_flatpak_env_test.dart 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); + }); +} From 3969dccac5b163b42c7ffa84eebed86ddb436dc9 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 24 Aug 2026 16:10:11 -0500 Subject: [PATCH 2/9] feat: use XDG_DATA_HOME for Flatpak data directory Inside Flatpak the data root becomes $XDG_DATA_HOME/ instead of ~/., so it lives in the sandbox's own persistent directory. Existing data is migrated once per process, before Hive opens: lock the legacy Hive db, copy the tree to .migrating, flush it, verify every file by SHA-256, write a completion marker and rename it into place. Legacy data is never modified or deleted and any failure falls back to it, so the manifests keep --filesystem=~/. to keep that fallback readable. The -d launch flag still takes precedence over all of this. closes #1249 --- flatpak/com.cypherstack.campfire.yaml | 3 +- flatpak/com.cypherstack.stackduo.yaml | 3 +- flatpak/com.cypherstack.stackwallet.yaml | 3 +- lib/utilities/flatpak_data_directory.dart | 218 ++++++++++++++++++ lib/utilities/stack_file_system.dart | 37 ++- .../flatpak_data_directory_test.dart | 193 ++++++++++++++++ .../stack_file_system_flatpak_env_test.dart | 125 ++++++++++ 7 files changed, 573 insertions(+), 9 deletions(-) create mode 100644 lib/utilities/flatpak_data_directory.dart create mode 100644 test/utilities/flatpak_data_directory_test.dart create mode 100644 test/utilities/stack_file_system_flatpak_env_test.dart diff --git a/flatpak/com.cypherstack.campfire.yaml b/flatpak/com.cypherstack.campfire.yaml index 6dfcca8638..f068b129d3 100644 --- a/flatpak/com.cypherstack.campfire.yaml +++ b/flatpak/com.cypherstack.campfire.yaml @@ -10,7 +10,8 @@ finish-args: - --socket=fallback-x11 - --socket=wayland - --device=dri - - --filesystem=~/.campfire:create + # 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 8b6b74269f..06eea47ea8 100644 --- a/flatpak/com.cypherstack.stackduo.yaml +++ b/flatpak/com.cypherstack.stackduo.yaml @@ -10,7 +10,8 @@ finish-args: - --socket=fallback-x11 - --socket=wayland - --device=dri - - --filesystem=~/.stackduo:create + # 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 4bdaa87c2b..67e920dd3a 100644 --- a/flatpak/com.cypherstack.stackwallet.yaml +++ b/flatpak/com.cypherstack.stackwallet.yaml @@ -10,7 +10,8 @@ finish-args: - --socket=fallback-x11 - --socket=wayland - --device=dri - - --filesystem=~/.stackwallet:create + # 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..b1d459f09e --- /dev/null +++ b/lib/utilities/flatpak_data_directory.dart @@ -0,0 +1,218 @@ +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..c3bbf41f9e 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,12 @@ 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 the fcntl lock Hive + // holds on hive/dbinfo.lock (those locks are per process). + 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..078b41e173 --- /dev/null +++ b/test/utilities/flatpak_data_directory_test.dart @@ -0,0 +1,193 @@ +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..1cef193b55 --- /dev/null +++ b/test/utilities/stack_file_system_flatpak_env_test.dart @@ -0,0 +1,125 @@ +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 (desktop startup, before Hive opens) 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); + }); +} From ecd09f067c01f577a25f76695d7b6381911e6b56 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 6 Oct 2026 21:39:42 -0500 Subject: [PATCH 3/9] fix(flatpak): move legacy data instead of copying it The verified copy left a full second copy of the wallet data, password and secure store in ~/.. A desktop reset of the new root does not touch it, so the old data survived a "forgot password" reset. Rename the legacy root when possible. Otherwise (always the case inside Flatpak, where it is a bind mount) remove its contents once the copy is verified and in place. A marker written first lets the next launch finish an interrupted removal; legacy data written after a completed migration without that marker is left alone. --- lib/utilities/flatpak_data_directory.dart | 63 +++++++++- .../flatpak_data_directory_test.dart | 113 +++++++++++++++++- 2 files changed, 173 insertions(+), 3 deletions(-) diff --git a/lib/utilities/flatpak_data_directory.dart b/lib/utilities/flatpak_data_directory.dart index b1d459f09e..ee3a7049af 100644 --- a/lib/utilities/flatpak_data_directory.dart +++ b/lib/utilities/flatpak_data_directory.dart @@ -7,14 +7,23 @@ typedef DirectoryCopy = Future Function( Directory source, Directory destination, ); +typedef DirectoryRename = Future Function( + Directory source, + String destination, +); abstract class FlatpakDataDirectory { static const migrationMarker = ".flatpak_data_migration_v1"; - /// Copies legacy data before selecting Flatpak's app-private XDG root. + /// Written to the legacy root before its contents are removed, so a later + /// launch can finish an interrupted removal. + static const legacyRemovalMarker = ".flatpak_data_migrated"; + + /// Moves legacy data before selecting Flatpak's app-private XDG root. static Future resolve({ required Map environment, required String appDirectoryName, + DirectoryRename renameDirectory = _renameDirectory, DirectoryCopy copyDirectory = _copyDirectory, void Function(Object error)? onError, }) async { @@ -55,6 +64,9 @@ abstract class FlatpakDataDirectory { final marker = File(path.join(destination.path, migrationMarker)); if (await marker.exists()) { + if (await File(path.join(legacy.path, legacyRemovalMarker)).exists()) { + await _removeLegacy(legacy, onError); + } return destination; } @@ -90,11 +102,32 @@ abstract class FlatpakDataDirectory { } await Directory(path.dirname(destination.path)).create(recursive: true); + var renamed = true; + try { + await renameDirectory(legacy, destination.path); + } on FileSystemException { + // Always the case inside Flatpak, where the legacy root is a bind + // mount. Fall back to copying. + renamed = false; + } + if (renamed) { + try { + await _initialize(destination); + } catch (error) { + // The data is already in place; the next launch writes the marker. + onError?.call(error); + } + return destination; + } + await copyDirectory(legacy, temporary); await _verifyDirectoryCopy(legacy, temporary); await File(path.join(temporary.path, migrationMarker)) .writeAsString("complete", flush: true); await temporary.rename(destination.path); + // Leave no second copy of the wallet data behind, e.g. one that a + // desktop reset of the new root would not remove. + await _removeLegacy(legacy, onError); return destination; } catch (error) { try { @@ -140,6 +173,34 @@ abstract class FlatpakDataDirectory { } } + static Future _removeLegacy( + Directory legacy, + void Function(Object error)? onError, + ) async { + try { + final removalMarker = File(path.join(legacy.path, legacyRemovalMarker)); + await removalMarker.writeAsString("removing", flush: true); + final entities = await legacy.list(followLinks: false).toList(); + for (final entity in entities) { + if (entity.path != removalMarker.path) { + await entity.delete(recursive: true); + } + } + await removalMarker.delete(); + } catch (error) { + onError?.call(error); + return; + } + try { + await legacy.delete(); + } on FileSystemException { + // Expected inside Flatpak, where the legacy root is a mount point. + } + } + + static Future _renameDirectory(Directory source, String destination) => + source.rename(destination); + static Future _initialize(Directory destination) async { await destination.create(recursive: true); final marker = File(path.join(destination.path, migrationMarker)); diff --git a/test/utilities/flatpak_data_directory_test.dart b/test/utilities/flatpak_data_directory_test.dart index 078b41e173..878c7abaab 100644 --- a/test/utilities/flatpak_data_directory_test.dart +++ b/test/utilities/flatpak_data_directory_test.dart @@ -16,6 +16,15 @@ Future _copyTree(Directory source, Directory destination) async { } } +// What a rename from the legacy bind mount fails with inside Flatpak. +Future _crossDeviceRename(Directory source, String destination) async { + throw FileSystemException( + "Rename failed", + source.path, + const OSError("Invalid cross-device link", 18), + ); +} + void main() { late Directory temporaryRoot; @@ -65,7 +74,7 @@ void main() { ); }); - test("copies and verifies legacy data before switching roots", () async { + test("moves legacy data by renaming it on the same filesystem", () async { final env = environment(); final legacy = Directory(path.join(env["HOME"]!, ".stackwallet")); await Directory(path.join(legacy.path, "hive")).create(recursive: true); @@ -75,6 +84,7 @@ void main() { final result = await FlatpakDataDirectory.resolve( environment: env, appDirectoryName: "stackwallet", + copyDirectory: (_, _) async => fail("should not copy"), ); expect(result!.path, path.join(env["XDG_DATA_HOME"]!, "stackwallet")); @@ -82,7 +92,101 @@ void main() { await File(path.join(result.path, "hive", "wallet.hive")).readAsString(), "wallet data", ); - expect(await legacy.exists(), isTrue); + expect( + await File(path.join(result.path, FlatpakDataDirectory.migrationMarker)) + .exists(), + isTrue, + ); + expect(await legacy.exists(), isFalse); + }); + + test("copies, verifies and removes legacy data across filesystems", () async { + final env = environment(); + final legacy = Directory(path.join(env["HOME"]!, ".stackwallet")); + await Directory(path.join(legacy.path, "hive")).create(recursive: true); + await Directory(path.join(legacy.path, "isar")).create(recursive: true); + await File(path.join(legacy.path, "hive", "desktopdata.hive")) + .writeAsString("password"); + await File(path.join(legacy.path, "hive", "dbinfo.lock")).writeAsString(""); + await File(path.join(legacy.path, "isar", "desktopStore.isar")) + .writeAsString("keys"); + Object? reportedError; + + final result = await FlatpakDataDirectory.resolve( + environment: env, + appDirectoryName: "stackwallet", + renameDirectory: _crossDeviceRename, + onError: (error) => reportedError = error, + ); + + expect(reportedError, isNull); + expect(result!.path, path.join(env["XDG_DATA_HOME"]!, "stackwallet")); + expect( + await File(path.join(result.path, "hive", "desktopdata.hive")) + .readAsString(), + "password", + ); + expect( + await File(path.join(result.path, "isar", "desktopStore.isar")) + .readAsString(), + "keys", + ); + expect(await legacy.exists(), isFalse); + expect(await Directory("${result.path}.migrating").exists(), isFalse); + }); + + test("keeps legacy data when the copy does not verify", () async { + final env = environment(); + final legacy = Directory(path.join(env["HOME"]!, ".stackwallet")); + final legacyFile = File(path.join(legacy.path, "isar", "wallet.isar")); + await legacyFile.parent.create(recursive: true); + await legacyFile.writeAsString("wallet data"); + Object? reportedError; + + final result = await FlatpakDataDirectory.resolve( + environment: env, + appDirectoryName: "stackwallet", + renameDirectory: _crossDeviceRename, + copyDirectory: (source, temporary) async { + await _copyTree(source, temporary); + await File(path.join(temporary.path, "isar", "wallet.isar")) + .writeAsString("corrupted"); + }, + onError: (error) => reportedError = error, + ); + + expect(result!.path, legacy.path); + expect(reportedError, isA()); + expect(await legacyFile.readAsString(), "wallet data"); + expect( + await Directory(path.join(env["XDG_DATA_HOME"]!, "stackwallet")).exists(), + isFalse, + ); + }); + + test("finishes an interrupted removal of migrated legacy data", () async { + final env = environment(); + final legacy = Directory(path.join(env["HOME"]!, ".stackwallet")); + final destination = Directory( + path.join(env["XDG_DATA_HOME"]!, "stackwallet"), + ); + await destination.create(recursive: true); + await File( + path.join(destination.path, FlatpakDataDirectory.migrationMarker), + ).writeAsString("complete"); + await Directory(path.join(legacy.path, "isar")).create(recursive: true); + await File(path.join(legacy.path, FlatpakDataDirectory.legacyRemovalMarker)) + .writeAsString("removing"); + await File(path.join(legacy.path, "isar", "desktopStore.isar")) + .writeAsString("keys"); + + final result = await FlatpakDataDirectory.resolve( + environment: env, + appDirectoryName: "stackwallet", + ); + + expect(result!.path, destination.path); + expect(await legacy.exists(), isFalse); }); test( @@ -99,6 +203,8 @@ void main() { environment: env, appDirectoryName: "stackwallet", ); + // e.g. written by a non-Flatpak build after the migration + await legacyFile.parent.create(recursive: true); await legacyFile.writeAsString("stale"); final second = await FlatpakDataDirectory.resolve( environment: env, @@ -110,6 +216,7 @@ void main() { await File(path.join(second.path, "wallet")).readAsString(), "first", ); + expect(await legacyFile.readAsString(), "stale"); }, ); @@ -122,6 +229,7 @@ void main() { final result = await FlatpakDataDirectory.resolve( environment: env, appDirectoryName: "stackwallet", + renameDirectory: _crossDeviceRename, copyDirectory: (_, _) async => throw const FileSystemException("copy"), onError: (error) => reportedError = error, ); @@ -174,6 +282,7 @@ void main() { final result = await FlatpakDataDirectory.resolve( environment: env, appDirectoryName: "stackwallet", + renameDirectory: _crossDeviceRename, 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. From 799d7efcbfec83e75d725d5cb57379b35348cb35 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 6 Oct 2026 21:40:17 -0500 Subject: [PATCH 4/9] refactor(file-system): resolve the Linux data root from a given environment --- lib/utilities/stack_file_system.dart | 37 ++++++++++++++++++---------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/lib/utilities/stack_file_system.dart b/lib/utilities/stack_file_system.dart index c3bbf41f9e..4448034d47 100644 --- a/lib/utilities/stack_file_system.dart +++ b/lib/utilities/stack_file_system.dart @@ -10,6 +10,7 @@ import 'dart:io'; +import 'package:meta/meta.dart'; import 'package:path/path.dart' as path; import 'package:path_provider/path_provider.dart'; @@ -36,10 +37,29 @@ abstract class StackFileSystem { static Future? _linuxRootResolution; - static Future _resolveLinuxRoot() async { + /// The Linux desktop data root: the -d directory if one was given, + /// otherwise the Flatpak or home directory default. + @visibleForTesting + static Future linuxRootDirectory( + Map environment, + ) async { + if (_overrideDesktopDirPath != null) { + return Directory(_overrideDesktopDirPath!); + } + // 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 the fcntl lock Hive + // holds on hive/dbinfo.lock (those locks are per process). + return _linuxRootResolution ??= _resolveLinuxRoot(environment); + } + + static Future _resolveLinuxRoot( + Map environment, + ) async { try { return await FlatpakDataDirectory.resolve( - environment: Platform.environment, + environment: environment, appDirectoryName: AppConfig.appDefaultDataDirName, onError: (error) => stderr.writeln( "Flatpak data migration failed; using legacy data: $error", @@ -47,7 +67,7 @@ abstract class StackFileSystem { ) ?? Directory( path.join( - Platform.environment['HOME']!, + environment['HOME']!, ".${AppConfig.appDefaultDataDirName}", ), ); @@ -68,16 +88,7 @@ abstract class StackFileSystem { path.join(appDirectory.path, ".${AppConfig.appDefaultDataDirName}"), ); } else if (Platform.isLinux) { - if (_overrideDesktopDirPath != null) { - appDirectory = Directory(_overrideDesktopDirPath!); - } else { - // 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 the fcntl lock Hive - // holds on hive/dbinfo.lock (those locks are per process). - appDirectory = await (_linuxRootResolution ??= _resolveLinuxRoot()); - } + appDirectory = await linuxRootDirectory(Platform.environment); } else if (Platform.isWindows) { if (_overrideDesktopDirPath != null) { appDirectory = Directory(_overrideDesktopDirPath!); From b73d95f5fe582c6b705762827ebfb8058c8e8d6e Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 6 Oct 2026 21:40:28 -0500 Subject: [PATCH 5/9] test(file-system): cover -d precedence over the Flatpak data root --- .../stack_file_system_override_test.dart | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 test/utilities/stack_file_system_override_test.dart diff --git a/test/utilities/stack_file_system_override_test.dart b/test/utilities/stack_file_system_override_test.dart new file mode 100644 index 0000000000..d879d7874e --- /dev/null +++ b/test/utilities/stack_file_system_override_test.dart @@ -0,0 +1,65 @@ +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'; +import 'package:stackwallet/utilities/stack_file_system.dart'; + +// The -d override is process wide and can only be set once, so this file +// sets it before any root is resolved. +void main() { + late Directory temporaryRoot; + late Directory override; + late Map environment; + + setUpAll(() async { + temporaryRoot = await Directory.systemTemp.createTemp("sfs-override-"); + override = Directory(path.join(temporaryRoot.path, "override")); + environment = { + "FLATPAK_ID": "com.cypherstack.stackwallet", + "HOME": path.join(temporaryRoot.path, "home"), + "XDG_DATA_HOME": path.join(temporaryRoot.path, "xdg"), + }; + StackFileSystem.setDesktopOverrideDir(override.path); + }); + + tearDownAll(() async { + await temporaryRoot.delete(recursive: true); + }); + + test("-d wins over the Flatpak root and skips the migration", () async { + final legacyFile = File( + path.join(environment["HOME"]!, ".stackwallet", "hive", "wallet.hive"), + ); + await legacyFile.parent.create(recursive: true); + await legacyFile.writeAsString("wallet data"); + + final root = await StackFileSystem.linuxRootDirectory(environment); + + expect(root.path, override.path); + expect(await legacyFile.readAsString(), "wallet data"); + expect( + await File( + path.join( + legacyFile.parent.parent.path, + FlatpakDataDirectory.legacyRemovalMarker, + ), + ).exists(), + isFalse, + ); + expect(await Directory(environment["XDG_DATA_HOME"]!).exists(), isFalse); + }); + + test("-d wins over the default root", () async { + final root = await StackFileSystem.applicationRootDirectory(); + + expect(root.path, override.path); + }, skip: Platform.isLinux ? false : "Linux only"); + + test("-d cannot be changed once set", () { + expect( + () => StackFileSystem.setDesktopOverrideDir(temporaryRoot.path), + throwsException, + ); + }); +} From 937044dbb58648e5e05e0df3c616eae181458e9d Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 6 Oct 2026 21:40:52 -0500 Subject: [PATCH 6/9] fix(logs): keep desktop logs in the -d data directory --- lib/utilities/stack_file_system.dart | 10 ++++++++++ test/utilities/stack_file_system_override_test.dart | 8 ++++++++ 2 files changed, 18 insertions(+) diff --git a/lib/utilities/stack_file_system.dart b/lib/utilities/stack_file_system.dart index 4448034d47..598d525b53 100644 --- a/lib/utilities/stack_file_system.dart +++ b/lib/utilities/stack_file_system.dart @@ -249,6 +249,16 @@ abstract class StackFileSystem { } } + // Keep logs with a -d data directory instead of the shared documents + // directory, which every instance would otherwise write to. + if (_overrideDesktopDirPath != null) { + final dir = Directory(path.join(_overrideDesktopDirPath!, "logs")); + if (!dir.existsSync()) { + await dir.create(recursive: true); + } + return dir; + } + final appDocsDir = await getApplicationDocumentsDirectory(); const logsDirName = "${AppConfig.prefix}_Logs"; final Directory logsDir; diff --git a/test/utilities/stack_file_system_override_test.dart b/test/utilities/stack_file_system_override_test.dart index d879d7874e..50a8b00f60 100644 --- a/test/utilities/stack_file_system_override_test.dart +++ b/test/utilities/stack_file_system_override_test.dart @@ -3,6 +3,7 @@ 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'; +import 'package:stackwallet/utilities/prefs.dart'; import 'package:stackwallet/utilities/stack_file_system.dart'; // The -d override is process wide and can only be set once, so this file @@ -56,6 +57,13 @@ void main() { expect(root.path, override.path); }, skip: Platform.isLinux ? false : "Linux only"); + test("logs follow the -d data directory", () async { + final logs = await StackFileSystem.applicationLogsDirectory(Prefs.instance); + + expect(logs.path, path.join(override.path, "logs")); + expect(await logs.exists(), isTrue); + }); + test("-d cannot be changed once set", () { expect( () => StackFileSystem.setDesktopOverrideDir(temporaryRoot.path), From c90cc14a7bb070c7d79c594689af9272220172ef Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 7 Oct 2026 14:01:29 -0500 Subject: [PATCH 7/9] fix: persist legacy cleanup intent before migration publication --- lib/utilities/flatpak_data_directory.dart | 9 +++- .../flatpak_data_directory_test.dart | 52 +++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/lib/utilities/flatpak_data_directory.dart b/lib/utilities/flatpak_data_directory.dart index ee3a7049af..030b008e2e 100644 --- a/lib/utilities/flatpak_data_directory.dart +++ b/lib/utilities/flatpak_data_directory.dart @@ -124,7 +124,14 @@ abstract class FlatpakDataDirectory { await _verifyDirectoryCopy(legacy, temporary); await File(path.join(temporary.path, migrationMarker)) .writeAsString("complete", flush: true); - await temporary.rename(destination.path); + final removalMarker = File(path.join(legacy.path, legacyRemovalMarker)); + await removalMarker.writeAsString("removing", flush: true); + final copiedRemovalMarker = File( + path.join(temporary.path, legacyRemovalMarker), + ); + if (await copiedRemovalMarker.exists()) + await copiedRemovalMarker.delete(); + await renameDirectory(temporary, destination.path); // Leave no second copy of the wallet data behind, e.g. one that a // desktop reset of the new root would not remove. await _removeLegacy(legacy, onError); diff --git a/test/utilities/flatpak_data_directory_test.dart b/test/utilities/flatpak_data_directory_test.dart index 878c7abaab..775d318b7a 100644 --- a/test/utilities/flatpak_data_directory_test.dart +++ b/test/utilities/flatpak_data_directory_test.dart @@ -18,6 +18,10 @@ Future _copyTree(Directory source, Directory destination) async { // What a rename from the legacy bind mount fails with inside Flatpak. Future _crossDeviceRename(Directory source, String destination) async { + if (source.path.endsWith(".migrating")) { + await source.rename(destination); + return; + } throw FileSystemException( "Rename failed", source.path, @@ -135,6 +139,54 @@ void main() { expect(await Directory("${result.path}.migrating").exists(), isFalse); }); + for (final published in [false, true]) { + test("resumes interrupted publication (published: $published)", () async { + final env = environment(); + final legacy = Directory(path.join(env["HOME"]!, ".stackwallet")); + final wallet = File(path.join(legacy.path, "wallet")); + await legacy.create(recursive: true); + await wallet.writeAsString("keys"); + final destination = path.join(env["XDG_DATA_HOME"]!, "stackwallet"); + + await FlatpakDataDirectory.resolve( + environment: env, + appDirectoryName: "stackwallet", + renameDirectory: (source, target) async { + if (source.path == legacy.path) { + return _crossDeviceRename(source, target); + } + expect( + await File( + path.join(legacy.path, FlatpakDataDirectory.legacyRemovalMarker), + ).exists(), + isTrue, + ); + if (published) await source.rename(target); + throw const FileSystemException("interrupted publication"); + }, + ); + expect(await wallet.readAsString(), "keys"); + + final result = await FlatpakDataDirectory.resolve( + environment: env, + appDirectoryName: "stackwallet", + renameDirectory: _crossDeviceRename, + ); + expect(result!.path, destination); + expect( + await File(path.join(destination, "wallet")).readAsString(), + "keys", + ); + expect(await legacy.exists(), isFalse); + expect( + await File( + path.join(destination, FlatpakDataDirectory.legacyRemovalMarker), + ).exists(), + isFalse, + ); + }); + } + test("keeps legacy data when the copy does not verify", () async { final env = environment(); final legacy = Directory(path.join(env["HOME"]!, ".stackwallet")); From 23fbe0fe641db9a1d711274d4d3df2aead03ffd0 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 10 Nov 2026 13:57:34 -0600 Subject: [PATCH 8/9] fix(flatpak): serialize legacy data migration safely --- lib/utilities/flatpak_data_directory.dart | 62 +++++++-- .../flatpak_data_directory_test.dart | 130 ++++++++++++++++-- 2 files changed, 165 insertions(+), 27 deletions(-) diff --git a/lib/utilities/flatpak_data_directory.dart b/lib/utilities/flatpak_data_directory.dart index 347436310b..623c326a3b 100644 --- a/lib/utilities/flatpak_data_directory.dart +++ b/lib/utilities/flatpak_data_directory.dart @@ -3,8 +3,10 @@ import 'dart:io'; import 'package:crypto/crypto.dart'; import 'package:path/path.dart' as path; -typedef DirectoryCopy = - Future Function(Directory source, Directory destination); +typedef DirectoryCopy = Future Function( + Directory source, + Directory destination, +); abstract class FlatpakDataDirectory { static const migrationMarker = ".flatpak_data_migration_v1"; @@ -66,14 +68,21 @@ abstract class FlatpakDataDirectory { } final temporary = Directory("${destination.path}.migrating"); + RandomAccessFile? migrationLock; RandomAccessFile? legacyLock; try { - legacyLock = await _lockLegacyDatabase(legacy); + migrationLock = await _lockMigration(destination); // Another instance may have completed a verified migration between the - // marker check above and taking the lock. + // marker check above and taking the migration lock. if (await marker.exists()) { return destination; } + if (await destination.exists()) { + throw StateError( + "Flatpak data migration found both legacy and unverified data", + ); + } + legacyLock = await _lockLegacyDatabase(legacy); final temporaryType = await FileSystemEntity.type( temporary.path, followLinks: false, @@ -90,9 +99,8 @@ abstract class FlatpakDataDirectory { 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 File(path.join(temporary.path, migrationMarker)) + .writeAsString("complete", flush: true); await temporary.rename(destination.path); return destination; } catch (error) { @@ -112,13 +120,38 @@ abstract class FlatpakDataDirectory { onError?.call(error); return legacy; } finally { - if (legacyLock != null) { - try { - await legacyLock.unlock(); - await legacyLock.close(); - } catch (error) { - onError?.call(error); - } + await _releaseLock(legacyLock, onError); + await _releaseLock(migrationLock, onError); + } + } + + static Future _lockMigration(Directory destination) async { + await Directory(path.dirname(destination.path)).create(recursive: true); + final lockFile = File("${destination.path}.migration.lock"); + final handle = await lockFile.open(mode: FileMode.append); + try { + await handle.lock(FileLock.exclusive); + return handle; + } catch (_) { + await handle.close(); + rethrow; + } + } + + static Future _releaseLock( + RandomAccessFile? lock, + void Function(Object error)? onError, + ) async { + if (lock != null) { + try { + await lock.unlock(); + } catch (error) { + onError?.call(error); + } + try { + await lock.close(); + } catch (error) { + onError?.call(error); } } } @@ -128,7 +161,6 @@ abstract class FlatpakDataDirectory { if (!await lockFile.exists()) { return null; } - final handle = await lockFile.open(mode: FileMode.append); try { await handle.lock(FileLock.exclusive); diff --git a/test/utilities/flatpak_data_directory_test.dart b/test/utilities/flatpak_data_directory_test.dart index 0fa62a9399..4d97623bac 100644 --- a/test/utilities/flatpak_data_directory_test.dart +++ b/test/utilities/flatpak_data_directory_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; @@ -16,6 +18,19 @@ Future _copyTree(Directory source, Directory destination) async { } } +String _dartExecutable() { + final flutterRoot = Platform.environment["FLUTTER_ROOT"]; + if (flutterRoot != null) { + return path.join(flutterRoot, "bin", "cache", "dart-sdk", "bin", "dart"); + } + + var directory = path.dirname(Platform.resolvedExecutable); + for (var i = 0; i < 6; i++) { + directory = path.dirname(directory); + } + return path.join(directory, "bin", "cache", "dart-sdk", "bin", "dart"); +} + void main() { late Directory temporaryRoot; @@ -59,9 +74,8 @@ void main() { expect(result!.path, path.join(temporaryRoot.path, "xdg", "stackwallet")); expect( - await File( - path.join(result.path, FlatpakDataDirectory.migrationMarker), - ).exists(), + await File(path.join(result.path, FlatpakDataDirectory.migrationMarker)) + .exists(), isTrue, ); }); @@ -70,9 +84,8 @@ void main() { 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"); + await File(path.join(legacy.path, "hive", "wallet.hive")) + .writeAsString("wallet data"); final result = await FlatpakDataDirectory.resolve( environment: env, @@ -85,6 +98,101 @@ void main() { "wallet data", ); expect(await legacy.exists(), isTrue); + expect( + await File(path.join(legacy.path, "hive", "dbinfo.lock")).exists(), + isFalse, + ); + }); + + test( + "locks a legacy directory before copying when no Hive lock exists", + () async { + final env = environment(); + final legacy = Directory(path.join(env["HOME"]!, ".stackwallet")); + await legacy.create(recursive: true); + await File(path.join(legacy.path, "wallet")).writeAsString("legacy data"); + final lockFilePath = path.join( + env["XDG_DATA_HOME"]!, + "stackwallet.migration.lock", + ); + final lockProbe = File(path.join(temporaryRoot.path, "try_lock.dart")); + await lockProbe.writeAsString(''' +import 'dart:io'; +Future main(List args) async { + final handle = await File(args.single).open(mode: FileMode.append); + try { + await handle.lock(FileLock.exclusive); + print('ACQUIRED'); + await handle.unlock(); + } on FileSystemException { + print('BLOCKED'); + } finally { + await handle.close(); + } +} +'''); + + Process? lockProbeProcess; + var lockWasHeldDuringCopy = false; + late final Future lockProbeOutput; + final result = await FlatpakDataDirectory.resolve( + environment: env, + appDirectoryName: "stackwallet", + copyDirectory: (source, destination) async { + final probe = await Process.start(_dartExecutable(), [ + lockProbe.path, + lockFilePath, + ]); + lockProbeProcess = probe; + lockProbeOutput = probe.stdout.transform(utf8.decoder).join(); + final probeExited = Completer(); + probe.exitCode.then((_) => probeExited.complete()); + await Future.delayed(const Duration(milliseconds: 100)); + lockWasHeldDuringCopy = !probeExited.isCompleted; + await _copyTree(source, destination); + }, + ); + + expect(lockWasHeldDuringCopy, isTrue); + expect(await lockProbeProcess!.exitCode, 0); + expect((await lockProbeOutput).trim(), "ACQUIRED"); + expect(result!.path, path.join(env["XDG_DATA_HOME"]!, "stackwallet")); + expect( + await File(path.join(result.path, "wallet")).readAsString(), + "legacy data", + ); + expect(await Directory(path.join(legacy.path, "hive")).exists(), isFalse); + }, + ); + + test("does not modify legacy data when the XDG root is read-only", () async { + final env = environment(); + final legacy = Directory(path.join(env["HOME"]!, ".stackwallet")); + final xdg = Directory(env["XDG_DATA_HOME"]!); + await legacy.create(recursive: true); + await File(path.join(legacy.path, "wallet")).writeAsString("legacy data"); + await xdg.create(recursive: true); + await Process.run("chmod", ["0500", xdg.path]); + + try { + final result = await FlatpakDataDirectory.resolve( + environment: env, + appDirectoryName: "stackwallet", + ); + + expect(result!.path, legacy.path); + expect( + await File(path.join(legacy.path, "wallet")).readAsString(), + "legacy data", + ); + expect(await Directory(path.join(legacy.path, "hive")).exists(), isFalse); + expect( + await File(path.join(legacy.path, "hive", "dbinfo.lock")).exists(), + isFalse, + ); + } finally { + await Process.run("chmod", ["0700", xdg.path]); + } }); test( @@ -131,9 +239,8 @@ void main() { expect(result!.path, legacy.path); expect(reportedError, isA()); expect( - await Directory( - "${env["XDG_DATA_HOME"]!}/stackwallet.migrating", - ).exists(), + await Directory("${env["XDG_DATA_HOME"]!}/stackwallet.migrating") + .exists(), isFalse, ); }); @@ -170,9 +277,8 @@ void main() { 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"); + await File(path.join(legacy.path, "hive", "wallet.hive")) + .writeAsString("wallet data"); Object? reportedError; final result = await FlatpakDataDirectory.resolve( From ff7f9212cac5c9a7761ac538b1ac08cd24cfc7c6 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 10 Nov 2026 14:13:30 -0600 Subject: [PATCH 9/9] fix(flatpak): serialize migration before legacy cleanup --- lib/utilities/flatpak_data_directory.dart | 4 +- .../flatpak_data_directory_test.dart | 84 +++++++++++++++++++ 2 files changed, 86 insertions(+), 2 deletions(-) diff --git a/lib/utilities/flatpak_data_directory.dart b/lib/utilities/flatpak_data_directory.dart index 6156f92220..3dbe6fa440 100644 --- a/lib/utilities/flatpak_data_directory.dart +++ b/lib/utilities/flatpak_data_directory.dart @@ -97,7 +97,6 @@ abstract class FlatpakDataDirectory { } final temporary = Directory("${destination.path}.migrating"); - RandomAccessFile? migrationLock; RandomAccessFile? legacyLock; try { legacyLock = await _lockLegacyDatabase(legacy); @@ -147,8 +146,9 @@ abstract class FlatpakDataDirectory { final copiedRemovalMarker = File( path.join(temporary.path, legacyRemovalMarker), ); - if (await copiedRemovalMarker.exists()) + if (await copiedRemovalMarker.exists()) { await copiedRemovalMarker.delete(); + } await renameDirectory(temporary, destination.path); // Leave no second copy of the wallet data behind, e.g. one that a // desktop reset of the new root would not remove. diff --git a/test/utilities/flatpak_data_directory_test.dart b/test/utilities/flatpak_data_directory_test.dart index 775d318b7a..e01840d818 100644 --- a/test/utilities/flatpak_data_directory_test.dart +++ b/test/utilities/flatpak_data_directory_test.dart @@ -187,6 +187,90 @@ void main() { }); } + test("serializes concurrent first migrations without a Hive lock", () async { + final env = environment(); + final legacy = Directory(path.join(env["HOME"]!, ".stackwallet")); + await legacy.create(recursive: true); + await File(path.join(legacy.path, "wallet")).writeAsString("keys"); + final counter = File(path.join(temporaryRoot.path, "rename-calls")); + final script = File(path.join(temporaryRoot.path, "migrate.dart")); + await script.writeAsString(''' +import 'dart:io'; +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); + } + } +} +Future main(List args) async { + final legacy = Directory(args[0]); + final env = { + 'FLATPAK_ID': 'com.cypherstack.stackwallet', + 'HOME': Directory(legacy.path).parent.path, + 'XDG_DATA_HOME': args[1], + }; + await FlatpakDataDirectory.resolve( + environment: env, + appDirectoryName: 'stackwallet', + renameDirectory: (source, target) async { + if (source.path == legacy.path) { + await File(args[2]).writeAsString('x', mode: FileMode.append); + await Future.delayed(const Duration(milliseconds: 200)); + throw FileSystemException('cross-device', source.path); + } + await source.rename(target); + }, + copyDirectory: copyTree, + ); +} +'''); + final args = [legacy.path, env["XDG_DATA_HOME"]!, counter.path]; + final packageConfig = path.join( + Directory.current.path, + ".dart_tool/package_config.json", + ); + final processes = await Future.wait([ + Process.start("dart", [ + "--packages=$packageConfig", + script.path, + ...args, + ]), + Process.start("dart", [ + "--packages=$packageConfig", + script.path, + ...args, + ]), + ]); + final results = await Future.wait( + processes.map((process) async { + final stdout = await process.stdout + .transform(systemEncoding.decoder) + .join(); + final stderr = await process.stderr + .transform(systemEncoding.decoder) + .join(); + final code = await process.exitCode; + return (code: code, stdout: stdout, stderr: stderr); + }), + ); + + expect(results.map((result) => result.code), everyElement(0)); + expect(await counter.readAsString(), "x"); + expect( + await File(path.join(env["XDG_DATA_HOME"]!, "stackwallet", "wallet")) + .readAsString(), + "keys", + ); + expect(await legacy.exists(), isFalse); + }); + test("keeps legacy data when the copy does not verify", () async { final env = environment(); final legacy = Directory(path.join(env["HOME"]!, ".stackwallet"));