Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion ios/Runner/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
]
}

Expand Down
48 changes: 42 additions & 6 deletions lib/src/backup_models.dart
Original file line number Diff line number Diff line change
Expand Up @@ -380,12 +380,18 @@ class BackupRunManifest {
.whereType<Map<String, Object?>>()
.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? ??
Expand Down Expand Up @@ -724,7 +730,7 @@ class RenderNotebook {
final rawNodes = json['nodes'] as List<Object?>? ?? 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
Expand All @@ -734,6 +740,16 @@ class RenderNotebook {
);
}

static DateTime _requiredCreatedAt(Map<String, Object?> 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());
}

Expand Down Expand Up @@ -766,7 +782,7 @@ class RenderNode {
static RenderNode fromJson(Map<String, Object?> json) {
final rawParts = json['parts'] as List<Object?>? ?? 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,
Expand All @@ -777,6 +793,16 @@ class RenderNode {
.toList(),
);
}

static int _requiredId(Map<String, Object?> json) {
final value = json['id'];
if (value is int) {
return value;
}
throw const FormatException(
'Render node is missing required integer "id".',
);
}
}

class RenderPart {
Expand Down Expand Up @@ -848,7 +874,7 @@ class RenderPart {

static RenderPart fromJson(Map<String, Object?> 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? ?? '',
Expand All @@ -866,6 +892,16 @@ class RenderPart {
attachmentOriginalVersion: json['attachmentOriginalVersion'] as int?,
);
}

static int _requiredId(Map<String, Object?> json) {
final value = json['id'];
if (value is int) {
return value;
}
throw const FormatException(
'Render part is missing required integer "id".',
);
}
}

class RenderComment {
Expand Down
10 changes: 8 additions & 2 deletions lib/src/backup_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down Expand Up @@ -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),
Expand Down
6 changes: 5 additions & 1 deletion lib/src/help_search_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
8 changes: 6 additions & 2 deletions lib/src/labarchives_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
54 changes: 54 additions & 0 deletions test/backup_models_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <String, Object?>{
'name': 'Demo Lab Notebook',
'createdAt': '2026-05-14T12:00:00.000Z',
'archivePath': 'notebooks/demo_lab/2026/05/14/run_001/notebook.7z',
'nodes': const <Object?>[],
};

final missingCreatedAt = Map<String, Object?>.of(valid)
..remove('createdAt');
final invalidCreatedAt = Map<String, Object?>.of(valid)
..['createdAt'] = 'not-a-date';

expect(
() => RenderNotebook.fromJson(missingCreatedAt),
throwsA(isA<FormatException>()),
);
expect(
() => RenderNotebook.fromJson(invalidCreatedAt),
throwsA(isA<FormatException>()),
);
});

test('render nodes and parts reject missing structural IDs', () {
final validNode = <String, Object?>{
'id': 42,
'parentId': 0,
'title': 'Zebrafish hypoxia assay',
'isPage': true,
'position': 1,
'parts': const <Object?>[],
};
final validPart = <String, Object?>{
'id': 7,
'kindCode': 2,
'kindLabel': 'Attachment',
'renderText': 'Raw imaging export',
'position': 1,
'comments': const <Object?>[],
};

final missingNodeId = Map<String, Object?>.of(validNode)..remove('id');
final missingPartId = Map<String, Object?>.of(validPart)..remove('id');

expect(
() => RenderNode.fromJson(missingNodeId),
throwsA(isA<FormatException>()),
);
expect(
() => RenderPart.fromJson(missingPartId),
throwsA(isA<FormatException>()),
);
});
}
Loading