From 3ae7aa554b7a265e312b30829df66ab4e856d4d2 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 1 Aug 2026 02:53:19 +0200 Subject: [PATCH] fix(prefs): recover a preference store whose bytes are not valid UTF-8 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `File.readAsString` reports a UTF-8 decode failure as a FileSystemException, not a FormatException, so three guards written for that case never ran. The preflight's `on FormatException` branch was unreachable and its `on FileSystemException` sibling waved the document through; the plugin then threw the same FileSystemException, which failed the FormatException/TypeError test that decides repairability; and quarantine's lossy-decode fallback sat dead behind a rethrow. A store with one bad high byte — a UTF-16 BOM, a stray 0x80 — therefore reached the user as a failure screen with no Repair button and no way forward at all. Read bytes and decode explicitly instead, at both sites. Classification moves into describeStoreDamage, so a failure that surfaces after the preflight passed is judged by re-reading the file rather than by the error's type: a denied or locked store is indistinguishable from a decode failure by type or message, and offering a destructive repair for a permissions problem would reset every setting and risk the vault key over something a chmod fixes. isCorruptStoreError went with it, having no remaining callers. A repair that quarantines the store and then cannot reopen it no longer strands the process either. The repaired future was built straight from the cache loader, bypassing the self-healing reset sharedCache installs, so a failed reopen parked a rejected future in _cacheFuture and every later attempt replayed that stale error — with the damaged file already moved aside, so a restart would have booted cleanly. CorruptPreferenceStoreException now carries reopenSafe and a derived, content-free shape: byte length, whether it decoded, whether every byte is zero. #1732 arrived as "FormatException at offset 0" and nothing else, which cannot separate an all-zero file from a non-JSON first character from bytes that are not UTF-8; these can, and never quote the document. Cover the loop against the real desktop backend rather than a fake. shared_preferences_linux is pure Dart, byte-identical to the Windows implementation, and exposes fs/pathProvider, so pointing it at a temp directory exercises the genuine read, parse, cache and write path on any host — the join between preflight, classification and reopen where every one of these defects lived, and which had no coverage at all. --- .../base_shared_preferences_service.dart | 42 ++-- lib/services/prefs_recovery.dart | 145 ++++++++++--- pubspec.lock | 4 +- pubspec.yaml | 5 + .../base_shared_preferences_service_test.dart | 56 +++++ test/services/prefs_recovery_test.dart | 111 ++++++++++ .../prefs_store_repair_flow_test.dart | 202 ++++++++++++++++++ 7 files changed, 517 insertions(+), 48 deletions(-) create mode 100644 test/services/prefs_store_repair_flow_test.dart diff --git a/lib/services/base_shared_preferences_service.dart b/lib/services/base_shared_preferences_service.dart index 18fc1fd2..54634e7b 100644 --- a/lib/services/base_shared_preferences_service.dart +++ b/lib/services/base_shared_preferences_service.dart @@ -93,12 +93,19 @@ abstract class BaseSharedPreferencesService { try { return await _openSharedCache(); } catch (error, stackTrace) { - if (!PrefsRecovery.isCorruptStoreError(error)) rethrow; - // The preflight accepted this document and the plugin still rejected it, - // so it has now cached something we cannot reason about. Repair can + // The preflight accepted this document and the plugin still rejected it. + // Classify by re-reading the bytes, never by the error's type: the + // desktop backends surface a UTF-8 decode failure as a + // `FileSystemException`, which is indistinguishable from a denied or + // locked file, and a permission error must never be offered a + // destructive repair. A null result means the document on disk is fine, + // so whatever went wrong keeps its own type and its own path. + final damage = await PrefsRecovery.describeCurrentStoreDamage(reopenSafe: false); + if (damage == null) rethrow; + // The plugin has now cached something we cannot reason about. Repair can // still quarantine the file, but the process has to restart afterwards. appLogger.e('Preference store could not be parsed', error: error, stackTrace: stackTrace); - throw CorruptPreferenceStoreException(error, stackTrace, reopenSafe: false); + throw damage; } } @@ -159,14 +166,25 @@ abstract class BaseSharedPreferencesService { } _cacheFuture = null; - final repaired = _cacheLoader().then((cache) async { - final vaultKey = salvaged.vaultKey; - if (vaultKey != null) await cache.setString(credentialVaultKeyPref, vaultKey); - for (final entry in salvaged.sessions.entries) { - await cache.setString(entry.key, entry.value); - } - return cache; - }); + late final Future repaired; + repaired = _cacheLoader() + .then((cache) async { + final vaultKey = salvaged.vaultKey; + if (vaultKey != null) await cache.setString(credentialVaultKeyPref, vaultKey); + for (final entry in salvaged.sessions.entries) { + await cache.setString(entry.key, entry.value); + } + return cache; + }) + .onError((error, stackTrace) { + // The same self-healing reset `sharedCache` installs, for the same + // reason. Without it a reopen that fails *after* the store was + // already quarantined would leave a permanently rejected future in + // `_cacheFuture`, and every later retry would replay that stale error + // for the rest of the process — with the on-disk cause already gone. + if (identical(_cacheFuture, repaired)) _cacheFuture = null; + Error.throwWithStackTrace(error, stackTrace); + }); _cacheFuture = repaired; await repaired; diff --git a/lib/services/prefs_recovery.dart b/lib/services/prefs_recovery.dart index 10b3b66a..54037cb4 100644 --- a/lib/services/prefs_recovery.dart +++ b/lib/services/prefs_recovery.dart @@ -25,6 +25,39 @@ const String prefsStoreFileName = 'shared_preferences.json'; /// spelling until the legacy-to-async migration has copied them across. const String legacyKeyPrefix = 'flutter.'; +/// Derived, content-free description of a damaged store's bytes. +/// +/// Every field is a measurement, never a quotation: a length, and two +/// booleans. Together they separate the shapes that all surface as +/// "FormatException at offset 0" — an all-zero file, a document with a +/// non-JSON first character, bytes that are not UTF-8 at all — which the +/// message alone cannot (#1732). +class PrefsStoreShape { + const PrefsStoreShape({required this.length, required this.validUtf8, required this.allZero}); + + factory PrefsStoreShape.of(List bytes) => PrefsStoreShape( + length: bytes.length, + validUtf8: _decodesAsUtf8(bytes), + allZero: bytes.isNotEmpty && bytes.every((byte) => byte == 0), + ); + + final int length; + final bool validUtf8; + final bool allZero; + + static bool _decodesAsUtf8(List bytes) { + try { + utf8.decode(bytes); + return true; + } on FormatException { + return false; + } + } + + @override + String toString() => '$length bytes, ${validUtf8 ? 'valid' : 'invalid'} UTF-8${allZero ? ', every byte zero' : ''}'; +} + /// Raised when the preference store exists but cannot be parsed. /// /// `BaseSharedPreferencesService` converts the platform's raw @@ -38,10 +71,10 @@ const String legacyKeyPrefix = 'flutter.'; /// offset. Anything holding that object could persist, render or upload raw /// credential material — `credential_vault_key_v1`, a tracker refresh token, a /// Seerr cookie — and at this point in startup `LogRedactionManager` has no -/// registered values to catch it. Only the cause's type and offset survive, -/// both of which are safe by construction. +/// registered values to catch it. Only the cause's type, offset and the +/// derived [PrefsStoreShape] survive, all of which are safe by construction. class CorruptPreferenceStoreException implements Exception { - CorruptPreferenceStoreException(Object cause, this.causeStackTrace, {this.reopenSafe = true}) + CorruptPreferenceStoreException(Object cause, this.causeStackTrace, {this.reopenSafe = true, this.shape}) : causeType = cause.runtimeType.toString(), offset = cause is FormatException ? cause.offset : null; @@ -64,10 +97,14 @@ class CorruptPreferenceStoreException implements Exception { /// A repair in that state must be followed by a restart. final bool reopenSafe; + /// Measurements of the rejected bytes, when they were available. + final PrefsStoreShape? shape; + @override String toString() => 'CorruptPreferenceStoreException: the preference store could not be parsed' - ' ($causeType${offset == null ? '' : ' at offset $offset'})'; + ' ($causeType${offset == null ? '' : ' at offset $offset'};' + ' ${shape ?? 'shape unavailable'}; reopenSafe: $reopenSafe)'; } /// Raised when a credential preference exists but its stored type no longer @@ -160,18 +197,18 @@ class SalvagedPrefsCredentials { /// user an explicit choice that names the real cost, and only an accepted /// choice reaches `BaseSharedPreferencesService.repairCorruptStore`. abstract final class PrefsRecovery { - /// Whether [error] means the store is present but unparseable, as opposed to - /// missing, inaccessible, or a plugin failure. - /// - /// `shared_preferences_windows` throws `FormatException` from `json.decode` - /// on a truncated file and `TypeError` from `Map.from` when - /// a decoded value is null. - static bool isCorruptStoreError(Object error) => error is FormatException || error is TypeError; - /// Whether a repair can be attempted on this platform. Only the desktop /// implementations use a single JSON file we can salvage and quarantine; /// Android, iOS and macOS delegate to platform-native stores. - static bool get isSupportedPlatform => Platform.isWindows || Platform.isLinux; + static bool get isSupportedPlatform => _supportedPlatformOverride ?? (Platform.isWindows || Platform.isLinux); + + static bool? _supportedPlatformOverride; + + /// Test seam. The desktop store path is Windows/Linux only, so a host suite + /// running anywhere else cannot reach the production preflight at all — and + /// that preflight is precisely where #1732 is decided. + @visibleForTesting + static void debugSetSupportedPlatformOverride(bool? value) => _supportedPlatformOverride = value; static Future storeFile() async { final directory = await getApplicationSupportDirectory(); @@ -194,51 +231,94 @@ abstract final class PrefsRecovery { /// No-op on the platforms that use a native store, and on a missing store — /// a first launch has nothing to validate. static Future assertStoreReadable({File? storeFileOverride}) async { - if (storeFileOverride == null && !isSupportedPlatform) return; + final damage = await describeCurrentStoreDamage(reopenSafe: true, storeFileOverride: storeFileOverride); + if (damage != null) throw damage; + } + + /// Re-reads the live store and classifies it, without throwing. + /// + /// Returns null when the bytes on disk are ones the desktop backends can + /// hold — including a missing or unreadable file, neither of which a repair + /// addresses. A non-null result means the *document* is damaged, which is + /// the only thing that justifies offering the user a destructive repair. + /// + /// Used both as the preflight and, with `reopenSafe: false`, to classify a + /// failure that surfaced after the preflight already passed. + static Future describeCurrentStoreDamage({ + required bool reopenSafe, + File? storeFileOverride, + }) async { + if (storeFileOverride == null && !isSupportedPlatform) return null; final File file; try { file = storeFileOverride ?? await storeFile(); - if (!await file.exists()) return; + if (!await file.exists()) return null; } on Object { // Locating or stat-ing the directory is a different failure entirely // (missing/denied application support); let the plugin report it. - return; + return null; } + final List bytes; + try { + bytes = await file.readAsBytes(); + } on FileSystemException { + return null; // Unreadable rather than invalid; not something a repair fixes. + } + + return describeStoreDamage(bytes, reopenSafe: reopenSafe); + } + + /// Classifies raw store bytes, returning the failure to surface or null when + /// the document is one the desktop backends can hold. + /// + /// Byte-level on purpose. `File.readAsString` reports a UTF-8 decode failure + /// as a `FileSystemException`, indistinguishable by type from a denied or + /// locked file, so decoding explicitly here is the only way to tell a damaged + /// document apart from a file we simply cannot read (#1732). + @visibleForTesting + static CorruptPreferenceStoreException? describeStoreDamage(List bytes, {bool reopenSafe = true}) { + final shape = PrefsStoreShape.of(bytes); + final String raw; try { - raw = await file.readAsString(); - } on FileSystemException { - return; // Unreadable rather than invalid; not something a repair fixes. + raw = utf8.decode(bytes); } on FormatException catch (error, stackTrace) { - throw CorruptPreferenceStoreException(error, stackTrace); // Not UTF-8. + return CorruptPreferenceStoreException(error, stackTrace, reopenSafe: reopenSafe, shape: shape); } - if (raw.isEmpty) return; + // Both desktop backends skip `json.decode` for an empty document and start + // from `{}`, so an empty store is a first launch, not damage. + if (raw.isEmpty) return null; final Object? decoded; try { decoded = jsonDecode(raw); } on FormatException catch (error, stackTrace) { - throw CorruptPreferenceStoreException(error, stackTrace); + return CorruptPreferenceStoreException(error, stackTrace, reopenSafe: reopenSafe, shape: shape); } if (decoded is! Map) { - throw CorruptPreferenceStoreException( + return CorruptPreferenceStoreException( const FormatException('Preference store is not a JSON object'), StackTrace.current, + reopenSafe: reopenSafe, + shape: shape, ); } for (final entry in decoded.entries) { if (entry.key is! String || !_isStorableValue(entry.value)) { // The key name is safe to omit and the value must never be quoted, so // the exception deliberately carries neither. - throw CorruptPreferenceStoreException( + return CorruptPreferenceStoreException( const FormatException('Preference store holds a value of an unsupported type'), StackTrace.current, + reopenSafe: reopenSafe, + shape: shape, ); } } + return null; } /// Mirrors what the desktop backends can hold: the JSON scalars plus a @@ -381,16 +461,13 @@ abstract final class PrefsRecovery { return (salvaged: SalvagedPrefsCredentials.empty, backupPath: null); } - String raw; - try { - raw = await file.readAsString(); - } on FileSystemException { - rethrow; - } on FormatException { - // Not valid UTF-8 either; a lossy read still exposes the ASCII-only - // credential entries to the salvage pass. - raw = const Utf8Decoder(allowMalformed: true).convert(await file.readAsBytes()); - } + // Read bytes and decode lossily rather than calling `readAsString`, which + // surfaces a UTF-8 decode failure as a `FileSystemException` and would + // abort the repair on exactly the damaged store it exists to rescue. For a + // well-formed document this is identical to a strict decode; for a damaged + // one it still exposes the ASCII credential entries to the salvage pass. + // A genuine read failure propagates — that is not something a repair fixes. + final raw = const Utf8Decoder(allowMalformed: true).convert(await file.readAsBytes()); final salvaged = salvage(raw); diff --git a/pubspec.lock b/pubspec.lock index d017f33b..1fa4a129 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -845,7 +845,7 @@ packages: source: hosted version: "2.6.0" path_provider_linux: - dependency: transitive + dependency: "direct dev" description: name: path_provider_linux sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" @@ -1100,7 +1100,7 @@ packages: source: hosted version: "2.5.6" shared_preferences_linux: - dependency: transitive + dependency: "direct dev" description: name: shared_preferences_linux sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" diff --git a/pubspec.yaml b/pubspec.yaml index 495912f0..6f24b708 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -99,6 +99,11 @@ dev_dependencies: drift_dev: ^2.28.3 shared_preferences_platform_interface: ^2.4.0 path_provider_platform_interface: ^2.1.0 + # Drives the real desktop preference backend against a temp file in tests, so + # the #1732 corrupt-store repair loop is covered on any host and not only on + # a Windows/Linux runner. Both classes expose `fs`/`pathProvider` for this. + shared_preferences_linux: ^2.4.1 + path_provider_linux: ^2.2.1 plugin_platform_interface: ^2.1.0 freezed: ^3.2.5 analyzer: 10.2.0 diff --git a/test/services/base_shared_preferences_service_test.dart b/test/services/base_shared_preferences_service_test.dart index 12cfb835..f4f8221c 100644 --- a/test/services/base_shared_preferences_service_test.dart +++ b/test/services/base_shared_preferences_service_test.dart @@ -1,8 +1,12 @@ import 'dart:async'; +import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; import 'package:plezy/services/base_shared_preferences_service.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../test_helpers/io_fakes.dart'; import '../test_helpers/prefs.dart'; void main() { @@ -57,4 +61,56 @@ void main() { await cache.setString('loader', 'production'); expect(cache.getString('loader'), 'production'); }); + + _poisonedCacheRegression(); +} + +/// A repair that quarantines the store and then cannot reopen it must not +/// leave the process permanently unable to try again. +/// +/// Before #1732's fix the repaired future was built straight from the cache +/// loader, bypassing the self-healing `onError` reset that `sharedCache` +/// installs. A reopen failure therefore parked a rejected future in +/// `_cacheFuture`, and every later attempt replayed that stale error for the +/// rest of the process — with the damaged file already moved aside, so a +/// restart would have booted cleanly. +void _poisonedCacheRegression() { + group('repairCorruptStore', () { + late Directory root; + late PathProviderPlatform previousPathProvider; + + setUp(() async { + resetSharedPreferencesForTest(); + root = await Directory.systemTemp.createTemp('plezy_repair_reopen_'); + previousPathProvider = PathProviderPlatform.instance; + PathProviderPlatform.instance = FakePathProvider(root); + }); + + tearDown(() async { + BaseSharedPreferencesService.resetForTesting(); + PathProviderPlatform.instance = previousPathProvider; + if (await root.exists()) await root.delete(recursive: true); + }); + + test('a reopen failure does not poison the shared cache', () async { + final support = Directory(p.join(root.path, 'support'))..createSync(recursive: true); + File(p.join(support.path, 'shared_preferences.json')).writeAsStringSync('{"theme":"dark"'); + + var loadCount = 0; + BaseSharedPreferencesService.setCacheLoaderForTesting(() { + loadCount++; + if (loadCount == 1) return Future.error(StateError('reopen failed')); + return SharedPreferencesWithCache.create(cacheOptions: const SharedPreferencesWithCacheOptions()); + }); + + await expectLater(BaseSharedPreferencesService.repairCorruptStore(), throwsA(isA())); + + // The damaged file is already quarantined, so the next attempt has a + // clean slate and must actually be allowed to use it. + final recovered = await BaseSharedPreferencesService.sharedCache(); + expect(loadCount, 2); + await recovered.setBool('recovered', true); + expect(recovered.getBool('recovered'), isTrue); + }); + }); } diff --git a/test/services/prefs_recovery_test.dart b/test/services/prefs_recovery_test.dart index 71433eb7..2dfbafde 100644 --- a/test/services/prefs_recovery_test.dart +++ b/test/services/prefs_recovery_test.dart @@ -190,6 +190,105 @@ void main() { throwsA(isA()), ); }); + + // #1732 was reported as "FormatException at offset 0" and nothing else. + // These fix which byte shapes can produce that, because the diagnostic + // deliberately discards the document and the offset is all a report has. + group('byte-level damage', () { + test('an all-zero document is rejected at offset 0', () async { + // The shape an interrupted write leaves behind when the file system + // extended the file's metadata but never flushed its contents. + await store.writeAsBytes(List.filled(64, 0)); + + final damage = await PrefsRecovery.describeCurrentStoreDamage(reopenSafe: true, storeFileOverride: store); + + expect(damage, isNotNull); + expect(damage!.causeType, 'FormatException'); + expect(damage.offset, 0); + expect(damage.shape?.length, 64); + expect(damage.shape?.validUtf8, isTrue); + expect(damage.shape?.allZero, isTrue); + }); + + test('a leading NUL before an intact document is rejected at offset 0', () async { + await store.writeAsBytes([0, ...utf8.encode('{"$credentialVaultKeyPref":"$_validVaultKey"}')]); + + final damage = await PrefsRecovery.describeCurrentStoreDamage(reopenSafe: true, storeFileOverride: store); + + expect(damage?.offset, 0); + // Byte-level garbage at the front, but the entries behind it are still + // verbatim, so this is the offset-0 shape a salvage can still rescue. + expect(damage?.shape?.allZero, isFalse); + expect(PrefsRecovery.salvage(await store.readAsString()).vaultKey, _validVaultKey); + }); + + test('bytes that are not UTF-8 are damage, not an unreadable file', () async { + // `File.readAsString` reports this as a FileSystemException, which is + // indistinguishable from a denied or locked file — so the preflight + // used to wave it through and the app died with no Repair button. + await store.writeAsBytes([0xFF, 0xFE, ...utf8.encode('{"theme":"dark"}')]); + + final damage = await PrefsRecovery.describeCurrentStoreDamage(reopenSafe: true, storeFileOverride: store); + + expect(damage, isNotNull); + expect(damage!.causeType, 'FormatException'); + expect(damage.shape?.validUtf8, isFalse); + await expectLater( + PrefsRecovery.assertStoreReadable(storeFileOverride: store), + throwsA(isA()), + ); + }); + + test('a UTF-8 BOM is stripped by the decoder and accepted', () async { + // Ruled out as a cause of #1732: the decoder consumes the BOM, so the + // document behind it parses and the app boots. + await store.writeAsBytes([0xEF, 0xBB, 0xBF, ...utf8.encode('{"theme":"dark"}')]); + + expect(await PrefsRecovery.describeCurrentStoreDamage(reopenSafe: true, storeFileOverride: store), isNull); + }); + + test('a whitespace-only document fails past offset 0', () async { + // Also ruled out: the parser skips the whitespace first, so the offset + // lands at the end of the document rather than at its first byte. + await store.writeAsString(' \n'); + + final damage = await PrefsRecovery.describeCurrentStoreDamage(reopenSafe: true, storeFileOverride: store); + + expect(damage, isNotNull); + expect(damage!.offset, isNot(0)); + }); + + test('a structural rejection carries no offset', () async { + await store.writeAsString('{"theme":"dark","broken":null}'); + + final damage = await PrefsRecovery.describeCurrentStoreDamage(reopenSafe: true, storeFileOverride: store); + + expect(damage?.offset, isNull); + }); + + test('reopenSafe and the byte shape reach the rendered message', () async { + // The whole point of carrying them: a report of this class should be + // diagnosable without asking the user for the file. + await store.writeAsBytes(List.filled(8, 0)); + + final damage = await PrefsRecovery.describeCurrentStoreDamage(reopenSafe: false, storeFileOverride: store); + + expect(damage.toString(), contains('reopenSafe: false')); + expect(damage.toString(), contains('8 bytes')); + expect(damage.toString(), contains('every byte zero')); + // Still never the document itself. + expect(damage.toString(), isNot(contains(_validVaultKey))); + }); + + test('an unreadable store is not classified as damage', () async { + // A denied or locked file must keep its own error and its own + // non-repairable path; offering a destructive repair for it would be + // worse than reporting it. + final missing = File('${store.parent.path}/definitely-absent.json'); + + expect(await PrefsRecovery.describeCurrentStoreDamage(reopenSafe: true, storeFileOverride: missing), isNull); + }); + }); }); group('quarantine', () { @@ -213,6 +312,18 @@ void main() { expect(await File(result.backupPath!).exists(), isFalse); }); + + test('salvages a store whose bytes are not valid UTF-8', () async { + // `readAsString` raises FileSystemException for a decode failure, so the + // old strict read aborted the whole repair and left the damaged store + // live — the app stayed dead with a "Repair failed" snackbar. + await store.writeAsBytes([0xFF, 0xFE, ...utf8.encode('{"$credentialVaultKeyPref":"$_validVaultKey"}')]); + + final result = await PrefsRecovery.quarantine(storeFileOverride: store); + + expect(await store.exists(), isFalse); + expect(result.salvaged.vaultKey, _validVaultKey); + }); }); group('backupStore', () { diff --git a/test/services/prefs_store_repair_flow_test.dart b/test/services/prefs_store_repair_flow_test.dart new file mode 100644 index 00000000..314b21ba --- /dev/null +++ b/test/services/prefs_store_repair_flow_test.dart @@ -0,0 +1,202 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider_linux/path_provider_linux.dart'; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:plezy/services/base_shared_preferences_service.dart'; +import 'package:plezy/services/prefs_recovery.dart'; +import 'package:plezy/services/sensitive_prefs.dart'; +import 'package:plezy/services/settings_service.dart'; +import 'package:shared_preferences_linux/shared_preferences_linux.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_platform_interface.dart'; + +import '../test_helpers/io_fakes.dart'; + +/// The whole #1732 loop, driven against the **real** desktop preference +/// backend rather than an in-memory fake. +/// +/// The two halves of the existing coverage never met: file-level tests called +/// `PrefsRecovery` statics with a `storeFileOverride` and never built a gate, +/// while gate-level tests threw synthetic exceptions and never touched a file. +/// Every defect in #1732 lived in the join — the preflight's callsite, the +/// classification of what the plugin threw, and the reopen after a repair. +/// +/// `shared_preferences_linux` is pure Dart and byte-identical to the Windows +/// implementation; both expose `fs`/`pathProvider` for exactly this. Pointing +/// its path provider at a temp directory runs the genuine read, parse, cache +/// and write code on any host. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory root; + late Directory support; + late File store; + + /// A 32-byte key, the only length `AesGcm.with256bits()` accepts. + final validVaultKey = base64Encode(List.generate(32, (i) => i)); + + setUp(() async { + root = await Directory.systemTemp.createTemp('plezy_prefs_repair_flow_'); + support = Directory(p.join(root.path, 'support'))..createSync(recursive: true); + store = File(p.join(support.path, prefsStoreFileName)); + + PathProviderPlatform.instance = FakePathProvider(root); + // The desktop store path is Windows/Linux only; without this the preflight + // is a no-op on a macOS host and the test would assert nothing. + PrefsRecovery.debugSetSupportedPlatformOverride(true); + + final pathProvider = _TempPathProviderLinux(support.path); + SharedPreferencesStorePlatform.instance = SharedPreferencesLinux()..pathProvider = pathProvider; + SharedPreferencesAsyncPlatform.instance = SharedPreferencesAsyncLinux()..pathProvider = pathProvider; + + BaseSharedPreferencesService.resetForTesting(); + SettingsService.resetForTesting(); + // `CredentialVault` memoises the first key it sees; the repair reseeds + // before anything can read, which is what makes the reseed safe. + }); + + tearDown(() async { + BaseSharedPreferencesService.resetForTesting(); + SettingsService.resetForTesting(); + PrefsRecovery.debugSetSupportedPlatformOverride(null); + if (await root.exists()) await root.delete(recursive: true); + }); + + test('a store the backend can read opens normally', () async { + await store.writeAsString(jsonEncode({'theme': 'dark'})); + + await expectLater(SettingsService.getInstance(), completes); + }); + + test('an offset-0 store fails the gate as a repairable corruption', () async { + // The exact shape reported in #1732: valid UTF-8, non-empty, and a first + // byte that is not a legal JSON value start. + await store.writeAsBytes([ + 0, + ...utf8.encode(jsonEncode({'theme': 'dark'})), + ]); + + final error = await SettingsService.getInstance().then((_) => null, onError: (Object e) => e); + + expect(error, isA()); + final corruption = error! as CorruptPreferenceStoreException; + expect(corruption.offset, 0); + // Caught by the preflight, before either backend memoised anything, so the + // repair can reopen in this same process. + expect(corruption.reopenSafe, isTrue); + }); + + test('repairing an offset-0 store reopens it in the same process and keeps the vault key', () async { + await store.writeAsBytes([ + 0, + ...utf8.encode(jsonEncode({credentialVaultKeyPref: validVaultKey, 'theme': 'dark'})), + ]); + + await expectLater(SettingsService.getInstance(), throwsA(isA())); + + final outcome = await BaseSharedPreferencesService.repairCorruptStore(); + + expect(outcome.vaultKeySalvaged, isTrue); + expect(outcome.requiresRestart, isFalse); + expect(outcome.backupPath, isNotNull); + expect(await File(outcome.backupPath!).exists(), isTrue); + + // The point of the whole exercise: this process, no restart. + final settings = await SettingsService.getInstance(); + expect(settings, isNotNull); + + // The salvaged key is on disk under the async key name, so every token + // stored as ciphertext in the database stays decryptable. + final reopened = jsonDecode(await store.readAsString()) as Map; + expect(reopened[credentialVaultKeyPref], validVaultKey); + // Settings were reset, which is the cost the consent dialog names. + expect(reopened.containsKey('theme'), isFalse); + }); + + test('a store whose bytes are not UTF-8 is repairable, not a dead end', () async { + // `File.readAsString` reports a decode failure as FileSystemException, so + // this used to slip past the preflight, fail the type check that decides + // repairability, and reach the user as a failure screen with no Repair + // button and no way forward. + await store.writeAsBytes([ + 0xFF, + 0xFE, + ...utf8.encode(jsonEncode({credentialVaultKeyPref: validVaultKey})), + ]); + + final error = await SettingsService.getInstance().then((_) => null, onError: (Object e) => e); + + expect(error, isA()); + expect((error! as CorruptPreferenceStoreException).shape?.validUtf8, isFalse); + + final outcome = await BaseSharedPreferencesService.repairCorruptStore(); + + expect(outcome.vaultKeySalvaged, isTrue); + await expectLater(SettingsService.getInstance(), completes); + }); + + test('an empty store is a first launch, not damage', () async { + // Both backends skip `json.decode` for an empty document, so rejecting it + // would brick a launch the plugin handles perfectly well. + await store.writeAsBytes(const []); + + await expectLater(SettingsService.getInstance(), completes); + }); + + test('a genuine read failure keeps its own type and stays non-repairable', () async { + // A store the process cannot read is not a damaged *document*. Offering a + // destructive repair for a permissions problem would reset every setting + // and risk the vault key over something a chmod fixes, so the failure has + // to keep its own type and its own non-repairable path. + await store.writeAsString(jsonEncode({'theme': 'dark'})); + if (!_run('chmod', ['000', store.path])) { + // No `chmod` on a Windows PATH, and its ACL model would need a different + // probe entirely. + markTestSkipped('cannot make a file unreadable here'); + return; + } + addTearDown(() => _run('chmod', ['600', store.path])); + + var readable = true; + try { + store.readAsBytesSync(); + } on FileSystemException { + readable = false; + } + // Running as root (some container images) defeats the mode bits entirely. + if (readable) { + markTestSkipped('the store stayed readable after chmod 000; cannot stage an I/O failure here'); + return; + } + + final error = await SettingsService.getInstance().then((_) => null, onError: (Object e) => e); + + expect(error, isNotNull); + expect(error, isNot(isA())); + }); +} + +class _TempPathProviderLinux extends PathProviderLinux { + _TempPathProviderLinux(this.supportPath); + + final String supportPath; + + @override + Future getApplicationSupportPath() async => supportPath; +} + +/// Runs a POSIX helper, reporting whether it succeeded. +/// +/// Returns false rather than throwing when the binary is missing, so a Windows +/// run reaches `markTestSkipped` instead of failing the whole suite on a +/// ProcessException before the test body can decide anything. +bool _run(String executable, List arguments) { + try { + return Process.runSync(executable, arguments).exitCode == 0; + } on ProcessException { + return false; + } +}