`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.
565 lines
24 KiB
Dart
565 lines
24 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter/foundation.dart' show visibleForTesting;
|
|
import 'package:path/path.dart' as p;
|
|
import 'package:path_provider/path_provider.dart';
|
|
|
|
import '../models/seerr/seerr_session.dart';
|
|
import '../utils/app_logger.dart';
|
|
import '../utils/log_redaction_manager.dart';
|
|
import 'sensitive_prefs.dart';
|
|
import 'trackers/tracker_constants.dart';
|
|
import 'trackers/tracker_session.dart';
|
|
|
|
/// File-backed preference store used by the desktop `shared_preferences`
|
|
/// implementations. Windows and Linux both persist a single flat JSON object
|
|
/// at `<applicationSupport>/shared_preferences.json`, written with a
|
|
/// non-atomic `writeAsStringSync` and parsed with an unguarded `json.decode`.
|
|
/// A crash, power loss or antivirus interception mid-write therefore leaves a
|
|
/// truncated file that fails every subsequent launch identically.
|
|
const String prefsStoreFileName = 'shared_preferences.json';
|
|
|
|
/// Prefix the legacy `SharedPreferences` API writes into the same store file
|
|
/// (`shared_preferences_legacy.dart` `_prefix`). Entries only exist under this
|
|
/// 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
|
|
/// `FormatException`/`TypeError` into this so the startup gate can tell a
|
|
/// repairable store apart from an inaccessible directory, and offer the user
|
|
/// an explicit repair instead of a dead app (#1732).
|
|
///
|
|
/// The original error is deliberately **not** retained. `json.decode` throws a
|
|
/// `FormatException` whose `source` is the entire preference document, and
|
|
/// `FormatException.toString()` prints an excerpt of it around the error
|
|
/// 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, 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, this.shape})
|
|
: causeType = cause.runtimeType.toString(),
|
|
offset = cause is FormatException ? cause.offset : null;
|
|
|
|
/// Runtime type of the discarded cause, e.g. `FormatException`.
|
|
final String causeType;
|
|
|
|
/// Byte offset the parser failed at, when the cause reported one.
|
|
final int? offset;
|
|
|
|
/// Frames only — never contains document contents.
|
|
final StackTrace causeStackTrace;
|
|
|
|
/// Whether the store can be reopened inside this process after a repair.
|
|
///
|
|
/// True when [PrefsRecovery.assertStoreReadable] rejected the document
|
|
/// before either plugin backend touched it, so nothing was memoised. False
|
|
/// when the preflight passed and the plugin failed anyway: the desktop
|
|
/// backends cache the parsed document in a private map and never re-read it,
|
|
/// so reopening would hand back the bad document and write it straight back.
|
|
/// 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'};'
|
|
' ${shape ?? 'shape unavailable'}; reopenSafe: $reopenSafe)';
|
|
}
|
|
|
|
/// Raised when a credential preference exists but its stored type no longer
|
|
/// matches the declaration.
|
|
///
|
|
/// Ordinary preferences are dropped and defaulted on a type mismatch, but a
|
|
/// credential cannot be: silently discarding one would sign the user out with
|
|
/// no explanation. This surfaces instead, so the startup gate can offer the
|
|
/// same consented repair it offers for an unparseable store (#1732).
|
|
///
|
|
/// Only the key name and the cause's type are retained. Key names are not
|
|
/// secret; values are, and no value ever reaches this object.
|
|
class UnreadableSensitivePreferenceException implements Exception {
|
|
UnreadableSensitivePreferenceException(this.key, Object cause) : causeType = cause.runtimeType.toString();
|
|
|
|
final String key;
|
|
final String causeType;
|
|
|
|
@override
|
|
String toString() =>
|
|
'UnreadableSensitivePreferenceException: credential preference "$key"'
|
|
' has an unreadable stored type ($causeType)';
|
|
}
|
|
|
|
/// What a repair recovered and what it could not.
|
|
///
|
|
/// The distinction is user-facing. A salvaged vault key keeps every
|
|
/// server/profile signed in, because those tokens live as ciphertext in the
|
|
/// database rather than in preferences. Tracker and Seerr sessions are stored
|
|
/// as plaintext preference entries, so they survive only when individually
|
|
/// salvageable — the vault key says nothing about them.
|
|
class PrefsRepairOutcome {
|
|
const PrefsRepairOutcome({
|
|
required this.backupPath,
|
|
required this.vaultKeySalvaged,
|
|
required this.sessionsSalvaged,
|
|
required this.sessionsLost,
|
|
this.settingsReset = true,
|
|
this.requiresRestart = false,
|
|
});
|
|
|
|
/// Absolute path of the quarantined store. It contains credentials in
|
|
/// plaintext: never upload it, attach it to a report, or log its contents.
|
|
final String? backupPath;
|
|
|
|
/// Whether [credentialVaultKeyPref] was recovered. When false every stored
|
|
/// server and profile token becomes undecryptable and must be re-acquired.
|
|
final bool vaultKeySalvaged;
|
|
|
|
/// Tracker/Seerr session slots reseeded into the fresh store.
|
|
final int sessionsSalvaged;
|
|
|
|
/// Session slots that were present but unrecoverable.
|
|
final int sessionsLost;
|
|
|
|
/// Whether the user has to reconnect at least one tracker or Seerr instance.
|
|
bool get sessionsAffected => sessionsLost > 0;
|
|
|
|
/// Whether the repair discarded the settings store. False for the surgical
|
|
/// single-key repair, which leaves every other preference untouched.
|
|
final bool settingsReset;
|
|
|
|
/// Whether the app must restart before the repaired store can be used.
|
|
/// Set when the plugin had already memoised the bad document.
|
|
final bool requiresRestart;
|
|
}
|
|
|
|
/// Credentials lifted out of a damaged store, before it is quarantined.
|
|
class SalvagedPrefsCredentials {
|
|
const SalvagedPrefsCredentials({required this.vaultKey, required this.sessions, required this.losses});
|
|
|
|
static const SalvagedPrefsCredentials empty = SalvagedPrefsCredentials(vaultKey: null, sessions: {}, losses: 0);
|
|
|
|
/// Base64 vault key, already validated as exactly 32 raw bytes.
|
|
final String? vaultKey;
|
|
|
|
/// Preference key → encoded session payload, each validated by decoding it
|
|
/// with the owning store's codec.
|
|
final Map<String, String> sessions;
|
|
|
|
/// Credential slots that were present but could not be decoded.
|
|
final int losses;
|
|
}
|
|
|
|
/// Recovers a damaged desktop preference store without silently destroying
|
|
/// credentials.
|
|
///
|
|
/// Repair is never automatic. `SettingsService.getInstance()` surfaces
|
|
/// [CorruptPreferenceStoreException] to the startup gate, the gate offers the
|
|
/// user an explicit choice that names the real cost, and only an accepted
|
|
/// choice reaches `BaseSharedPreferencesService.repairCorruptStore`.
|
|
abstract final class PrefsRecovery {
|
|
/// 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 => _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();
|
|
return File(p.join(directory.path, prefsStoreFileName));
|
|
}
|
|
|
|
/// Rejects a damaged desktop store *before* either plugin backend reads it.
|
|
///
|
|
/// This ordering is load-bearing. `shared_preferences_windows` assigns the
|
|
/// decoded document to a private `_cachedPreferences` map and never re-reads
|
|
/// it, and the map it stores is the lazy result of `Map.cast<String, Object>`
|
|
/// — so a document containing a null value caches successfully and only
|
|
/// throws later, when `Map<String, Object>.from` walks it. Detecting that
|
|
/// after the fact would be useless: the bad document is already memoised, a
|
|
/// repair could not reopen onto a clean store, and the first write would
|
|
/// persist the memoised copy straight back over the repaired file.
|
|
///
|
|
/// Validating here means the plugin only ever sees a document it can hold.
|
|
///
|
|
/// 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 {
|
|
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 null;
|
|
} on Object {
|
|
// Locating or stat-ing the directory is a different failure entirely
|
|
// (missing/denied application support); let the plugin report it.
|
|
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 = utf8.decode(bytes);
|
|
} on FormatException catch (error, stackTrace) {
|
|
return CorruptPreferenceStoreException(error, stackTrace, reopenSafe: reopenSafe, shape: shape);
|
|
}
|
|
// 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) {
|
|
return CorruptPreferenceStoreException(error, stackTrace, reopenSafe: reopenSafe, shape: shape);
|
|
}
|
|
|
|
if (decoded is! Map) {
|
|
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.
|
|
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
|
|
/// string list. A null value is the common real-world offender.
|
|
static bool _isStorableValue(Object? value) {
|
|
if (value is bool || value is int || value is double || value is String) return true;
|
|
return value is List && value.every((element) => element is String);
|
|
}
|
|
|
|
/// Writes a fresh store containing only [salvaged], for the next process.
|
|
///
|
|
/// Used when the plugin already memoised the bad document and cannot be
|
|
/// reopened in-process. The salvage would otherwise be thrown away: the
|
|
/// quarantined file is gone, nothing reseeds the vault key, and the next
|
|
/// launch generates a replacement key that orphans every token stored as
|
|
/// ciphertext in the database.
|
|
///
|
|
/// Writes the same flat JSON object the desktop backends read, under the
|
|
/// unprefixed async key names. Staged through a sibling temporary file and
|
|
/// renamed into place so a crash mid-write cannot leave a second truncated
|
|
/// store — the exact failure this whole path exists to recover from.
|
|
///
|
|
/// Returns whether the seed landed.
|
|
static Future<bool> seedStore(SalvagedPrefsCredentials salvaged, {File? storeFileOverride}) async {
|
|
final vaultKey = salvaged.vaultKey;
|
|
if (vaultKey == null && salvaged.sessions.isEmpty) return false;
|
|
final values = <String, Object>{credentialVaultKeyPref: ?vaultKey, ...salvaged.sessions};
|
|
|
|
try {
|
|
final file = storeFileOverride ?? await storeFile();
|
|
if (!await file.parent.exists()) await file.parent.create(recursive: true);
|
|
final staged = File('${file.path}.seed');
|
|
await staged.writeAsString(jsonEncode(values), flush: true);
|
|
await staged.rename(file.path);
|
|
return true;
|
|
} catch (error, stackTrace) {
|
|
appLogger.e('Could not seed a repaired preference store', error: error, stackTrace: stackTrace);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// Lifts credentials out of the raw bytes of a store that no longer parses.
|
|
///
|
|
/// A damaged store is almost always *truncated*, not scrambled, so entries
|
|
/// near the front usually survive verbatim even when the object as a whole
|
|
/// has no closing brace. Each value is matched on the wire, unescaped
|
|
/// individually and validated with its owning codec, so a partially written
|
|
/// entry is discarded rather than reseeded as garbage.
|
|
///
|
|
/// Both key spellings are recognised. The legacy `SharedPreferences` API
|
|
/// persists into the same file under a [legacyKeyPrefix] prefix, and
|
|
/// `_loadSharedCache` runs `SharedPreferences.getInstance()` before the
|
|
/// legacy-to-async migration — so a store damaged mid-migration can hold a
|
|
/// credential only under its prefixed name. Salvage normalises those onto
|
|
/// the async key the app actually reads, and an unprefixed entry always wins
|
|
/// over a prefixed one regardless of their order in the file.
|
|
@visibleForTesting
|
|
static SalvagedPrefsCredentials salvage(String raw) {
|
|
String? vaultKey;
|
|
var vaultKeyFromLegacy = false;
|
|
final sessions = <String, String>{};
|
|
final sessionFromLegacy = <String, bool>{};
|
|
var losses = 0;
|
|
|
|
for (final match in _stringEntryPattern.allMatches(raw)) {
|
|
final rawKey = _unescape(match.group(1)!);
|
|
if (rawKey == null) continue;
|
|
final legacy = rawKey.startsWith(legacyKeyPrefix);
|
|
final key = legacy ? rawKey.substring(legacyKeyPrefix.length) : rawKey;
|
|
if (!isSensitivePrefKey(key)) continue;
|
|
final value = _unescape(match.group(2)!);
|
|
|
|
if (key == credentialVaultKeyPref) {
|
|
if (value == null || !_isVaultKey(value)) {
|
|
losses++;
|
|
} else if (vaultKey == null || (vaultKeyFromLegacy && !legacy)) {
|
|
vaultKey = value;
|
|
vaultKeyFromLegacy = legacy;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// The legacy Plex slot is an opaque token with no codec to validate
|
|
// against; a non-empty string is all we can assert.
|
|
final valid =
|
|
value != null &&
|
|
value.isNotEmpty &&
|
|
(key == legacyPlexTokenPref ? _registerLegacyPlexToken(value) : _validateAndRegisterSession(key, value));
|
|
if (!valid) {
|
|
losses++;
|
|
continue;
|
|
}
|
|
if (!sessions.containsKey(key) || ((sessionFromLegacy[key] ?? false) && !legacy)) {
|
|
sessions[key] = value;
|
|
sessionFromLegacy[key] = legacy;
|
|
}
|
|
// Defence in depth on top of the per-field registration above: catches
|
|
// the payload being echoed whole.
|
|
LogRedactionManager.registerCustomValue(value);
|
|
}
|
|
|
|
if (vaultKey != null) {
|
|
// Register before returning so no later log line, diagnostic or crash
|
|
// report can echo the key material even if a caller mishandles it.
|
|
LogRedactionManager.registerCustomValue(vaultKey);
|
|
}
|
|
|
|
return SalvagedPrefsCredentials(vaultKey: vaultKey, sessions: sessions, losses: losses);
|
|
}
|
|
|
|
/// Copies the live store aside without disturbing it.
|
|
///
|
|
/// Used by the surgical single-key repair, where the store is still valid
|
|
/// and stays in place — only the copy records the pre-repair state.
|
|
static Future<String?> backupStore({File? storeFileOverride}) async {
|
|
final file = storeFileOverride ?? await storeFile();
|
|
if (!await file.exists()) return null;
|
|
final backup = File(p.join(file.parent.path, 'shared_preferences.backup-${_stamp()}.json'));
|
|
try {
|
|
await file.copy(backup.path);
|
|
} on FileSystemException catch (error, stackTrace) {
|
|
appLogger.w('Could not back up the preference store before repair', error: error, stackTrace: stackTrace);
|
|
return null;
|
|
}
|
|
return backup.path;
|
|
}
|
|
|
|
static String _stamp() => DateTime.now().toUtc().toIso8601String().replaceAll(RegExp(r'[:.]'), '-');
|
|
|
|
/// Quarantines the damaged store and returns what was salvaged.
|
|
///
|
|
/// The caller opens a fresh store afterwards and reseeds it; see
|
|
/// `BaseSharedPreferencesService.repairCorruptStore`.
|
|
///
|
|
/// The damaged file is *moved*, never deleted: it is the only remaining copy
|
|
/// of any credential that could not be salvaged.
|
|
static Future<({SalvagedPrefsCredentials salvaged, String? backupPath})> quarantine({File? storeFileOverride}) async {
|
|
final file = storeFileOverride ?? await storeFile();
|
|
if (!await file.exists()) {
|
|
return (salvaged: SalvagedPrefsCredentials.empty, backupPath: null);
|
|
}
|
|
|
|
// 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);
|
|
|
|
final stamp = _stamp();
|
|
final backup = File(p.join(file.parent.path, 'shared_preferences.corrupt-$stamp.json'));
|
|
try {
|
|
await file.rename(backup.path);
|
|
} on FileSystemException catch (error, stackTrace) {
|
|
// Cross-device or locked; copy-then-delete keeps the bytes rather than
|
|
// failing the repair outright.
|
|
appLogger.w(
|
|
'Preference store quarantine could not rename; copying instead',
|
|
error: error,
|
|
stackTrace: stackTrace,
|
|
);
|
|
await file.copy(backup.path);
|
|
await file.delete();
|
|
}
|
|
|
|
appLogger.w(
|
|
'Quarantined a corrupt preference store'
|
|
' (vault key salvaged: ${salvaged.vaultKey != null},'
|
|
' sessions salvaged: ${salvaged.sessions.length}, lost: ${salvaged.losses})',
|
|
);
|
|
return (salvaged: salvaged, backupPath: backup.path);
|
|
}
|
|
|
|
/// Deletes a quarantined store so the user is not left holding a
|
|
/// credential-bearing file indefinitely.
|
|
static Future<void> deleteBackup(String path) async {
|
|
final file = File(path);
|
|
if (await file.exists()) await file.delete();
|
|
}
|
|
|
|
// A `"key": "value"` pair with JSON-escaped halves. Deliberately tolerant of
|
|
// the surrounding object being unterminated.
|
|
static final RegExp _stringEntryPattern = RegExp(r'"((?:[^"\\]|\\.)*)"\s*:\s*"((?:[^"\\]|\\.)*)"');
|
|
|
|
/// The vault generates exactly 32 random bytes; anything else would make
|
|
/// `AesGcm.with256bits()` throw on first use, which is worse than no key.
|
|
static bool _isVaultKey(String value) {
|
|
if (value.isEmpty) return false;
|
|
try {
|
|
return base64Decode(value).length == 32;
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// The legacy Plex slot is an opaque bearer token. Registering it as a token
|
|
/// also covers its URL-encoded form.
|
|
static bool _registerLegacyPlexToken(String value) {
|
|
LogRedactionManager.registerToken(value);
|
|
return true;
|
|
}
|
|
|
|
/// Validates a session payload with its owning codec and registers every
|
|
/// secret it carries individually.
|
|
///
|
|
/// Registering only the encoded payload would redact it just when it appears
|
|
/// verbatim; a bare access token, refresh token or session cookie quoted on
|
|
/// its own would still leak. The decode step already hands us the fields, so
|
|
/// harvest them while they are in scope.
|
|
static bool _validateAndRegisterSession(String key, String encoded) {
|
|
try {
|
|
if (isSeerrSessionPrefKey(key)) {
|
|
final session = SeerrSession.decode(encoded);
|
|
LogRedactionManager.registerToken(session.cookie);
|
|
// `secret` is CredentialVault ciphertext at rest; the plaintext is not
|
|
// available here, so register the stored form.
|
|
LogRedactionManager.registerCustomValue(session.secret);
|
|
LogRedactionManager.registerServerUrl(session.baseUrl);
|
|
return true;
|
|
}
|
|
final base = profileScopedCredentialBaseKey(key);
|
|
final service = TrackerService.values.where((s) => base == '${s.name}_session').firstOrNull;
|
|
if (service == null) return false;
|
|
final session = TrackerSession.decode(encoded, service: service);
|
|
LogRedactionManager.registerToken(session.accessToken);
|
|
LogRedactionManager.registerToken(session.refreshToken);
|
|
return true;
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
static String? _unescape(String rawInner) {
|
|
try {
|
|
return jsonDecode('"$rawInner"') as String;
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
}
|