diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index dc029dc..b57fb59 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -55,10 +55,12 @@ import UIKit } private func keychainQuery(service: String, account: String) -> [String: Any] { + // Pin synchronizable=false so credentials never sync to iCloud Keychain. return [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, - kSecAttrAccount as String: account + kSecAttrAccount as String: account, + kSecAttrSynchronizable as String: false ] } diff --git a/lib/src/backup_models.dart b/lib/src/backup_models.dart index a77f3a9..92e8c82 100644 --- a/lib/src/backup_models.dart +++ b/lib/src/backup_models.dart @@ -380,12 +380,18 @@ class BackupRunManifest { .whereType>() .map(BackupNotebookOutcome.fromJson) .toList(); + final createdAtRaw = json['createdAt']; + if (createdAtRaw is! String) { + throw const FormatException( + 'Backup run manifest is missing required date "createdAt".', + ); + } + final createdAt = DateTime.parse(createdAtRaw); return BackupRunManifest( id: json['id'] as String? ?? '', - createdAt: DateTime.parse(json['createdAt'] as String), + createdAt: createdAt, completedAt: - DateTime.tryParse(json['completedAt'] as String? ?? '') ?? - DateTime.parse(json['createdAt'] as String), + DateTime.tryParse(json['completedAt'] as String? ?? '') ?? createdAt, totalNotebookCount: json['totalNotebookCount'] as int? ?? json['notebookCount'] as int? ?? @@ -724,7 +730,7 @@ class RenderNotebook { final rawNodes = json['nodes'] as List? ?? const []; return RenderNotebook( name: json['name'] as String? ?? 'Untitled notebook', - createdAt: DateTime.parse(json['createdAt'] as String), + createdAt: _requiredCreatedAt(json), archivePath: json['archivePath'] as String? ?? '', sourceLayout: json['sourceLayout'] as String? ?? 'json', nodes: rawNodes @@ -734,6 +740,16 @@ class RenderNotebook { ); } + static DateTime _requiredCreatedAt(Map json) { + final value = json['createdAt']; + if (value is String) { + return DateTime.parse(value); + } + throw const FormatException( + 'Render notebook is missing required date "createdAt".', + ); + } + String toPrettyJson() => const JsonEncoder.withIndent(' ').convert(toJson()); } @@ -766,7 +782,7 @@ class RenderNode { static RenderNode fromJson(Map json) { final rawParts = json['parts'] as List? ?? const []; return RenderNode( - id: json['id'] as int, + id: _requiredId(json), parentId: json['parentId'] as int? ?? 0, title: json['title'] as String? ?? 'Untitled', isPage: json['isPage'] as bool? ?? false, @@ -777,6 +793,16 @@ class RenderNode { .toList(), ); } + + static int _requiredId(Map json) { + final value = json['id']; + if (value is int) { + return value; + } + throw const FormatException( + 'Render node is missing required integer "id".', + ); + } } class RenderPart { @@ -848,7 +874,7 @@ class RenderPart { static RenderPart fromJson(Map json) { return RenderPart( - id: json['id'] as int, + id: _requiredId(json), kindCode: json['kindCode'] as int? ?? -1, kindLabel: json['kindLabel'] as String? ?? 'Entry part', renderText: json['renderText'] as String? ?? '', @@ -866,6 +892,16 @@ class RenderPart { attachmentOriginalVersion: json['attachmentOriginalVersion'] as int?, ); } + + static int _requiredId(Map json) { + final value = json['id']; + if (value is int) { + return value; + } + throw const FormatException( + 'Render part is missing required integer "id".', + ); + } } class RenderComment { diff --git a/lib/src/backup_service.dart b/lib/src/backup_service.dart index 3222aa0..5d3b66b 100644 --- a/lib/src/backup_service.dart +++ b/lib/src/backup_service.dart @@ -2281,7 +2281,11 @@ class BackupService { return await action(); } finally { if (locked) { - await lock.unlock(); + try { + await lock.unlock(); + } catch (_) { + // Still close the file handle below even if unlock fails. + } } await lock.close(); } @@ -2517,7 +2521,9 @@ class BackupService { if (lower.contains('authorization failed') || lower.contains('missing local credentials') || lower.contains('missing labarchives uid') || - lower.contains('auth')) { + lower.contains('authoriz') || + lower.contains('auth_code') || + lower.contains('auth code')) { return _BackupFailure( category: BackupFailureCategory.authorization, message: _oneLine(message), diff --git a/lib/src/help_search_service.dart b/lib/src/help_search_service.dart index 8c980fb..7c18982 100644 --- a/lib/src/help_search_service.dart +++ b/lib/src/help_search_service.dart @@ -312,7 +312,11 @@ class HelpSearchService { final response = await request.close().timeout( const Duration(seconds: 90), ); - final responseBody = await utf8.decoder.bind(response).join(); + // Timeout the body read independently; response.close() only covers headers. + final responseBody = await utf8.decoder + .bind(response) + .join() + .timeout(const Duration(seconds: 120)); if (response.statusCode < 200 || response.statusCode >= 300) { throw AiApiHttpException(response.statusCode, responseBody); } diff --git a/lib/src/labarchives_client.dart b/lib/src/labarchives_client.dart index b8bf732..55c3c23 100644 --- a/lib/src/labarchives_client.dart +++ b/lib/src/labarchives_client.dart @@ -182,8 +182,12 @@ class LabArchivesClient { } catch (_) { // Best-effort cleanup after an interrupted response. } - if (await partial.exists()) { - await partial.delete(); + try { + if (await partial.exists()) { + await partial.delete(); + } + } catch (_) { + // Don't let cleanup mask the original download failure. } rethrow; } diff --git a/test/backup_models_test.dart b/test/backup_models_test.dart index f5c6edb..038485c 100644 --- a/test/backup_models_test.dart +++ b/test/backup_models_test.dart @@ -73,4 +73,58 @@ void main() { expect(records, hasLength(1)); expect(records.single.id, 'run_001_demo_lab'); }); + + test('render notebooks reject missing or invalid createdAt', () { + final valid = { + 'name': 'Demo Lab Notebook', + 'createdAt': '2026-05-14T12:00:00.000Z', + 'archivePath': 'notebooks/demo_lab/2026/05/14/run_001/notebook.7z', + 'nodes': const [], + }; + + final missingCreatedAt = Map.of(valid) + ..remove('createdAt'); + final invalidCreatedAt = Map.of(valid) + ..['createdAt'] = 'not-a-date'; + + expect( + () => RenderNotebook.fromJson(missingCreatedAt), + throwsA(isA()), + ); + expect( + () => RenderNotebook.fromJson(invalidCreatedAt), + throwsA(isA()), + ); + }); + + test('render nodes and parts reject missing structural IDs', () { + final validNode = { + 'id': 42, + 'parentId': 0, + 'title': 'Zebrafish hypoxia assay', + 'isPage': true, + 'position': 1, + 'parts': const [], + }; + final validPart = { + 'id': 7, + 'kindCode': 2, + 'kindLabel': 'Attachment', + 'renderText': 'Raw imaging export', + 'position': 1, + 'comments': const [], + }; + + final missingNodeId = Map.of(validNode)..remove('id'); + final missingPartId = Map.of(validPart)..remove('id'); + + expect( + () => RenderNode.fromJson(missingNodeId), + throwsA(isA()), + ); + expect( + () => RenderPart.fromJson(missingPartId), + throwsA(isA()), + ); + }); }