From 45a8a9d25da7560dbf2eb73992fd5f8c103dea46 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Mon, 23 Mar 2026 20:33:19 +0800 Subject: [PATCH] Remove legacy settings recovery path --- .../xworkmate-internal-state-architecture.md | 1 - lib/app/app_controller_desktop.dart | 8 +- lib/app/app_controller_web.dart | 2 - lib/features/settings/settings_page.dart | 18 +- lib/runtime/legacy_settings_recovery.dart | 58 --- lib/runtime/secure_config_store.dart | 6 - lib/runtime/settings_store.dart | 457 +----------------- test/runtime/secure_config_store_suite.dart | 200 +------- 8 files changed, 43 insertions(+), 707 deletions(-) delete mode 100644 lib/runtime/legacy_settings_recovery.dart diff --git a/docs/architecture/xworkmate-internal-state-architecture.md b/docs/architecture/xworkmate-internal-state-architecture.md index 75716d61..4905a8dc 100644 --- a/docs/architecture/xworkmate-internal-state-architecture.md +++ b/docs/architecture/xworkmate-internal-state-architecture.md @@ -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 diff --git a/lib/app/app_controller_desktop.dart b/lib/app/app_controller_desktop.dart index 1648915c..2a78e5d4 100644 --- a/lib/app/app_controller_desktop.dart +++ b/lib/app/app_controller_desktop.dart @@ -306,7 +306,6 @@ class AppController extends ChangeNotifier { _draftSecretValues.isNotEmpty; bool get hasPendingSettingsApply => _pendingSettingsApply; String get settingsDraftStatusMessage => _settingsDraftStatusMessage; - LegacyRecoveryReport get legacyRecoveryReport => _store.lastRecoveryReport; List get agents => _agentsController.agents; List 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; diff --git a/lib/app/app_controller_web.dart b/lib/app/app_controller_web.dart index 0420c407..ce1f8279 100644 --- a/lib/app/app_controller_web.dart +++ b/lib/app/app_controller_web.dart @@ -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; diff --git a/lib/features/settings/settings_page.dart b/lib/features/settings/settings_page.dart index a0b71d84..e6a8695d 100644 --- a/lib/features/settings/settings_page.dart +++ b/lib/features/settings/settings_page.dart @@ -400,13 +400,7 @@ class _SettingsPageState extends State { 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 { ), const SizedBox(height: 6), Text( - recoveryIssue + message.isNotEmpty ? message : hasDraft ? appText( @@ -451,14 +445,14 @@ class _SettingsPageState extends State { 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')), diff --git a/lib/runtime/legacy_settings_recovery.dart b/lib/runtime/legacy_settings_recovery.dart deleted file mode 100644 index 401e6ced..00000000 --- a/lib/runtime/legacy_settings_recovery.dart +++ /dev/null @@ -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 toJson() { - return { - 'status': status.jsonValue, - 'sourcePath': sourcePath, - 'details': details, - }; - } - - factory LegacyRecoveryReport.fromJson(Map json) { - return LegacyRecoveryReport( - status: LegacyRecoveryStatusCopy.fromJsonValue( - json['status'] as String?, - ), - sourcePath: json['sourcePath'] as String?, - details: json['details'] as String? ?? '', - ); - } -} diff --git a/lib/runtime/secure_config_store.dart b/lib/runtime/secure_config_store.dart index c1275bac..8416b251 100644 --- a/lib/runtime/secure_config_store.dart +++ b/lib/runtime/secure_config_store.dart @@ -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 initialize() async { await _secretStore.initialize(); await _settingsStore.initialize(); diff --git a/lib/runtime/settings_store.dart b/lib/runtime/settings_store.dart index 63bff0bd..c7a0a334 100644 --- a/lib/runtime/settings_store.dart +++ b/lib/runtime/settings_store.dart @@ -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 Function()? databasePathResolver, Future Function()? defaultSupportDirectoryPathResolver, SecureConfigDatabaseOpener? databaseOpener, - Future?> 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 _durableStateFileNames = { - settingsKey: 'settings-snapshot.json', - assistantThreadsKey: 'assistant-threads.json', - }; final Future Function()? _fallbackDirectoryPathResolver; final Future Function()? _databasePathResolver; final Future Function()? _defaultSupportDirectoryPathResolver; final SecureConfigDatabaseOpener? _databaseOpener; - final Future?> 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 initialize() async { if (_initialized) { return; } - try { - _prefs = await SharedPreferences.getInstance(); - } catch (_) { - _prefs = null; - } await _initializeDatabase(); _initialized = true; } Future 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> loadAssistantThreadRecords() async { await initialize(); - await _ensureLegacyRecoveryIfNeeded(); final raw = await _readStoredString(assistantThreadsKey); return _decodeAssistantThreadRecords(raw) ?? const []; @@ -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> 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 _openDatabase(String resolvedPath) async { @@ -201,318 +167,6 @@ class SettingsStore { '''); } - Future _migrateLegacyPrefs() async { - if (_database == null || _prefs == null) { - return; - } - await _migrateLegacyPrefEntry(settingsKey); - await _migrateLegacyPrefEntry(auditKey); - await _migrateLegacyPrefEntry(assistantThreadsKey); - } - - Future _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', - [key], - ); - if (existing.isEmpty) { - await _writeStoredString(key, legacyValue); - } - await _prefs!.remove(key); - } - - Future _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 _attemptLegacyRecovery({ - required String? currentSettingsRaw, - required String? currentThreadsRaw, - }) async { - final lockedSources = []; - 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 []; - 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> _legacyCandidateDirectories() async { - final databasePath = await _resolveDatabasePath(); - return [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', - [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; - 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; - return _LegacyBackupReadResult( - snapshot: _AssistantStateSnapshot( - settings: SettingsSnapshot.fromJson( - (payload['settings'] as Map?)?.cast() ?? - const {}, - ), - assistantThreads: - ((payload['assistantThreads'] as List?) ?? const []) - .whereType() - .map( - (item) => AssistantThreadRecord.fromJson( - item.cast(), - ), - ) - .toList(growable: false), - ), - ); - } - final settings = SettingsSnapshot.fromJson( - (decoded['settings'] as Map?)?.cast() ?? const {}, - ); - final threads = ((decoded['assistantThreads'] as List?) ?? const []) - .whereType() - .map( - (item) => - AssistantThreadRecord.fromJson(item.cast()), - ) - .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 _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; - 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 _resolveDatabasePath() async { final resolved = _resolvedDatabasePath?.trim() ?? ''; if (resolved.isNotEmpty) { @@ -544,7 +198,9 @@ class SettingsStore { Future _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 _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 _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 _deleteDurableStateFile(String key) async { - final file = await _durableStateFile(key); - if (file == null || !await file.exists()) { - return; - } - await file.delete(); - } - - Future _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(); - 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 && - decoded['storageFormat'] == sealedStateFormat; - } catch (_) { - return false; - } - } - - static List _base64UrlDecode(String value) { - final normalized = value.replaceAll('-', '+').replaceAll('_', '/'); - final padded = normalized + '=' * ((4 - normalized.length % 4) % 4); - return base64.decode(padded); - } - bool _looksLikeSettingsSnapshot(Map 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? threads; - final bool locked; -} - -class _LegacyStateReadResult { - const _LegacyStateReadResult({ - this.snapshot, - this.threads, - this.locked = false, - }); - - final SettingsSnapshot? snapshot; - final List? threads; - final bool locked; -} - -class _AssistantStateSnapshot { - const _AssistantStateSnapshot({ - required this.settings, - required this.assistantThreads, - }); - - final SettingsSnapshot settings; - final List assistantThreads; -} - -class _LegacyBackupReadResult { - const _LegacyBackupReadResult({this.snapshot, this.locked = false}); - - final _AssistantStateSnapshot? snapshot; - final bool locked; -} diff --git a/test/runtime/secure_config_store_suite.dart b/test/runtime/secure_config_store_suite.dart index 876a1715..463e6e52 100644 --- a/test/runtime/secure_config_store_suite.dart +++ b/test/runtime/secure_config_store_suite.dart @@ -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({}); 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({}); 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.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( - sessionKey: 'draft:migrated-1', - title: 'Migrated thread', - archived: false, - executionTarget: AssistantExecutionTarget.local, - messageViewMode: AssistantMessageViewMode.rendered, - updatedAtMs: 1700000000000, - messages: [ - 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({}); - 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.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 - ''', - [key], - ); - return result.single['value']! as String; - } finally { - database.dispose(); - } -} - class _MapSecureStorageClient implements SecureStorageClient { final Map _values = {}; @@ -1192,28 +1067,3 @@ class _MapSecureStorageClient implements SecureStorageClient { _values[key] = value; } } - -Future _sealLocalStateForTest({ - required String key, - required String plaintext, - required List keyBytes, -}) async { - const nonce = [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({ - 'storageFormat': SettingsStore.sealedStateFormat, - 'nonce': _base64UrlNoPadding(secretBox.nonce), - 'cipherText': _base64UrlNoPadding(secretBox.cipherText), - 'mac': _base64UrlNoPadding(secretBox.mac.bytes), - }); -} - -String _base64UrlNoPadding(List bytes) { - return base64Url.encode(bytes).replaceAll('=', ''); -}