diff --git a/flatpak/com.cypherstack.campfire.yaml b/flatpak/com.cypherstack.campfire.yaml index 525895417..59934ea38 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=all + # Retained for legacy-data migration. - --filesystem=~/.campfire:create - --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 5dcfdde22..a874facb3 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=all + # Retained for legacy-data migration. - --filesystem=~/.stackduo:create - --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 44f693b33..372450953 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=all + # Retained for legacy-data migration. - --filesystem=~/.stackwallet:create - --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 000000000..3dbe6fa44 --- /dev/null +++ b/lib/utilities/flatpak_data_directory.dart @@ -0,0 +1,315 @@ +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:path/path.dart' as path; + +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"; + + /// 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 { + 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)); + RandomAccessFile? migrationLock; + try { + // Serialize before touching legacy data. A separate lock in the XDG + // parent also covers first launch when Hive has not created dbinfo.lock. + await Directory(path.dirname(destination.path)).create(recursive: true); + migrationLock = await File("${destination.path}.migration.lock") + .open(mode: FileMode.append); + await migrationLock.lock(FileLock.exclusive); + } catch (error) { + await _releaseLock(migrationLock, onError); + onError?.call(error); + // If XDG is not writable, keep using the legacy data without mutation. + return legacy; + } + + if (await marker.exists()) { + if (await File(path.join(legacy.path, legacyRemovalMarker)).exists()) { + await _removeLegacy(legacy, onError); + } + await _releaseLock(migrationLock, onError); + return destination; + } + + if (await destination.exists()) { + onError?.call( + StateError( + "Flatpak data migration found both legacy and unverified data", + ), + ); + await _releaseLock(migrationLock, onError); + 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); + 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); + 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); + 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 { + await _releaseLock(legacyLock, onError); + await _releaseLock(migrationLock, onError); + } + } + + static Future _releaseLock( + RandomAccessFile? lock, + void Function(Object error)? onError, + ) async { + if (lock == null) return; + try { + await lock.unlock(); + } catch (error) { + onError?.call(error); + } + try { + await lock.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 _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)); + 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 292dda136..598d525b5 100644 --- a/lib/utilities/stack_file_system.dart +++ b/lib/utilities/stack_file_system.dart @@ -10,10 +10,12 @@ import 'dart:io'; +import 'package:meta/meta.dart'; 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 +35,49 @@ abstract class StackFileSystem { static bool get _createSubDirs => Util.isDesktop || AppConfig.appName == "Campfire"; + static Future? _linuxRootResolution; + + /// 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: environment, + appDirectoryName: AppConfig.appDefaultDataDirName, + onError: (error) => stderr.writeln( + "Flatpak data migration failed; using legacy data: $error", + ), + ) ?? + Directory( + path.join( + environment['HOME']!, + ".${AppConfig.appDefaultDataDirName}", + ), + ); + } catch (_) { + // Only a successful resolution is sticky. + _linuxRootResolution = null; + rethrow; + } + } + static Future applicationRootDirectory() async { Directory appDirectory; @@ -43,16 +88,7 @@ abstract class StackFileSystem { path.join(appDirectory.path, ".${AppConfig.appDefaultDataDirName}"), ); } else if (Platform.isLinux) { - if (_overrideDesktopDirPath != null) { - appDirectory = Directory(_overrideDesktopDirPath!); - } else { - appDirectory = Directory( - path.join( - Platform.environment['HOME']!, - ".${AppConfig.appDefaultDataDirName}", - ), - ); - } + appDirectory = await linuxRootDirectory(Platform.environment); } else if (Platform.isWindows) { if (_overrideDesktopDirPath != null) { appDirectory = Directory(_overrideDesktopDirPath!); @@ -213,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/flatpak_data_directory_test.dart b/test/utilities/flatpak_data_directory_test.dart new file mode 100644 index 000000000..e01840d81 --- /dev/null +++ b/test/utilities/flatpak_data_directory_test.dart @@ -0,0 +1,438 @@ +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); + } + } +} + +// 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, + const OSError("Invalid cross-device link", 18), + ); +} + +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("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); + await File(path.join(legacy.path, "hive", "wallet.hive")) + .writeAsString("wallet data"); + + 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")); + expect( + await File(path.join(result.path, "hive", "wallet.hive")).readAsString(), + "wallet data", + ); + 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); + }); + + 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("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")); + 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( + "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", + ); + // 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, + appDirectoryName: "stackwallet", + ); + + expect(second!.path, first!.path); + expect( + await File(path.join(second.path, "wallet")).readAsString(), + "first", + ); + expect(await legacyFile.readAsString(), "stale"); + }, + ); + + 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", + renameDirectory: _crossDeviceRename, + 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", + 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. + 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 000000000..1cef193b5 --- /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); + }); +} 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 000000000..50a8b00f6 --- /dev/null +++ b/test/utilities/stack_file_system_override_test.dart @@ -0,0 +1,73 @@ +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 +// 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("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), + throwsException, + ); + }); +}