fix(prefs): recover a preference store whose bytes are not valid UTF-8

`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.
This commit is contained in:
edde746
2026-08-01 06:59:20 +02:00
parent 3509f4b989
commit 3ae7aa554b
7 changed files with 517 additions and 48 deletions
@@ -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<SharedPreferencesWithCache> 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<Object>((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;
+111 -34
View File
@@ -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<int> 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<int> 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<String, Object>.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<File> 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<void> 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<CorruptPreferenceStoreException?> 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<int> 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<int> 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);