Remove legacy settings recovery path

This commit is contained in:
Haitao Pan 2026-03-23 20:33:19 +08:00
parent 33d08b5a5f
commit 45a8a9d25d
8 changed files with 43 additions and 707 deletions

View File

@ -748,7 +748,6 @@ Persistence:
- lib/runtime/secure_config_store.dart
- lib/runtime/settings_store.dart
- lib/runtime/secret_store.dart
- lib/runtime/legacy_settings_recovery.dart
Supporting architecture docs:
- docs/architecture/assistant-thread-information-architecture.md

View File

@ -306,7 +306,6 @@ class AppController extends ChangeNotifier {
_draftSecretValues.isNotEmpty;
bool get hasPendingSettingsApply => _pendingSettingsApply;
String get settingsDraftStatusMessage => _settingsDraftStatusMessage;
LegacyRecoveryReport get legacyRecoveryReport => _store.lastRecoveryReport;
List<GatewayAgentSummary> get agents => _agentsController.agents;
List<GatewaySessionSummary> get sessions => isSingleAgentMode
? _assistantSessionSummaries()
@ -2704,12 +2703,7 @@ class AppController extends ChangeNotifier {
_settingsDraft = settings;
_lastAppliedSettings = settings;
_settingsDraftInitialized = true;
_settingsDraftStatusMessage = legacyRecoveryReport.hasIssue
? appText(
'检测到旧版本配置,但当前版本无法解锁旧加密状态。',
'Detected legacy settings, but this build could not unlock the old encrypted state.',
)
: '';
_settingsDraftStatusMessage = '';
} catch (error) {
if (_disposed) {
return;

View File

@ -4,7 +4,6 @@ import 'package:flutter/material.dart';
import '../i18n/app_language.dart';
import '../models/app_models.dart';
import '../runtime/legacy_settings_recovery.dart';
import '../runtime/runtime_models.dart';
import '../web/web_ai_gateway_client.dart';
import '../web/web_relay_gateway_client.dart';
@ -86,7 +85,6 @@ class AppController extends ChangeNotifier {
_draftSecretValues.isNotEmpty;
bool get hasPendingSettingsApply => _pendingSettingsApply;
String get settingsDraftStatusMessage => _settingsDraftStatusMessage;
LegacyRecoveryReport get legacyRecoveryReport => const LegacyRecoveryReport();
AppLanguage get appLanguage => _settings.appLanguage;
GatewayConnectionSnapshot get connection => _relayClient.snapshot;
bool get relayBusy => _relayBusy;

View File

@ -400,13 +400,7 @@ class _SettingsPageState extends State<SettingsPage> {
final theme = Theme.of(context);
final hasDraft = controller.hasSettingsDraftChanges;
final hasPendingApply = controller.hasPendingSettingsApply;
final recoveryIssue = controller.legacyRecoveryReport.hasIssue;
final message = recoveryIssue
? appText(
'检测到旧版本配置,但当前版本无法解锁旧加密状态。',
'Detected legacy settings, but this build could not unlock the old encrypted state.',
)
: controller.settingsDraftStatusMessage;
final message = controller.settingsDraftStatusMessage;
return SurfaceCard(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
@ -421,7 +415,7 @@ class _SettingsPageState extends State<SettingsPage> {
),
const SizedBox(height: 6),
Text(
recoveryIssue
message.isNotEmpty
? message
: hasDraft
? appText(
@ -451,14 +445,14 @@ class _SettingsPageState extends State<SettingsPage> {
children: [
OutlinedButton(
key: const ValueKey('settings-global-save-button'),
onPressed: (!hasDraft && !recoveryIssue)
? null
: () => _handleTopLevelSave(controller),
onPressed: hasDraft
? () => _handleTopLevelSave(controller)
: null,
child: Text(appText('保存', 'Save')),
),
FilledButton.tonal(
key: const ValueKey('settings-global-apply-button'),
onPressed: (!hasDraft && !hasPendingApply && !recoveryIssue)
onPressed: (!hasDraft && !hasPendingApply)
? null
: () => _handleTopLevelApply(controller),
child: Text(appText('应用', 'Apply')),

View File

@ -1,58 +0,0 @@
enum LegacyRecoveryStatus {
none,
migrated,
lockedLegacyState,
failed,
}
extension LegacyRecoveryStatusCopy on LegacyRecoveryStatus {
static LegacyRecoveryStatus fromJsonValue(String? value) {
return switch (value?.trim()) {
'migrated' => LegacyRecoveryStatus.migrated,
'locked_legacy_state' => LegacyRecoveryStatus.lockedLegacyState,
'failed' => LegacyRecoveryStatus.failed,
_ => LegacyRecoveryStatus.none,
};
}
String get jsonValue => switch (this) {
LegacyRecoveryStatus.none => 'none',
LegacyRecoveryStatus.migrated => 'migrated',
LegacyRecoveryStatus.lockedLegacyState => 'locked_legacy_state',
LegacyRecoveryStatus.failed => 'failed',
};
}
class LegacyRecoveryReport {
const LegacyRecoveryReport({
this.status = LegacyRecoveryStatus.none,
this.sourcePath,
this.details = '',
});
final LegacyRecoveryStatus status;
final String? sourcePath;
final String details;
bool get hasIssue =>
status == LegacyRecoveryStatus.lockedLegacyState ||
status == LegacyRecoveryStatus.failed;
Map<String, dynamic> toJson() {
return <String, dynamic>{
'status': status.jsonValue,
'sourcePath': sourcePath,
'details': details,
};
}
factory LegacyRecoveryReport.fromJson(Map<String, dynamic> json) {
return LegacyRecoveryReport(
status: LegacyRecoveryStatusCopy.fromJsonValue(
json['status'] as String?,
),
sourcePath: json['sourcePath'] as String?,
details: json['details'] as String? ?? '',
);
}
}

View File

@ -1,10 +1,8 @@
import 'dart:io';
export 'legacy_settings_recovery.dart';
export 'secret_store.dart';
export 'settings_store.dart';
import 'legacy_settings_recovery.dart';
import 'runtime_models.dart';
import 'secret_store.dart';
import 'settings_store.dart';
@ -35,16 +33,12 @@ class SecureConfigStore {
defaultSupportDirectoryPathResolver:
resolvedDefaultSupportDirectoryPathResolver,
databaseOpener: databaseOpener,
legacyLocalStateKeyLoader: _secretStore.loadLegacyLocalStateKeyBytes,
);
}
late final SecretStore _secretStore;
late final SettingsStore _settingsStore;
LegacyRecoveryReport get lastRecoveryReport =>
_settingsStore.lastRecoveryReport;
Future<void> initialize() async {
await _secretStore.initialize();
await _settingsStore.initialize();

View File

@ -2,12 +2,9 @@ import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:cryptography/cryptography.dart';
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:sqlite3/sqlite3.dart' as sqlite;
import 'legacy_settings_recovery.dart';
import 'runtime_models.dart';
typedef SecureConfigDatabaseOpener =
@ -19,58 +16,36 @@ class SettingsStore {
Future<String?> Function()? databasePathResolver,
Future<String?> Function()? defaultSupportDirectoryPathResolver,
SecureConfigDatabaseOpener? databaseOpener,
Future<List<int>?> Function()? legacyLocalStateKeyLoader,
}) : _fallbackDirectoryPathResolver = fallbackDirectoryPathResolver,
_databasePathResolver = databasePathResolver,
_defaultSupportDirectoryPathResolver =
defaultSupportDirectoryPathResolver,
_databaseOpener = databaseOpener,
_legacyLocalStateKeyLoader = legacyLocalStateKeyLoader;
_databaseOpener = databaseOpener;
static const String settingsKey = 'xworkmate.settings.snapshot';
static const String auditKey = 'xworkmate.secrets.audit';
static const String assistantThreadsKey = 'xworkmate.assistant.threads';
static const String databaseFileName = 'config-store.sqlite3';
static const String databaseTableName = 'config_entries';
static const String stateBackupFileName = 'assistant-state-backup.json';
static const String sealedStateFormat = 'xworkmate.sealed.local-state.v1';
static const Map<String, String> _durableStateFileNames = <String, String>{
settingsKey: 'settings-snapshot.json',
assistantThreadsKey: 'assistant-threads.json',
};
final Future<String?> Function()? _fallbackDirectoryPathResolver;
final Future<String?> Function()? _databasePathResolver;
final Future<String?> Function()? _defaultSupportDirectoryPathResolver;
final SecureConfigDatabaseOpener? _databaseOpener;
final Future<List<int>?> Function()? _legacyLocalStateKeyLoader;
final Cipher _legacyCipher = AesGcm.with256bits();
SharedPreferences? _prefs;
sqlite.Database? _database;
String? _resolvedDatabasePath;
bool _initialized = false;
bool _recoveryAttempted = false;
LegacyRecoveryReport _lastRecoveryReport = const LegacyRecoveryReport();
LegacyRecoveryReport get lastRecoveryReport => _lastRecoveryReport;
Future<void> initialize() async {
if (_initialized) {
return;
}
try {
_prefs = await SharedPreferences.getInstance();
} catch (_) {
_prefs = null;
}
await _initializeDatabase();
_initialized = true;
}
Future<SettingsSnapshot> loadSettingsSnapshot() async {
await initialize();
await _ensureLegacyRecoveryIfNeeded();
final raw = await _readStoredString(settingsKey);
return _decodeSettingsSnapshot(raw) ?? SettingsSnapshot.defaults();
}
@ -79,12 +54,10 @@ class SettingsStore {
await initialize();
final encoded = snapshot.toJsonString();
await _writeStoredString(settingsKey, encoded);
_lastRecoveryReport = const LegacyRecoveryReport();
}
Future<List<AssistantThreadRecord>> loadAssistantThreadRecords() async {
await initialize();
await _ensureLegacyRecoveryIfNeeded();
final raw = await _readStoredString(assistantThreadsKey);
return _decodeAssistantThreadRecords(raw) ??
const <AssistantThreadRecord>[];
@ -104,11 +77,6 @@ class SettingsStore {
await initialize();
await _deleteStoredString(settingsKey);
await _deleteStoredString(assistantThreadsKey);
await _deleteDurableStateFile(settingsKey);
await _deleteDurableStateFile(assistantThreadsKey);
await _deleteLegacyBackupFile();
_lastRecoveryReport = const LegacyRecoveryReport();
_recoveryAttempted = true;
}
Future<List<SecretAuditEntry>> loadAuditTrail() async {
@ -153,7 +121,6 @@ class SettingsStore {
// Ignore close errors during teardown.
}
}
_prefs = null;
_initialized = false;
_resolvedDatabasePath = null;
}
@ -168,7 +135,6 @@ class SettingsStore {
'Durable settings storage unavailable: failed to open $resolvedPath. Cause: $error',
);
}
await _migrateLegacyPrefs();
}
Future<sqlite.Database> _openDatabase(String resolvedPath) async {
@ -201,318 +167,6 @@ class SettingsStore {
''');
}
Future<void> _migrateLegacyPrefs() async {
if (_database == null || _prefs == null) {
return;
}
await _migrateLegacyPrefEntry(settingsKey);
await _migrateLegacyPrefEntry(auditKey);
await _migrateLegacyPrefEntry(assistantThreadsKey);
}
Future<void> _migrateLegacyPrefEntry(String key) async {
if (_database == null || _prefs == null) {
return;
}
final legacyValue = _prefs!.getString(key);
if (legacyValue == null || legacyValue.trim().isEmpty) {
return;
}
final existing = _database!.select(
'SELECT value FROM $databaseTableName WHERE storage_key = ? LIMIT 1',
<Object?>[key],
);
if (existing.isEmpty) {
await _writeStoredString(key, legacyValue);
}
await _prefs!.remove(key);
}
Future<void> _ensureLegacyRecoveryIfNeeded() async {
if (_recoveryAttempted) {
return;
}
_recoveryAttempted = true;
final currentSettingsRaw = await _readStoredString(settingsKey);
final currentThreadsRaw = await _readStoredString(assistantThreadsKey);
final hasReadableCurrentState =
_decodeSettingsSnapshot(currentSettingsRaw) != null ||
_decodeAssistantThreadRecords(currentThreadsRaw) != null;
if (hasReadableCurrentState) {
_lastRecoveryReport = const LegacyRecoveryReport();
return;
}
final recovery = await _attemptLegacyRecovery(
currentSettingsRaw: currentSettingsRaw,
currentThreadsRaw: currentThreadsRaw,
);
_lastRecoveryReport = recovery;
}
Future<LegacyRecoveryReport> _attemptLegacyRecovery({
required String? currentSettingsRaw,
required String? currentThreadsRaw,
}) async {
final lockedSources = <String>[];
final candidates = await _legacyCandidateDirectories();
for (final directory in candidates) {
final source = await _readLegacySource(directory);
if (source.locked) {
lockedSources.add(source.sourcePath);
}
if (source.settings != null || source.threads != null) {
final recoveredSettings =
source.settings ?? SettingsSnapshot.defaults();
final recoveredThreads =
source.threads ?? const <AssistantThreadRecord>[];
await _writeStoredString(settingsKey, recoveredSettings.toJsonString());
await _writeStoredString(
assistantThreadsKey,
jsonEncode(
recoveredThreads
.map((item) => item.toJson())
.toList(growable: false),
),
);
return LegacyRecoveryReport(
status: LegacyRecoveryStatus.migrated,
sourcePath: source.sourcePath,
details:
'Recovered legacy settings into the new plain settings store.',
);
}
}
final currentLocked =
_isSealedLocalState(currentSettingsRaw) ||
_isSealedLocalState(currentThreadsRaw);
if (currentLocked || lockedSources.isNotEmpty) {
return LegacyRecoveryReport(
status: LegacyRecoveryStatus.lockedLegacyState,
sourcePath: lockedSources.isNotEmpty ? lockedSources.first : null,
details:
'Detected legacy encrypted state but could not restore the local-state key.',
);
}
return const LegacyRecoveryReport();
}
Future<List<String>> _legacyCandidateDirectories() async {
final databasePath = await _resolveDatabasePath();
return <String>[File(databasePath).parent.path];
}
Future<_LegacySourceResult> _readLegacySource(String directoryPath) async {
final settingsFromDatabase = await _readLegacyDatabaseEntry(
directoryPath,
settingsKey,
);
final threadsFromDatabase = await _readLegacyDatabaseEntry(
directoryPath,
assistantThreadsKey,
);
final settingsFromFile = await _readLegacyDurableState(
directoryPath,
settingsKey,
);
final threadsFromFile = await _readLegacyDurableState(
directoryPath,
assistantThreadsKey,
);
final backup = await _readLegacyBackup(directoryPath);
final settings =
settingsFromDatabase.snapshot ??
settingsFromFile.snapshot ??
backup.snapshot?.settings;
final threads =
threadsFromDatabase.threads ??
threadsFromFile.threads ??
backup.snapshot?.assistantThreads;
final locked =
settingsFromDatabase.locked ||
threadsFromDatabase.locked ||
settingsFromFile.locked ||
threadsFromFile.locked ||
backup.locked;
return _LegacySourceResult(
sourcePath: directoryPath,
settings: settings,
threads: threads,
locked: locked,
);
}
Future<_LegacyStateReadResult> _readLegacyDatabaseEntry(
String directoryPath,
String key,
) async {
final databaseFile = File('$directoryPath/$databaseFileName');
if (!await databaseFile.exists()) {
return const _LegacyStateReadResult();
}
try {
final database =
(_database != null &&
await _resolveDatabasePath() == databaseFile.path)
? _database
: sqlite.sqlite3.open(databaseFile.path);
final result = database!.select(
'SELECT value FROM $databaseTableName WHERE storage_key = ? LIMIT 1',
<Object?>[key],
);
if (!identical(database, _database)) {
database.dispose();
}
if (result.isEmpty) {
return const _LegacyStateReadResult();
}
final raw = result.first['value'] as String?;
return _decodeLegacyValue(raw, key);
} catch (_) {
return const _LegacyStateReadResult();
}
}
Future<_LegacyStateReadResult> _readLegacyDurableState(
String directoryPath,
String key,
) async {
final fileName = _durableStateFileNames[key];
if (fileName == null) {
return const _LegacyStateReadResult();
}
final file = File('$directoryPath/$fileName');
if (!await file.exists()) {
return const _LegacyStateReadResult();
}
try {
final raw = await file.readAsString();
return _decodeLegacyValue(raw, key);
} catch (_) {
return const _LegacyStateReadResult();
}
}
Future<_LegacyBackupReadResult> _readLegacyBackup(
String directoryPath,
) async {
final file = File('$directoryPath/$stateBackupFileName');
if (!await file.exists()) {
return const _LegacyBackupReadResult();
}
try {
final decoded =
jsonDecode(await file.readAsString()) as Map<String, dynamic>;
final sealedState = decoded['sealedState'];
if (sealedState is String && sealedState.trim().isNotEmpty) {
final plaintext = await _decryptLegacyValue(
'_assistant_state_backup',
sealedState,
);
if (plaintext == null) {
return const _LegacyBackupReadResult(locked: true);
}
final payload = jsonDecode(plaintext) as Map<String, dynamic>;
return _LegacyBackupReadResult(
snapshot: _AssistantStateSnapshot(
settings: SettingsSnapshot.fromJson(
(payload['settings'] as Map?)?.cast<String, dynamic>() ??
const {},
),
assistantThreads:
((payload['assistantThreads'] as List?) ?? const [])
.whereType<Map>()
.map(
(item) => AssistantThreadRecord.fromJson(
item.cast<String, dynamic>(),
),
)
.toList(growable: false),
),
);
}
final settings = SettingsSnapshot.fromJson(
(decoded['settings'] as Map?)?.cast<String, dynamic>() ?? const {},
);
final threads = ((decoded['assistantThreads'] as List?) ?? const [])
.whereType<Map>()
.map(
(item) =>
AssistantThreadRecord.fromJson(item.cast<String, dynamic>()),
)
.toList(growable: false);
return _LegacyBackupReadResult(
snapshot: _AssistantStateSnapshot(
settings: settings,
assistantThreads: threads,
),
);
} catch (_) {
return const _LegacyBackupReadResult();
}
}
Future<_LegacyStateReadResult> _decodeLegacyValue(
String? raw,
String key,
) async {
final trimmed = raw?.trim() ?? '';
if (trimmed.isEmpty) {
return const _LegacyStateReadResult();
}
final plainSettings = key == settingsKey
? _decodeSettingsSnapshot(trimmed)
: null;
final plainThreads = key == assistantThreadsKey
? _decodeAssistantThreadRecords(trimmed)
: null;
if (plainSettings != null || plainThreads != null) {
return _LegacyStateReadResult(
snapshot: plainSettings,
threads: plainThreads,
);
}
if (!_isSealedLocalState(trimmed)) {
return const _LegacyStateReadResult();
}
final decrypted = await _decryptLegacyValue(key, trimmed);
if (decrypted == null) {
return const _LegacyStateReadResult(locked: true);
}
return _LegacyStateReadResult(
snapshot: key == settingsKey ? _decodeSettingsSnapshot(decrypted) : null,
threads: key == assistantThreadsKey
? _decodeAssistantThreadRecords(decrypted)
: null,
);
}
Future<String?> _decryptLegacyValue(String key, String persisted) async {
final keyBytes = await _legacyLocalStateKeyLoader?.call();
if (keyBytes == null || keyBytes.isEmpty) {
return null;
}
try {
final envelope = jsonDecode(persisted) as Map<String, dynamic>;
final secretBox = SecretBox(
_base64UrlDecode(envelope['cipherText'] as String? ?? ''),
nonce: _base64UrlDecode(envelope['nonce'] as String? ?? ''),
mac: Mac(_base64UrlDecode(envelope['mac'] as String? ?? '')),
);
final clearText = await _legacyCipher.decrypt(
secretBox,
secretKey: SecretKey(keyBytes),
aad: utf8.encode(key),
);
return utf8.decode(clearText);
} catch (_) {
return null;
}
}
Future<String> _resolveDatabasePath() async {
final resolved = _resolvedDatabasePath?.trim() ?? '';
if (resolved.isNotEmpty) {
@ -544,7 +198,9 @@ class SettingsStore {
Future<String?> _readStoredString(String key) async {
if (_database == null) {
throw StateError('Durable settings storage unavailable: database not initialized.');
throw StateError(
'Durable settings storage unavailable: database not initialized.',
);
}
try {
final result = _database!.select(
@ -569,7 +225,9 @@ class SettingsStore {
return;
}
if (_database == null) {
throw StateError('Durable settings storage unavailable: database not initialized.');
throw StateError(
'Durable settings storage unavailable: database not initialized.',
);
}
try {
_database!.execute(
@ -591,7 +249,9 @@ class SettingsStore {
Future<void> _deleteStoredString(String key) async {
if (_database == null) {
throw StateError('Durable settings storage unavailable: database not initialized.');
throw StateError(
'Durable settings storage unavailable: database not initialized.',
);
}
try {
_database!.execute(
@ -603,37 +263,6 @@ class SettingsStore {
'Durable settings storage unavailable: failed to delete $key from $_resolvedDatabasePath.',
);
}
try {
await _prefs?.remove(key);
} catch (_) {
// Ignore.
}
}
Future<File?> _durableStateFile(String key) async {
final fileName = _durableStateFileNames[key];
if (fileName == null) {
return null;
}
final databasePath = await _resolveDatabasePath();
final directory = File(databasePath).parent;
return File('${directory.path}/$fileName');
}
Future<void> _deleteDurableStateFile(String key) async {
final file = await _durableStateFile(key);
if (file == null || !await file.exists()) {
return;
}
await file.delete();
}
Future<void> _deleteLegacyBackupFile() async {
final databasePath = await _resolveDatabasePath();
final file = File('${File(databasePath).parent.path}/$stateBackupFileName');
if (await file.exists()) {
await file.delete();
}
}
SettingsSnapshot? _decodeSettingsSnapshot(String? raw) {
@ -647,8 +276,7 @@ class SettingsStore {
return null;
}
final decoded = decodedValue.cast<String, dynamic>();
if (decoded['storageFormat'] == sealedStateFormat ||
!_looksLikeSettingsSnapshot(decoded)) {
if (!_looksLikeSettingsSnapshot(decoded)) {
return null;
}
return SettingsSnapshot.fromJson(decoded);
@ -676,26 +304,6 @@ class SettingsStore {
}
}
bool _isSealedLocalState(String? value) {
final trimmed = value?.trim() ?? '';
if (trimmed.isEmpty) {
return false;
}
try {
final decoded = jsonDecode(trimmed);
return decoded is Map<String, dynamic> &&
decoded['storageFormat'] == sealedStateFormat;
} catch (_) {
return false;
}
}
static List<int> _base64UrlDecode(String value) {
final normalized = value.replaceAll('-', '+').replaceAll('_', '/');
final padded = normalized + '=' * ((4 - normalized.length % 4) % 4);
return base64.decode(padded);
}
bool _looksLikeSettingsSnapshot(Map<String, dynamic> json) {
return json.containsKey('appLanguage') ||
json.containsKey('gateway') ||
@ -718,46 +326,3 @@ class SettingsStore {
}
}
}
class _LegacySourceResult {
const _LegacySourceResult({
required this.sourcePath,
this.settings,
this.threads,
this.locked = false,
});
final String sourcePath;
final SettingsSnapshot? settings;
final List<AssistantThreadRecord>? threads;
final bool locked;
}
class _LegacyStateReadResult {
const _LegacyStateReadResult({
this.snapshot,
this.threads,
this.locked = false,
});
final SettingsSnapshot? snapshot;
final List<AssistantThreadRecord>? threads;
final bool locked;
}
class _AssistantStateSnapshot {
const _AssistantStateSnapshot({
required this.settings,
required this.assistantThreads,
});
final SettingsSnapshot settings;
final List<AssistantThreadRecord> assistantThreads;
}
class _LegacyBackupReadResult {
const _LegacyBackupReadResult({this.snapshot, this.locked = false});
final _AssistantStateSnapshot? snapshot;
final bool locked;
}

View File

@ -4,10 +4,8 @@ library;
import 'dart:convert';
import 'dart:io';
import 'package:cryptography/cryptography.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:sqlite3/sqlite3.dart' as sqlite;
import 'package:xworkmate/models/app_models.dart';
import 'package:xworkmate/runtime/runtime_models.dart';
import 'package:xworkmate/runtime/secure_config_store.dart';
@ -416,7 +414,7 @@ void main() {
);
test(
'SecureConfigStore fails fast and keeps legacy files untouched when sqlite is unavailable',
'SecureConfigStore fails fast and keeps stray local-state files untouched when sqlite is unavailable',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final tempDirectory = await Directory.systemTemp.createTemp(
@ -455,7 +453,7 @@ void main() {
);
test(
'SecureConfigStore migrates plaintext local state into the new settings store and clears legacy prefs',
'SecureConfigStore ignores legacy shared-preferences assistant state and only reads sqlite',
() async {
final legacySnapshot = SettingsSnapshot.defaults().copyWith(
accountUsername: 'legacy-user',
@ -507,35 +505,33 @@ void main() {
final loadedSnapshot = await store.loadSettingsSnapshot();
final loadedThreads = await store.loadAssistantThreadRecords();
expect(loadedSnapshot.accountUsername, 'legacy-user');
expect(loadedSnapshot.assistantLastSessionKey, 'draft:legacy-1');
expect(loadedThreads.single.messages.single.text, 'legacy message');
expect(
loadedSnapshot.accountUsername,
SettingsSnapshot.defaults().accountUsername,
);
expect(loadedSnapshot.assistantLastSessionKey, isEmpty);
expect(loadedThreads, isEmpty);
final prefs = await SharedPreferences.getInstance();
expect(prefs.getString('xworkmate.settings.snapshot'), isNull);
expect(prefs.getString('xworkmate.assistant.threads'), isNull);
final settingsValue = _readDatabaseValue(
databasePath,
SettingsStore.settingsKey,
expect(
prefs.getString('xworkmate.settings.snapshot'),
legacySnapshot.toJsonString(),
);
final threadsValue = _readDatabaseValue(
databasePath,
SettingsStore.assistantThreadsKey,
expect(
prefs.getString('xworkmate.assistant.threads'),
jsonEncode(
legacyRecords.map((item) => item.toJson()).toList(growable: false),
),
);
expect(settingsValue, contains('legacy-user'));
expect(threadsValue, contains('legacy message'));
expect(settingsValue, isNot(contains(SettingsStore.sealedStateFormat)));
expect(threadsValue, isNot(contains(SettingsStore.sealedStateFormat)));
},
);
test(
'SecureConfigStore recovers sealed legacy local state when the local-state key is available',
'SecureConfigStore ignores stray local-state files when sqlite has no assistant state',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final tempDirectory = await Directory.systemTemp.createTemp(
'xworkmate-config-store-sealed-recovery-',
'xworkmate-config-store-ignore-stray-files-',
);
addTearDown(() async {
if (await tempDirectory.exists()) {
@ -543,128 +539,25 @@ void main() {
}
});
final databasePath = '${tempDirectory.path}/settings.sqlite3';
final secureStorage = _MapSecureStorageClient();
final localStateKey = List<int>.generate(32, (index) => index + 1);
final encodedKey = _base64UrlNoPadding(localStateKey);
final keyFallbackFile = File('${tempDirectory.path}/local-state-key.txt');
await keyFallbackFile.writeAsString(encodedKey, flush: true);
final snapshot = SettingsSnapshot.defaults().copyWith(
accountUsername: 'migrated-user',
assistantLastSessionKey: 'draft:migrated-1',
);
const records = <AssistantThreadRecord>[
AssistantThreadRecord(
sessionKey: 'draft:migrated-1',
title: 'Migrated thread',
archived: false,
executionTarget: AssistantExecutionTarget.local,
messageViewMode: AssistantMessageViewMode.rendered,
updatedAtMs: 1700000000000,
messages: <GatewayChatMessage>[
GatewayChatMessage(
id: 'assistant-1',
role: 'assistant',
text: 'migrated message',
timestampMs: 1700000001000,
toolCallId: null,
toolName: null,
stopReason: null,
pending: false,
error: false,
),
],
),
];
await File('${tempDirectory.path}/settings-snapshot.json').writeAsString(
await _sealLocalStateForTest(
key: SettingsStore.settingsKey,
plaintext: snapshot.toJsonString(),
keyBytes: localStateKey,
),
flush: true,
);
await File('${tempDirectory.path}/assistant-threads.json').writeAsString(
await _sealLocalStateForTest(
key: SettingsStore.assistantThreadsKey,
plaintext: jsonEncode(
records.map((item) => item.toJson()).toList(growable: false),
),
keyBytes: localStateKey,
),
flush: true,
);
await File(
'${tempDirectory.path}/settings-snapshot.json',
).writeAsString('{"accountUsername":"locked-user"}', flush: true);
await File(
'${tempDirectory.path}/assistant-threads.json',
).writeAsString('[{"sessionKey":"ignored-thread"}]', flush: true);
final store = SecureConfigStore(
databasePathResolver: () async => databasePath,
fallbackDirectoryPathResolver: () async => tempDirectory.path,
secureStorage: secureStorage,
);
final loadedSnapshot = await store.loadSettingsSnapshot();
final loadedThreads = await store.loadAssistantThreadRecords();
expect(loadedSnapshot.accountUsername, 'migrated-user');
expect(loadedThreads.single.messages.single.text, 'migrated message');
expect(store.lastRecoveryReport.status, LegacyRecoveryStatus.migrated);
expect(
secureStorage._values[SecretStore.legacyLocalStateKey],
encodedKey,
);
expect(await keyFallbackFile.exists(), isFalse);
expect(
_readDatabaseValue(databasePath, SettingsStore.settingsKey),
contains('migrated-user'),
);
},
);
test(
'SecureConfigStore reports locked legacy state when sealed settings exist without a recoverable key',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final tempDirectory = await Directory.systemTemp.createTemp(
'xworkmate-config-store-locked-legacy-',
);
addTearDown(() async {
if (await tempDirectory.exists()) {
await tempDirectory.delete(recursive: true);
}
});
final databasePath = '${tempDirectory.path}/settings.sqlite3';
final localStateKey = List<int>.generate(32, (index) => 32 - index);
final snapshot = SettingsSnapshot.defaults().copyWith(
accountUsername: 'locked-user',
);
await File('${tempDirectory.path}/settings-snapshot.json').writeAsString(
await _sealLocalStateForTest(
key: SettingsStore.settingsKey,
plaintext: snapshot.toJsonString(),
keyBytes: localStateKey,
),
flush: true,
);
final store = SecureConfigStore(
databasePathResolver: () async => databasePath,
fallbackDirectoryPathResolver: () async => tempDirectory.path,
secureStorage: _MapSecureStorageClient(),
);
final loadedSnapshot = await store.loadSettingsSnapshot();
expect(
loadedSnapshot.accountUsername,
SettingsSnapshot.defaults().accountUsername,
);
expect(
store.lastRecoveryReport.status,
LegacyRecoveryStatus.lockedLegacyState,
);
expect(
store.lastRecoveryReport.details,
contains('could not restore the local-state key'),
);
expect(loadedThreads, isEmpty);
},
);
@ -1156,24 +1049,6 @@ void main() {
);
}
String _readDatabaseValue(String databasePath, String key) {
final database = sqlite.sqlite3.open(databasePath);
try {
final result = database.select(
'''
SELECT value
FROM ${SettingsStore.databaseTableName}
WHERE storage_key = ?
LIMIT 1
''',
<Object?>[key],
);
return result.single['value']! as String;
} finally {
database.dispose();
}
}
class _MapSecureStorageClient implements SecureStorageClient {
final Map<String, String> _values = <String, String>{};
@ -1192,28 +1067,3 @@ class _MapSecureStorageClient implements SecureStorageClient {
_values[key] = value;
}
}
Future<String> _sealLocalStateForTest({
required String key,
required String plaintext,
required List<int> keyBytes,
}) async {
const nonce = <int>[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
final cipher = AesGcm.with256bits();
final secretBox = await cipher.encrypt(
utf8.encode(plaintext),
secretKey: SecretKey(keyBytes),
nonce: nonce,
aad: utf8.encode(key),
);
return jsonEncode(<String, dynamic>{
'storageFormat': SettingsStore.sealedStateFormat,
'nonce': _base64UrlNoPadding(secretBox.nonce),
'cipherText': _base64UrlNoPadding(secretBox.cipherText),
'mac': _base64UrlNoPadding(secretBox.mac.bytes),
});
}
String _base64UrlNoPadding(List<int> bytes) {
return base64Url.encode(bytes).replaceAll('=', '');
}