diff --git a/docs/wiki/architecture.md b/docs/wiki/architecture.md index 6e55da0..99140ae 100644 --- a/docs/wiki/architecture.md +++ b/docs/wiki/architecture.md @@ -26,6 +26,8 @@ AppDelegate - `AISettingsPanelController*.swift`: settings window, with focused builders for each page and separate speech selection/download extensions. - `SpeechPlaybackCoordinator.swift`, `SpeechRuntimeResourceManager.swift`, and `RuntimeDownload.swift`: local TTS playback, runtime selection, compatibility, and model downloads. - `SQLiteTransactionExecutor.swift`: shared checked transaction boundary used by SQLite-backed stores. +- `UserDataBackupService*.swift`: versioned user-data packages, live SQLite snapshots, credential-filtered preferences, integrity validation, and journaled cold-start restore/rollback. +- `AppDelegate+UserDataBackup.swift`: backup and restore menu workflow. Pending restores run before reader controllers and database singletons are created. - `Resources/reader-web*.js`: focused WebKit reader modules for text, marks, search, TTS ranges, selection events, and bridge installation. Web marks share cached normalized text indexes and prefer CSS Custom Highlight ranges, with a DOM-span fallback for older WebKit versions. - `RecentDocuments*.swift` and `RecentBookCardView.swift`: bookshelf panel and recent document UI. - `WordRecordSQLiteStore.swift` and related stores: persistent word and conversation data. @@ -37,6 +39,8 @@ Large controllers are split by behavior into extensions or focused helper views. Document opening prioritizes first visible content. PDF cover generation, table-of-contents construction, and persisted mark restoration start only after the reader surface is visible, and every asynchronous result is guarded by the active document generation. +Reader selection and automatic background embedding work do not inspect Keychain credentials. Credentials are read only from explicit AI, diagnostics, connection-test, or settings actions. User-data backups exclude all current and legacy API-key preference fields and never copy or replace Keychain items. + ## Related Files - `mac-app/AppDelegate.swift` diff --git a/docs/wiki/code-map.md b/docs/wiki/code-map.md index c680fc9..da9cb24 100644 --- a/docs/wiki/code-map.md +++ b/docs/wiki/code-map.md @@ -4,9 +4,9 @@ Generated by `./scripts/generate_code_wiki.sh`. ## Summary -- Code files: 439 -- Main code lines: 59856 -- Swift app lines: 48857 +- Code files: 446 +- Main code lines: 61328 +- Swift app lines: 49997 - Full Swift type index: [Type Index](type-index.md) ## Largest Files @@ -14,6 +14,7 @@ Generated by `./scripts/generate_code_wiki.sh`. | File | Lines | | --- | ---: | | `tests/VocabularyLogicTests.swift` | 500 | +| `mac-app/UserDataBackupService.swift` | 494 | | `mac-app/DocumentLoading+DOCXStreaming.swift` | 492 | | `tests/ReaderCoreLogicTests.swift` | 487 | | `mac-app/PersonalVocabularyProfileStore.swift` | 479 | @@ -26,12 +27,11 @@ Generated by `./scripts/generate_code_wiki.sh`. | `mac-app/ReadingNotePanelController+AskAI.swift` | 413 | | `mac-app/Resources/reader-web-marks.js` | 401 | | `mac-app/ReaderWindowController+Input.swift` | 400 | +| `tests/AISettingsLogicTests.swift` | 399 | | `tests/ReadingNoteMarkdownLogicTests.swift` | 390 | | `mac-app/WordRecordSQLiteStore.swift` | 389 | | `tests/SpeechRuntimeAvailabilityTests.swift` | 385 | | `mac-app/DocumentLoading+EPUB.swift` | 385 | -| `mac-app/SelectionActionToolbar.swift` | 376 | -| `tests/AISettingsLogicTests.swift` | 373 | ## Reader Window Modules @@ -82,6 +82,7 @@ Generated by `./scripts/generate_code_wiki.sh`. - `mac-app/ReaderWindowController+ToolbarUI.swift` - `mac-app/ReaderWindowController+UI.swift` - `mac-app/ReaderWindowController+UILayout.swift` +- `mac-app/ReaderWindowController+UserDataBackup.swift` - `mac-app/ReaderWindowController+Vocabulary.swift` - `mac-app/ReaderWindowController+VocabularyAnswerFormatting.swift` - `mac-app/ReaderWindowController+VocabularyCards.swift` diff --git a/docs/wiki/development-tasks.md b/docs/wiki/development-tasks.md index 92d8e09..7513ae5 100644 --- a/docs/wiki/development-tasks.md +++ b/docs/wiki/development-tasks.md @@ -241,6 +241,35 @@ Watch for: - Sorting or import behavior changing without test coverage. - Shelf actions clearing the wrong document data. +## Change User Data Backup, Restore, Or Credentials + +Start with: + +- `mac-app/UserDataBackupModels.swift` +- `mac-app/UserDataBackupService.swift` +- `mac-app/UserDataBackupService+Restore.swift` +- `mac-app/AppDelegate+UserDataBackup.swift` +- `mac-app/LocalEncryptedStore.swift` +- `mac-app/EmbeddingClient.swift` + +Run: + +```sh +./tests/run.sh +./scripts/check.sh --no-build +./scripts/check_ui_theme.sh --warnings-as-errors +./scripts/build_app.sh +``` + +Watch for: + +- Including API keys, legacy credential preference fields, caches, model downloads, or document text caches in a backup. +- Restoring SQLite files after store singletons have opened them; restore must run during cold startup. +- Accepting undeclared files, symbolic links, escaping paths, excessive entry counts, oversized payloads, checksum mismatches, or failed SQLite integrity checks. +- Applying only part of a restore without retaining a durable rollback journal. +- Deleting current-machine credentials while applying preferences from another backup. +- Reading Keychain merely because the reader opened a document, selected text, or scheduled automatic background work. + ## Publish A New Version Start with: diff --git a/docs/wiki/feature-map.md b/docs/wiki/feature-map.md index 5bef5a8..a391b13 100644 --- a/docs/wiki/feature-map.md +++ b/docs/wiki/feature-map.md @@ -62,6 +62,9 @@ Use this page when the task starts from a product feature instead of a file name ## Persistence Helpers - `mac-app/SQLiteSchemaMigrator.swift`: shared SQLite column migration helper used by reading notes and vocabulary records. +- `mac-app/UserDataBackupService*.swift`: validated backup packages and journaled cold-start restoration. +- `mac-app/AppDelegate+UserDataBackup.swift`: File menu backup/restore workflow and startup recovery. +- `mac-app/LocalEncryptedStore.swift`: on-demand Keychain storage for API credentials; credentials are excluded from user-data backups. ## TTS And Read Aloud diff --git a/docs/wiki/getting-started.md b/docs/wiki/getting-started.md index 6c0c703..a19c357 100644 --- a/docs/wiki/getting-started.md +++ b/docs/wiki/getting-started.md @@ -41,7 +41,7 @@ AI 功能不是必须项。未配置 AI 时,普通阅读、书架、翻页和 4. 如需整本书问答或文档检索,配置 embedding 模型。 5. 运行连接测试。 -API Key 保存在本机。只有使用 AI 功能时,选中的文本、问题或用于分析的片段才会发送到你配置的模型服务。 +API Key 保存在 macOS Keychain 中。普通启动、打开文档和选择文字不会读取 Keychain;应用只在你实际执行 AI 操作、打开相关设置或运行诊断时读取所需密钥。只有使用 AI 功能时,选中的文本、问题或用于分析的片段才会发送到你配置的模型服务。 ## 使用翻译和解释 @@ -72,6 +72,13 @@ Leaf Reader 使用 Sparkle 更新通道。发布版本后,可以在应用内 如果更新失败,先看 [故障排查](troubleshooting.md)。 +## 备份和恢复用户数据 + +- 使用“文件 > 备份用户数据...”备份词汇、学习状态、阅读笔记、笔记图片、会话和偏好设置。 +- API Key、模型文件和可重建缓存不会写入备份。 +- 使用“文件 > 恢复用户数据...”选择并验证备份。为避免替换已打开的数据库,恢复会安排在下次启动、Reader 窗口创建之前执行。 +- 恢复不会覆盖当前 Mac 的 Keychain API Key。 + ## 相关页面 - [功能地图](feature-map.md) diff --git a/docs/wiki/security.md b/docs/wiki/security.md index 60c6169..0d8d9d1 100644 --- a/docs/wiki/security.md +++ b/docs/wiki/security.md @@ -5,7 +5,9 @@ This page records security practices for Leaf Reader development and release wor ## API Keys - Never commit API keys, tokens, private keys, signing keys, `.env` files, or local credentials. -- AI provider keys should be entered through the app settings UI and stored locally. +- AI provider keys should be entered through the app settings UI and stored in macOS Keychain. +- Reader startup, document opening, text selection, and automatic background embedding must not read Keychain. Credential access is deferred until an explicit AI, settings, diagnostics, or connection-test action. +- User-data backups exclude Keychain items and filter both `apiKey.*` and legacy `encryptedApiKey.*` preference fields. Restore preserves credentials already present on the current Mac. - Do not hard-code provider keys in Swift, JavaScript, HTML, shell scripts, docs, app bundles, or tests. - If a key appears in GitHub Secret Scanning, treat it as exposed even if it was removed from the current branch. diff --git a/docs/wiki/type-index.md b/docs/wiki/type-index.md index 93ffd52..8df3012 100644 --- a/docs/wiki/type-index.md +++ b/docs/wiki/type-index.md @@ -98,6 +98,7 @@ Generated by `./scripts/generate_code_wiki.sh`. | `mac-app/AppDelegate+Menu.swift` | 3 | `extension AppDelegate {` | | `mac-app/AppDelegate+Updates.swift` | 212 | `extension AppDelegate` | | `mac-app/AppDelegate+Updates.swift` | 4 | `extension AppDelegate {` | +| `mac-app/AppDelegate+UserDataBackup.swift` | 4 | `extension AppDelegate {` | | `mac-app/AppDelegate+Version.swift` | 3 | `extension AppDelegate {` | | `mac-app/AppDelegate.swift` | 4 | `final class AppDelegate` | | `mac-app/AppFont.swift` | 3 | `enum AppFont {` | @@ -321,6 +322,7 @@ Generated by `./scripts/generate_code_wiki.sh`. | `mac-app/ReaderWindowController+UILayout.swift` | 18 | `enum ReaderUILayout {` | | `mac-app/ReaderWindowController+UILayout.swift` | 3 | `struct ReaderToolbarSetup {` | | `mac-app/ReaderWindowController+UILayout.swift` | 88 | `extension ReaderWindowController {` | +| `mac-app/ReaderWindowController+UserDataBackup.swift` | 3 | `extension ReaderWindowController {` | | `mac-app/ReaderWindowController+Vocabulary.swift` | 29 | `final class VocabularyDetailScrollView` | | `mac-app/ReaderWindowController+Vocabulary.swift` | 36 | `final class VocabularyDetailClipView` | | `mac-app/ReaderWindowController+Vocabulary.swift` | 3 | `final class VocabularySpeakerButton` | @@ -474,6 +476,15 @@ Generated by `./scripts/generate_code_wiki.sh`. | `mac-app/ThemedSettingsSlider.swift` | 3 | `final class ThemedSettingsSlider` | | `mac-app/UpdateFailureClassifier.swift` | 11 | `enum UpdateFailureClassifier {` | | `mac-app/UpdateFailureClassifier.swift` | 3 | `enum UpdateFailureKind` | +| `mac-app/UserDataBackupModels.swift` | 115 | `struct UserDataRestoreRequest` | +| `mac-app/UserDataBackupModels.swift` | 119 | `struct UserDataRestoreJournal` | +| `mac-app/UserDataBackupModels.swift` | 24 | `struct UserDataBackupManifest` | +| `mac-app/UserDataBackupModels.swift` | 3 | `struct UserDataBackupConfiguration {` | +| `mac-app/UserDataBackupModels.swift` | 55 | `struct UserDataRestoreResult` | +| `mac-app/UserDataBackupModels.swift` | 60 | `enum UserDataBackupPreferencePolicy {` | +| `mac-app/UserDataBackupModels.swift` | 80 | `enum UserDataBackupError` | +| `mac-app/UserDataBackupService+Restore.swift` | 3 | `extension UserDataBackupService {` | +| `mac-app/UserDataBackupService.swift` | 5 | `final class UserDataBackupService {` | | `mac-app/VocabularyAnswerFormatter.swift` | 3 | `enum VocabularyAnswerFormatter {` | | `mac-app/VocabularyAnswerSanitizer.swift` | 3 | `enum VocabularyAnswerSanitizer {` | | `mac-app/VocabularyAudioCache.swift` | 4 | `enum VocabularyAudioCache {` | diff --git a/mac-app/AppDelegate+Menu.swift b/mac-app/AppDelegate+Menu.swift index 63e24f1..5aa3f3b 100644 --- a/mac-app/AppDelegate+Menu.swift +++ b/mac-app/AppDelegate+Menu.swift @@ -72,6 +72,19 @@ extension AppDelegate { modifiers: [.command, .shift] )) menu.addItem(.separator()) + menu.addItem(menuItem( + AppText.localized("备份用户数据...", "Back Up User Data..."), + action: #selector(createUserDataBackup(_:)), + key: "", + target: self + )) + menu.addItem(menuItem( + AppText.localized("恢复用户数据...", "Restore User Data..."), + action: #selector(scheduleUserDataRestore(_:)), + key: "", + target: self + )) + menu.addItem(.separator()) menu.addItem(menuItem( AppText.localized("关闭窗口", "Close Window"), action: #selector(NSWindow.performClose(_:)), diff --git a/mac-app/AppDelegate+UserDataBackup.swift b/mac-app/AppDelegate+UserDataBackup.swift new file mode 100644 index 0000000..0a362a7 --- /dev/null +++ b/mac-app/AppDelegate+UserDataBackup.swift @@ -0,0 +1,149 @@ +import Cocoa +import UniformTypeIdentifiers + +extension AppDelegate { + func prepareUserDataBackupBeforePersistenceActivation() -> Bool { + guard let configuration = UserDataBackupConfiguration.production() else { + return true + } + let service = UserDataBackupService(configuration: configuration) + userDataBackupService = service + do { + try service.recoverInterruptedRestoreIfNeeded() + if let result = try service.performPendingRestoreIfNeeded() { + restoredUserDataEntryCount = result.restoredEntryCount + } + return true + } catch { + let alert = NSAlert(error: error) + alert.messageText = AppText.localized("无法安全恢复用户数据", "User data could not be recovered safely") + alert.informativeText = error.localizedDescription + alert.alertStyle = .critical + alert.runModal() + NSApp.terminate(nil) + return false + } + } + + func showUserDataRestoreCompletionIfNeeded() { + guard let restoredUserDataEntryCount else { return } + self.restoredUserDataEntryCount = nil + let alert = NSAlert() + alert.messageText = AppText.localized("用户数据已恢复", "User data restored") + alert.informativeText = AppText.localized( + "已验证并恢复 \(restoredUserDataEntryCount) 个数据项。Keychain 中的 API 密钥未被更改。", + "Validated and restored \(restoredUserDataEntryCount) data items. API keys in Keychain were not changed." + ) + alert.alertStyle = .informational + alert.addButton(withTitle: AppText.localized("好", "OK")) + alert.beginSheetModal(for: controller.window ?? NSWindow()) + } + + @objc func createUserDataBackup(_ sender: Any?) { + guard let service = userDataBackupService else { + showUserDataBackupError(UserDataBackupError.fileOperation("Application Support is unavailable")) + return + } + let panel = NSSavePanel() + panel.title = AppText.localized("备份用户数据", "Back Up User Data") + panel.prompt = AppText.localized("备份", "Back Up") + panel.nameFieldStringValue = defaultUserDataBackupName() + panel.canCreateDirectories = true + panel.isExtensionHidden = false + panel.allowedContentTypes = [UTType(exportedAs: "com.linlu.leafreader.user-data-backup")] + guard panel.runModal() == .OK, var destinationURL = panel.url else { return } + if destinationURL.pathExtension.lowercased() != "leafreaderbackup" { + destinationURL.appendPathExtension("leafreaderbackup") + } + + controller.prepareForUserDataBackup() + DispatchQueue.global(qos: .utility).async { [weak self] in + do { + let manifest = try service.createBackup(at: destinationURL) + DispatchQueue.main.async { + self?.showUserDataBackupCompletion( + entryCount: manifest.entries.count, + destinationURL: destinationURL + ) + } + } catch { + DispatchQueue.main.async { self?.showUserDataBackupError(error) } + } + } + } + + @objc func scheduleUserDataRestore(_ sender: Any?) { + guard let service = userDataBackupService else { + showUserDataBackupError(UserDataBackupError.fileOperation("Application Support is unavailable")) + return + } + let panel = NSOpenPanel() + panel.title = AppText.localized("恢复用户数据", "Restore User Data") + panel.prompt = AppText.localized("验证", "Validate") + panel.canChooseDirectories = true + panel.canChooseFiles = true + panel.allowsMultipleSelection = false + panel.treatsFilePackagesAsDirectories = false + guard panel.runModal() == .OK, let backupURL = panel.url else { return } + + DispatchQueue.global(qos: .utility).async { [weak self] in + do { + try service.scheduleRestore(at: backupURL) + DispatchQueue.main.async { + self?.confirmScheduledUserDataRestore(service: service) + } + } catch { + DispatchQueue.main.async { self?.showUserDataBackupError(error) } + } + } + } + + private func confirmScheduledUserDataRestore(service: UserDataBackupService) { + let alert = NSAlert() + alert.messageText = AppText.localized("备份已验证", "Backup validated") + alert.informativeText = AppText.localized( + "恢复将在下次启动时、数据库打开之前执行。Keychain 中的 API 密钥不会备份或覆盖。现在退出应用吗?", + "Restore will run before databases open on the next launch. API keys in Keychain are neither backed up nor overwritten. Quit now?" + ) + alert.alertStyle = .warning + alert.addButton(withTitle: AppText.localized("退出并准备恢复", "Quit and Prepare Restore")) + alert.addButton(withTitle: AppText.localized("取消", "Cancel")) + let response = alert.runModal() + guard response == .alertFirstButtonReturn else { + do { + try service.clearPendingRestore() + } catch { + showUserDataBackupError(error) + } + return + } + controller.prepareForUserDataBackup() + NSApp.terminate(nil) + } + + private func showUserDataBackupCompletion(entryCount: Int, destinationURL: URL) { + let alert = NSAlert() + alert.messageText = AppText.localized("备份完成", "Backup complete") + alert.informativeText = AppText.localized( + "已备份 \(entryCount) 个数据项到:\n\(destinationURL.path)\n\nAPI 密钥未包含在备份中。", + "Backed up \(entryCount) data items to:\n\(destinationURL.path)\n\nAPI keys were not included." + ) + alert.alertStyle = .informational + alert.runModal() + } + + private func showUserDataBackupError(_ error: Error) { + let alert = NSAlert(error: error) + alert.messageText = AppText.localized("用户数据操作失败", "User data operation failed") + alert.informativeText = error.localizedDescription + alert.alertStyle = .critical + alert.runModal() + } + + private func defaultUserDataBackupName() -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd-HHmm" + return "Leaf-Reader-Backup-\(formatter.string(from: Date())).leafreaderbackup" + } +} diff --git a/mac-app/AppDelegate.swift b/mac-app/AppDelegate.swift index 773a4c8..21ccd88 100644 --- a/mac-app/AppDelegate.swift +++ b/mac-app/AppDelegate.swift @@ -15,9 +15,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate { var manualUpdateProbeHandledResult = false weak var manualUpdateSender: AnyObject? var pendingOpenFileURLs: [URL] = [] + var userDataBackupService: UserDataBackupService? + var restoredUserDataEntryCount: Int? func applicationDidFinishLaunching(_ notification: Notification) { LaunchPerformanceTracker.shared.mark("didFinishLaunching") + guard prepareUserDataBackupBeforePersistenceActivation() else { return } controller = ReaderWindowController() LaunchPerformanceTracker.shared.mark("windowController") installMainMenu() @@ -25,6 +28,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { controller.window?.makeKeyAndOrderFront(nil) LaunchPerformanceTracker.shared.mark("windowVisible") NSApp.activate(ignoringOtherApps: true) + showUserDataRestoreCompletionIfNeeded() loadPendingOpenFilesIfNeeded() LaunchPerformanceTracker.shared.finish() startUpdaterAfterInitialWindowDisplay() diff --git a/mac-app/EmbeddingClient.swift b/mac-app/EmbeddingClient.swift index a0198aa..252c1fa 100644 --- a/mac-app/EmbeddingClient.swift +++ b/mac-app/EmbeddingClient.swift @@ -15,10 +15,11 @@ struct EmbeddingModelConfig { } final class EmbeddingClient { - static func configFromCurrentAISettings() -> EmbeddingModelConfig? { + static func configFromCurrentAISettings(allowsCredentialAccess: Bool = true) -> EmbeddingModelConfig? { let endpoint = AISettingsStore.embeddingEndpoint let endpointOption = AISettingsStore.selectedEmbeddingEndpointOption - let apiKey = AISettingsStore.embeddingAPIKey + guard allowsCredentialAccess || !endpointOption.requiresAPIKey else { return nil } + let apiKey = allowsCredentialAccess ? AISettingsStore.embeddingAPIKey : "" guard !endpointOption.requiresAPIKey || !apiKey.isEmpty else { return nil } return EmbeddingModelConfig( diff --git a/mac-app/ReaderWindowController+EmbeddingLifecycle.swift b/mac-app/ReaderWindowController+EmbeddingLifecycle.swift index 62492ad..2400c84 100644 --- a/mac-app/ReaderWindowController+EmbeddingLifecycle.swift +++ b/mac-app/ReaderWindowController+EmbeddingLifecycle.swift @@ -8,7 +8,7 @@ extension ReaderWindowController { func scheduleDocumentEmbeddingWarmup(priorityPageIndex: Int?) { guard AISettingsStore.autoEmbeddingIndexEnabled, - EmbeddingClient.configFromCurrentAISettings() != nil else { + EmbeddingClient.configFromCurrentAISettings(allowsCredentialAccess: false) != nil else { return } guard let documentID = currentFileMD5 else { return } diff --git a/mac-app/ReaderWindowController+SelectionToolbar.swift b/mac-app/ReaderWindowController+SelectionToolbar.swift index 88252c2..5740479 100644 --- a/mac-app/ReaderWindowController+SelectionToolbar.swift +++ b/mac-app/ReaderWindowController+SelectionToolbar.swift @@ -99,9 +99,11 @@ extension ReaderWindowController { func configureSelectionToolbarActions(for text: String) { let wordText = selectedVocabularyTextForToolbar(fallback: text) let isVocabulary = vocabularySpeakerWord(wordText) != nil + let isOnline = NetworkConnectivityMonitor.shared.isOnline + let canOfferModelActionWithoutCredentialLookup = !AISettingsStore.selectedModel.requiresAPIKey || isOnline let capabilityState = ReaderCapabilityState.make( - isOnline: NetworkConnectivityMonitor.shared.isOnline, - hasModelAPIKey: AISettingsStore.hasAPIKeyForSelectedModel, + isOnline: isOnline, + hasModelAPIKey: canOfferModelActionWithoutCredentialLookup, isLocalDictionaryInstalled: ECDICTDictionary.shared.isInstalled ) let configuration = SelectionToolbarConfiguration.make( diff --git a/mac-app/ReaderWindowController+UserDataBackup.swift b/mac-app/ReaderWindowController+UserDataBackup.swift new file mode 100644 index 0000000..5e0688d --- /dev/null +++ b/mac-app/ReaderWindowController+UserDataBackup.swift @@ -0,0 +1,10 @@ +import Foundation + +extension ReaderWindowController { + func prepareForUserDataBackup() { + performSessionSave() + flushPendingAIConversationSave() + flushCurrentBookWordRecordSaves(waitForCompletion: true) + UserDefaults.standard.synchronize() + } +} diff --git a/mac-app/ReaderWindowController+VocabularySelectionBounds.swift b/mac-app/ReaderWindowController+VocabularySelectionBounds.swift index 13a7278..2c82009 100644 --- a/mac-app/ReaderWindowController+VocabularySelectionBounds.swift +++ b/mac-app/ReaderWindowController+VocabularySelectionBounds.swift @@ -28,9 +28,6 @@ extension ReaderWindowController { func vocabularyTextForCurrentPDFSelection(selection: PDFSelection?, fallback: String) -> String { let normalizedFallback = normalizedPDFVocabularyText(fallback) - if VocabularyTextPolicy.speakableWord(normalizedFallback) != nil { - return normalizedFallback - } guard let selection, let page = selection.pages.first, let pageText = page.string, @@ -123,12 +120,18 @@ extension ReaderWindowController { fallback: String ) -> String? { let trimmed = fallback.trimmingCharacters(in: .whitespacesAndNewlines) - guard trimmed.range(of: #"[‐‑‒–—-]$"#, options: .regularExpression) != nil else { return nil } - let prefix = trimmed.replacingOccurrences(of: #"[‐‑‒–—-]+$"#, with: "", options: .regularExpression) - guard !prefix.isEmpty else { return nil } + let pattern: String + if trimmed.range(of: #"[‐‑‒–—-]$"#, options: .regularExpression) != nil { + let prefix = trimmed.replacingOccurrences(of: #"[‐‑‒–—-]+$"#, with: "", options: .regularExpression) + guard !prefix.isEmpty else { return nil } + pattern = VocabularyTextPolicy.lineBrokenHyphenWordPattern(prefix: prefix) + } else { + guard VocabularyTextPolicy.isSingleEnglishWord(trimmed) else { return nil } + pattern = VocabularyTextPolicy.lineBrokenHyphenWordPattern(suffix: trimmed) + } - let pattern = #"(?i)"# + VocabularyTextPolicy.lineBrokenHyphenWordPattern(prefix: prefix) - guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil } + let caseInsensitivePattern = #"(?i)"# + pattern + guard let regex = try? NSRegularExpression(pattern: caseInsensitivePattern) else { return nil } let nsText = pageText as NSString let matches = regex.matches(in: pageText, range: NSRange(location: 0, length: nsText.length)) guard !matches.isEmpty else { return nil } @@ -153,7 +156,20 @@ extension ReaderWindowController { intersectionInset: CGSize(width: 10, height: 8) ) if score < bestScore { - let value = normalizedPDFVocabularyText(nsText.substring(with: match.range)) + let matchedText = nsText.substring(with: match.range) + let layoutHyphenRange = match.range(withName: "layoutHyphen") + let selectionSpansMultipleLines = candidateSelection.selectionsByLine().count > 1 + let localLayoutHyphenRange: NSRange? = + selectionSpansMultipleLines && layoutHyphenRange.location != NSNotFound + ? NSRange( + location: layoutHyphenRange.location - match.range.location, + length: layoutHyphenRange.length + ) + : nil + let value = normalizedPDFVocabularyText( + matchedText, + lineBrokenHyphenRange: localLayoutHyphenRange + ) if VocabularyTextPolicy.speakableWord(value) != nil { bestScore = score bestText = value @@ -164,11 +180,18 @@ extension ReaderWindowController { return bestText } - private func normalizedPDFVocabularyText(_ text: String) -> String { - VocabularyTextPolicy.normalizedPDFVocabularyText(text) { candidate in - let metadata = VocabularyDictionaryMetadataService.metadata(for: candidate) - return metadata.frequency != nil || metadata.tags != nil - } + private func normalizedPDFVocabularyText(_ text: String, lineBrokenHyphenRange: NSRange? = nil) -> String { + VocabularyTextPolicy.normalizedPDFVocabularyText( + text, + lineBrokenHyphenRange: lineBrokenHyphenRange, + isKnownHyphenatedWord: isKnownVocabularyWord, + isKnownWord: isKnownVocabularyWord + ) + } + + private func isKnownVocabularyWord(_ candidate: String) -> Bool { + let metadata = VocabularyDictionaryMetadataService.metadata(for: candidate) + return metadata.frequency != nil || metadata.tags != nil } private func tightSelectionBounds(_ selection: PDFSelection, page: PDFPage, originalBounds: CGRect) -> CGRect { diff --git a/mac-app/UserDataBackupModels.swift b/mac-app/UserDataBackupModels.swift new file mode 100644 index 0000000..ba00942 --- /dev/null +++ b/mac-app/UserDataBackupModels.swift @@ -0,0 +1,144 @@ +import Foundation + +struct UserDataBackupConfiguration { + let applicationSupportDirectory: URL + let preferencesDomainName: String + let applicationBundleIdentifier: String + let defaults: UserDefaults + + static func production(defaults: UserDefaults = .standard) -> UserDataBackupConfiguration? { + guard let supportRoot = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first else { return nil } + let identifier = Bundle.main.bundleIdentifier ?? "com.linlu.LeafReader" + return UserDataBackupConfiguration( + applicationSupportDirectory: supportRoot.appendingPathComponent("LeafReader", isDirectory: true), + preferencesDomainName: identifier, + applicationBundleIdentifier: identifier, + defaults: defaults + ) + } +} + +struct UserDataBackupManifest: Codable, Equatable { + struct Entry: Codable, Equatable { + enum Kind: String, Codable { + case database + case preferences + case readingNoteAsset + } + + let relativePath: String + let kind: Kind + let byteCount: Int64 + let sha256: String + } + + let schemaVersion: Int + let createdAt: Date + let applicationBundleIdentifier: String + let preferencesDomainName: String + let includesReadingNoteAssetsDirectory: Bool + let entries: [Entry] + + enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case createdAt = "created_at" + case applicationBundleIdentifier = "application_bundle_identifier" + case preferencesDomainName = "preferences_domain_name" + case includesReadingNoteAssetsDirectory = "includes_reading_note_assets_directory" + case entries + } +} + +struct UserDataRestoreResult: Equatable { + let restoredEntryCount: Int + let requiresRelaunch: Bool +} + +enum UserDataBackupPreferencePolicy { + private static let sensitivePrefixes = ["apiKey.", "encryptedApiKey."] + + static func isSensitiveKey(_ key: String) -> Bool { + sensitivePrefixes.contains { key.hasPrefix($0) } + } + + static func sanitized(_ domain: [String: Any]) -> [String: Any] { + domain.filter { !isSensitiveKey($0.key) } + } + + static func restoring(_ restored: [String: Any], preservingSensitiveValuesFrom current: [String: Any]) -> [String: Any] { + var result = sanitized(restored) + for (key, value) in current where isSensitiveKey(key) { + result[key] = value + } + return result + } +} + +enum UserDataBackupError: LocalizedError { + case destinationExists(String) + case invalidBackup(String) + case unsupportedSchema(Int) + case incompatibleApplication(String) + case fileOperation(String) + case sqliteSnapshot(String) + case sqliteIntegrity(String) + case preferences(String) + case rollbackFailed(String) + + var errorDescription: String? { + switch self { + case .destinationExists(let path): + return AppText.localized("备份位置已存在:\(path)", "A backup already exists at \(path).") + case .invalidBackup(let reason): + return AppText.localized("备份无效:\(reason)", "The backup is invalid: \(reason)") + case .unsupportedSchema(let version): + return AppText.localized("不支持备份格式版本 \(version)。", "Backup schema version \(version) is not supported.") + case .incompatibleApplication(let identifier): + return AppText.localized("备份属于其他应用:\(identifier)", "The backup belongs to another application: \(identifier)") + case .fileOperation(let reason): + return AppText.localized("备份文件操作失败:\(reason)", "Backup file operation failed: \(reason)") + case .sqliteSnapshot(let name): + return AppText.localized("无法备份数据库:\(name)", "Could not snapshot database: \(name)") + case .sqliteIntegrity(let name): + return AppText.localized("数据库完整性检查失败:\(name)", "SQLite integrity validation failed: \(name)") + case .preferences(let reason): + return AppText.localized("偏好设置处理失败:\(reason)", "Preferences processing failed: \(reason)") + case .rollbackFailed(let reason): + return AppText.localized("恢复失败且回滚不完整:\(reason)", "Restore failed and rollback was incomplete: \(reason)") + } + } +} + +struct UserDataRestoreRequest: Codable { + let backupPath: String +} + +struct UserDataRestoreJournal: Codable { + enum Phase: String, Codable { + case applying + case rollingBack + case committed + } + + struct Unit: Codable { + enum Phase: String, Codable { + case pending + case movingOriginal + case originalMoved + case installingStaged + case installed + } + + let name: String + let hadOriginal: Bool + var phase: Phase + } + + var phase: Phase + var units: [Unit] + var preferencesApplyStarted: Bool + var preferencesApplied: Bool +} diff --git a/mac-app/UserDataBackupService+Restore.swift b/mac-app/UserDataBackupService+Restore.swift new file mode 100644 index 0000000..b2242fb --- /dev/null +++ b/mac-app/UserDataBackupService+Restore.swift @@ -0,0 +1,268 @@ +import Foundation + +extension UserDataBackupService { + @discardableResult + func restoreBackup(at backupURL: URL) throws -> UserDataRestoreResult { + let backupURL = backupURL.standardizedFileURL + let manifest = try validateBackup(at: backupURL) + let payloadURL = backupURL.appendingPathComponent(Self.payloadName, isDirectory: true) + guard let preferencesEntry = manifest.entries.first(where: { $0.kind == .preferences }) else { + throw UserDataBackupError.invalidBackup("preferences payload is missing") + } + let restoredDomain = try preferencesDictionary( + at: try validatedPayloadURL(for: preferencesEntry.relativePath, payloadRoot: payloadURL) + ) + let previousDomain = configuration.defaults.persistentDomain( + forName: configuration.preferencesDomainName + ) ?? [:] + let preferencesToApply = UserDataBackupPreferencePolicy.restoring( + restoredDomain, + preservingSensitiveValuesFrom: previousDomain + ) + + try fileManager.createDirectory( + at: configuration.applicationSupportDirectory, + withIntermediateDirectories: true + ) + let transactionURL = configuration.applicationSupportDirectory.deletingLastPathComponent() + .appendingPathComponent(Self.restoreTransactionPrefix + UUID().uuidString, isDirectory: true) + let stageURL = transactionURL.appendingPathComponent("stage", isDirectory: true) + let rollbackURL = transactionURL.appendingPathComponent("rollback", isDirectory: true) + try fileManager.createDirectory(at: stageURL, withIntermediateDirectories: true) + try fileManager.createDirectory(at: rollbackURL, withIntermediateDirectories: true) + + let unitNames = Self.databaseNames + [Self.readingNoteAssetsName] + var journal = UserDataRestoreJournal( + phase: .applying, + units: unitNames.map { name in + UserDataRestoreJournal.Unit( + name: name, + hadOriginal: fileManager.fileExists( + atPath: configuration.applicationSupportDirectory.appendingPathComponent(name).path + ), + phase: .pending + ) + }, + preferencesApplyStarted: false, + preferencesApplied: false + ) + try writeRestoreJournal(journal, at: transactionURL) + try writePreferences(previousDomain, to: rollbackURL.appendingPathComponent(Self.rollbackPreferencesName)) + + do { + try stageRestorePayload(manifest, payloadRoot: payloadURL, stageURL: stageURL) + for index in journal.units.indices { + try applyRestoreUnit( + at: index, + journal: &journal, + transactionURL: transactionURL, + stageURL: stageURL, + rollbackURL: rollbackURL + ) + try restoreCheckpoint?(index + 1) + } + + journal.preferencesApplyStarted = true + try writeRestoreJournal(journal, at: transactionURL) + configuration.defaults.setPersistentDomain( + preferencesToApply, + forName: configuration.preferencesDomainName + ) + guard configuration.defaults.synchronize() else { + throw UserDataBackupError.preferences("restored preferences could not be synchronized") + } + journal.preferencesApplied = true + journal.phase = .committed + try writeRestoreJournal(journal, at: transactionURL) + } catch { + do { + try rollbackRestoreTransaction(at: transactionURL, journal: &journal) + try fileManager.removeItem(at: transactionURL) + } catch { + throw UserDataBackupError.rollbackFailed(error.localizedDescription) + } + throw error + } + + try fileManager.removeItem(at: transactionURL) + return UserDataRestoreResult( + restoredEntryCount: manifest.entries.count, + requiresRelaunch: true + ) + } + + func recoverInterruptedRestoreIfNeeded() throws { + let parentURL = configuration.applicationSupportDirectory.deletingLastPathComponent() + guard fileManager.fileExists(atPath: parentURL.path) else { return } + let candidates = try fileManager.contentsOfDirectory( + at: parentURL, + includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey], + options: [] + ).filter { $0.lastPathComponent.hasPrefix(Self.restoreTransactionPrefix) } + + for transactionURL in candidates { + try validateRealDirectory(transactionURL) + var journal = try readRestoreJournal(at: transactionURL) + if journal.phase == .committed { + try fileManager.removeItem(at: transactionURL) + continue + } + try rollbackRestoreTransaction(at: transactionURL, journal: &journal) + try fileManager.removeItem(at: transactionURL) + } + } + + @discardableResult + func performPendingRestoreIfNeeded() throws -> UserDataRestoreResult? { + guard let backupURL = try pendingRestoreURL() else { return nil } + try clearPendingRestore() + return try restoreBackup(at: backupURL) + } + + private func stageRestorePayload( + _ manifest: UserDataBackupManifest, + payloadRoot: URL, + stageURL: URL + ) throws { + for entry in manifest.entries where entry.kind == .database { + let source = try validatedPayloadURL(for: entry.relativePath, payloadRoot: payloadRoot) + try fileManager.copyItem(at: source, to: stageURL.appendingPathComponent(entry.relativePath)) + } + guard manifest.includesReadingNoteAssetsDirectory else { return } + let stagedAssets = stageURL.appendingPathComponent(Self.readingNoteAssetsName, isDirectory: true) + try fileManager.createDirectory(at: stagedAssets, withIntermediateDirectories: true) + for entry in manifest.entries where entry.kind == .readingNoteAsset { + let source = try validatedPayloadURL(for: entry.relativePath, payloadRoot: payloadRoot) + let destination = stageURL.appendingPathComponent(entry.relativePath) + try fileManager.createDirectory( + at: destination.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try fileManager.copyItem(at: source, to: destination) + } + } + + private func applyRestoreUnit( + at index: Int, + journal: inout UserDataRestoreJournal, + transactionURL: URL, + stageURL: URL, + rollbackURL: URL + ) throws { + let name = journal.units[index].name + let destination = configuration.applicationSupportDirectory.appendingPathComponent(name) + let staged = stageURL.appendingPathComponent(name) + let rollback = rollbackURL.appendingPathComponent(name) + let hasStaged = fileManager.fileExists(atPath: staged.path) + let hasDestination = fileManager.fileExists(atPath: destination.path) + guard hasStaged || hasDestination else { return } + + if hasDestination { + journal.units[index].phase = .movingOriginal + try writeRestoreJournal(journal, at: transactionURL) + try fileManager.moveItem(at: destination, to: rollback) + journal.units[index].phase = .originalMoved + try writeRestoreJournal(journal, at: transactionURL) + } + if hasStaged { + journal.units[index].phase = .installingStaged + try writeRestoreJournal(journal, at: transactionURL) + try fileManager.moveItem(at: staged, to: destination) + journal.units[index].phase = .installed + try writeRestoreJournal(journal, at: transactionURL) + } + } + + private func writeRestoreJournal(_ journal: UserDataRestoreJournal, at transactionURL: URL) throws { + do { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + try encoder.encode(journal).write( + to: transactionURL.appendingPathComponent(Self.restoreJournalName), + options: .atomic + ) + } catch { + throw UserDataBackupError.fileOperation("could not write restore journal") + } + } + + private func readRestoreJournal(at transactionURL: URL) throws -> UserDataRestoreJournal { + do { + return try JSONDecoder().decode( + UserDataRestoreJournal.self, + from: Data(contentsOf: transactionURL.appendingPathComponent(Self.restoreJournalName)) + ) + } catch { + throw UserDataBackupError.rollbackFailed("restore journal is unreadable") + } + } + + private func rollbackRestoreTransaction( + at transactionURL: URL, + journal: inout UserDataRestoreJournal + ) throws { + let rollbackURL = transactionURL.appendingPathComponent("rollback", isDirectory: true) + var failures: [String] = [] + journal.phase = .rollingBack + try? writeRestoreJournal(journal, at: transactionURL) + + if journal.preferencesApplyStarted { + do { + let previous = try preferencesDictionary( + at: rollbackURL.appendingPathComponent(Self.rollbackPreferencesName) + ) + configuration.defaults.setPersistentDomain( + previous, + forName: configuration.preferencesDomainName + ) + guard configuration.defaults.synchronize() else { + throw UserDataBackupError.preferences("previous preferences could not be synchronized") + } + journal.preferencesApplied = false + } catch { + failures.append(Self.preferencesName) + } + } + + for unit in journal.units.reversed() { + let destination = configuration.applicationSupportDirectory.appendingPathComponent(unit.name) + let rollback = rollbackURL.appendingPathComponent(unit.name) + let hasRollback = fileManager.fileExists(atPath: rollback.path) + let hasDestination = fileManager.fileExists(atPath: destination.path) + do { + if hasRollback { + if hasDestination { try fileManager.removeItem(at: destination) } + try fileManager.moveItem(at: rollback, to: destination) + continue + } + if !unit.hadOriginal, + (unit.phase == .installingStaged || unit.phase == .installed), + hasDestination { + try fileManager.removeItem(at: destination) + continue + } + if unit.hadOriginal, unit.phase != .pending, !hasDestination { + throw UserDataBackupError.rollbackFailed("missing original \(unit.name)") + } + } catch { + failures.append(unit.name) + } + } + guard failures.isEmpty else { + throw UserDataBackupError.rollbackFailed(failures.joined(separator: ", ")) + } + } + + private func writePreferences(_ domain: [String: Any], to url: URL) throws { + do { + let data = try PropertyListSerialization.data( + fromPropertyList: domain, + format: .binary, + options: 0 + ) + try data.write(to: url, options: .atomic) + } catch { + throw UserDataBackupError.preferences("could not journal previous preferences") + } + } +} diff --git a/mac-app/UserDataBackupService.swift b/mac-app/UserDataBackupService.swift new file mode 100644 index 0000000..f151e19 --- /dev/null +++ b/mac-app/UserDataBackupService.swift @@ -0,0 +1,494 @@ +import CryptoKit +import Foundation +import SQLite3 + +final class UserDataBackupService { + static let schemaVersion = 1 + static let manifestName = "manifest.json" + static let payloadName = "payload" + static let preferencesName = "preferences.plist" + static let readingNoteAssetsName = "ReadingNoteAssets" + static let restoreJournalName = "restore-journal.json" + static let rollbackPreferencesName = "previous-preferences.plist" + static let restoreTransactionPrefix = ".LeafReader-restore-" + static let restoreRequestName = ".LeafReader-pending-restore.json" + static let databaseNames = [ + "word-records.sqlite3", + "personal-vocabulary.sqlite3", + "reading-notes.sqlite" + ] + static let maximumEntryCount = 20_000 + static let maximumManifestByteCount: Int64 = 10 * 1_024 * 1_024 + static let maximumEntryByteCount: Int64 = 2 * 1_024 * 1_024 * 1_024 + static let maximumExpandedByteCount: Int64 = 10 * 1_024 * 1_024 * 1_024 + + let configuration: UserDataBackupConfiguration + let fileManager: FileManager + let restoreCheckpoint: ((Int) throws -> Void)? + + init( + configuration: UserDataBackupConfiguration, + fileManager: FileManager = .default, + restoreCheckpoint: ((Int) throws -> Void)? = nil + ) { + self.configuration = configuration + self.fileManager = fileManager + self.restoreCheckpoint = restoreCheckpoint + } + + var restoreRequestURL: URL { + configuration.applicationSupportDirectory.deletingLastPathComponent() + .appendingPathComponent(Self.restoreRequestName) + } + + @discardableResult + func createBackup(at destinationURL: URL) throws -> UserDataBackupManifest { + let backupURL = destinationURL.standardizedFileURL + guard !fileManager.fileExists(atPath: backupURL.path) else { + throw UserDataBackupError.destinationExists(backupURL.path) + } + try fileManager.createDirectory( + at: backupURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let temporaryURL = backupURL.deletingLastPathComponent() + .appendingPathComponent(".\(backupURL.lastPathComponent)-creating-\(UUID().uuidString)", isDirectory: true) + defer { try? fileManager.removeItem(at: temporaryURL) } + + let payloadURL = temporaryURL.appendingPathComponent(Self.payloadName, isDirectory: true) + try fileManager.createDirectory(at: payloadURL, withIntermediateDirectories: true) + var entries: [UserDataBackupManifest.Entry] = [] + + let preferencesURL = payloadURL.appendingPathComponent(Self.preferencesName) + let domain = configuration.defaults.persistentDomain( + forName: configuration.preferencesDomainName + ) ?? [:] + let sanitizedDomain = UserDataBackupPreferencePolicy.sanitized(domain) + do { + let data = try PropertyListSerialization.data( + fromPropertyList: sanitizedDomain, + format: .binary, + options: 0 + ) + try data.write(to: preferencesURL, options: .atomic) + } catch { + throw UserDataBackupError.preferences(error.localizedDescription) + } + entries.append(try entry( + for: preferencesURL, + relativePath: Self.preferencesName, + kind: .preferences + )) + + for name in Self.databaseNames { + let source = configuration.applicationSupportDirectory.appendingPathComponent(name) + guard fileManager.fileExists(atPath: source.path) else { continue } + let destination = payloadURL.appendingPathComponent(name) + try snapshotSQLiteDatabase(from: source, to: destination) + entries.append(try entry(for: destination, relativePath: name, kind: .database)) + } + + let sourceAssets = configuration.applicationSupportDirectory + .appendingPathComponent(Self.readingNoteAssetsName, isDirectory: true) + var sourceAssetsIsDirectory: ObjCBool = false + let includesAssets = fileManager.fileExists( + atPath: sourceAssets.path, + isDirectory: &sourceAssetsIsDirectory + ) && sourceAssetsIsDirectory.boolValue + if includesAssets { + try validateRealDirectory(sourceAssets) + let destinationAssets = payloadURL + .appendingPathComponent(Self.readingNoteAssetsName, isDirectory: true) + try fileManager.createDirectory(at: destinationAssets, withIntermediateDirectories: true) + for source in try regularFilesRecursively(in: sourceAssets) { + let suffix = relativePath(of: source, under: sourceAssets) + let relative = Self.readingNoteAssetsName + "/" + suffix + let destination = payloadURL.appendingPathComponent(relative) + try fileManager.createDirectory( + at: destination.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try fileManager.copyItem(at: source, to: destination) + entries.append(try entry(for: destination, relativePath: relative, kind: .readingNoteAsset)) + } + } + + try enforceLimits(entries) + entries.sort { $0.relativePath < $1.relativePath } + let manifest = UserDataBackupManifest( + schemaVersion: Self.schemaVersion, + createdAt: Date(), + applicationBundleIdentifier: configuration.applicationBundleIdentifier, + preferencesDomainName: configuration.preferencesDomainName, + includesReadingNoteAssetsDirectory: includesAssets, + entries: entries + ) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + var manifestData = try encoder.encode(manifest) + manifestData.append(0x0A) + try manifestData.write( + to: temporaryURL.appendingPathComponent(Self.manifestName), + options: .atomic + ) + try fileManager.moveItem(at: temporaryURL, to: backupURL) + return manifest + } + + func validateBackup(at backupURL: URL) throws -> UserDataBackupManifest { + let backupURL = backupURL.standardizedFileURL + try validateRealDirectory(backupURL) + let manifestURL = backupURL.appendingPathComponent(Self.manifestName) + let payloadURL = backupURL.appendingPathComponent(Self.payloadName, isDirectory: true) + let rootEntries = try fileManager.contentsOfDirectory( + at: backupURL, + includingPropertiesForKeys: [.isDirectoryKey, .isRegularFileKey, .isSymbolicLinkKey], + options: [] + ) + guard Set(rootEntries.map(\.lastPathComponent)) == [Self.manifestName, Self.payloadName] else { + throw UserDataBackupError.invalidBackup("backup root contains undeclared entries") + } + let manifestValues = try manifestURL.resourceValues( + forKeys: [.isRegularFileKey, .isSymbolicLinkKey] + ) + guard manifestValues.isRegularFile == true, + manifestValues.isSymbolicLink != true, + try fileSize(manifestURL) <= Self.maximumManifestByteCount else { + throw UserDataBackupError.invalidBackup("manifest is missing, unsafe, or oversized") + } + try validateRealDirectory(payloadURL) + + let manifest: UserDataBackupManifest + do { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + manifest = try decoder.decode( + UserDataBackupManifest.self, + from: Data(contentsOf: manifestURL) + ) + } catch { + throw UserDataBackupError.invalidBackup("manifest.json could not be decoded") + } + guard manifest.schemaVersion == Self.schemaVersion else { + throw UserDataBackupError.unsupportedSchema(manifest.schemaVersion) + } + guard manifest.applicationBundleIdentifier == configuration.applicationBundleIdentifier else { + throw UserDataBackupError.incompatibleApplication(manifest.applicationBundleIdentifier) + } + guard manifest.preferencesDomainName == configuration.preferencesDomainName else { + throw UserDataBackupError.invalidBackup("preferences domain does not match") + } + try enforceLimits(manifest.entries) + + let assetsURL = payloadURL.appendingPathComponent(Self.readingNoteAssetsName, isDirectory: true) + var assetsIsDirectory: ObjCBool = false + let hasAssets = fileManager.fileExists(atPath: assetsURL.path, isDirectory: &assetsIsDirectory) + && assetsIsDirectory.boolValue + guard hasAssets == manifest.includesReadingNoteAssetsDirectory else { + throw UserDataBackupError.invalidBackup("reading-note assets do not match the manifest") + } + if hasAssets { try validateRealDirectory(assetsURL) } + + var paths = Set() + var preferenceCount = 0 + for entry in manifest.entries { + guard paths.insert(entry.relativePath).inserted else { + throw UserDataBackupError.invalidBackup("duplicate entry \(entry.relativePath)") + } + try validateAllowed(entry) + let url = try validatedPayloadURL(for: entry.relativePath, payloadRoot: payloadURL) + let values = try url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]) + guard values.isRegularFile == true, values.isSymbolicLink != true else { + throw UserDataBackupError.invalidBackup("missing or unsafe payload file \(entry.relativePath)") + } + guard try fileSize(url) == entry.byteCount else { + throw UserDataBackupError.invalidBackup("size mismatch for \(entry.relativePath)") + } + guard try sha256(url) == entry.sha256 else { + throw UserDataBackupError.invalidBackup("checksum mismatch for \(entry.relativePath)") + } + switch entry.kind { + case .database: + try validateSQLiteIntegrity(url) + case .preferences: + preferenceCount += 1 + let preferences = try preferencesDictionary(at: url) + guard UserDataBackupPreferencePolicy.sanitized(preferences).count == preferences.count else { + throw UserDataBackupError.invalidBackup("preferences contain credential data") + } + case .readingNoteAsset: + guard manifest.includesReadingNoteAssetsDirectory else { + throw UserDataBackupError.invalidBackup("reading-note assets directory is undeclared") + } + } + } + guard preferenceCount == 1 else { + throw UserDataBackupError.invalidBackup("exactly one preferences payload is required") + } + let actualFiles = Set(try regularFilesRecursively(in: payloadURL).map { + relativePath(of: $0, under: payloadURL) + }) + guard actualFiles == paths else { + throw UserDataBackupError.invalidBackup("payload contains unlisted or missing files") + } + try validatePayloadDirectories(payloadURL, manifest: manifest) + return manifest + } + + func scheduleRestore(at backupURL: URL) throws { + _ = try validateBackup(at: backupURL) + let request = UserDataRestoreRequest(backupPath: backupURL.standardizedFileURL.path) + let data = try JSONEncoder().encode(request) + try data.write(to: restoreRequestURL, options: .atomic) + } + + func pendingRestoreURL() throws -> URL? { + guard fileManager.fileExists(atPath: restoreRequestURL.path) else { return nil } + do { + let request = try JSONDecoder().decode( + UserDataRestoreRequest.self, + from: Data(contentsOf: restoreRequestURL) + ) + guard !request.backupPath.isEmpty else { + throw UserDataBackupError.invalidBackup("pending restore path is empty") + } + return URL(fileURLWithPath: request.backupPath).standardizedFileURL + } catch let error as UserDataBackupError { + throw error + } catch { + throw UserDataBackupError.invalidBackup("pending restore request is unreadable") + } + } + + func clearPendingRestore() throws { + guard fileManager.fileExists(atPath: restoreRequestURL.path) else { return } + try fileManager.removeItem(at: restoreRequestURL) + } + + func validateAllowed(_ entry: UserDataBackupManifest.Entry) throws { + switch entry.kind { + case .preferences: + guard entry.relativePath == Self.preferencesName else { + throw UserDataBackupError.invalidBackup("preferences path is not allowed") + } + case .database: + guard Self.databaseNames.contains(entry.relativePath) else { + throw UserDataBackupError.invalidBackup("database path is not allowed") + } + case .readingNoteAsset: + guard entry.relativePath.hasPrefix(Self.readingNoteAssetsName + "/") else { + throw UserDataBackupError.invalidBackup("reading-note asset path is not allowed") + } + } + } + + func validatedPayloadURL(for relativePath: String, payloadRoot: URL) throws -> URL { + let components = relativePath.split(separator: "/", omittingEmptySubsequences: false) + guard !relativePath.hasPrefix("/"), !components.contains(".."), !components.contains("") else { + throw UserDataBackupError.invalidBackup("unsafe payload path \(relativePath)") + } + let url = payloadRoot.appendingPathComponent(relativePath).standardizedFileURL + let rootPath = payloadRoot.standardizedFileURL.path + "/" + guard url.path.hasPrefix(rootPath) else { + throw UserDataBackupError.invalidBackup("payload path escapes the backup") + } + return url + } + + func entry( + for url: URL, + relativePath: String, + kind: UserDataBackupManifest.Entry.Kind + ) throws -> UserDataBackupManifest.Entry { + UserDataBackupManifest.Entry( + relativePath: relativePath, + kind: kind, + byteCount: try fileSize(url), + sha256: try sha256(url) + ) + } + + func enforceLimits(_ entries: [UserDataBackupManifest.Entry]) throws { + guard entries.count <= Self.maximumEntryCount else { + throw UserDataBackupError.invalidBackup("too many payload entries") + } + var total: Int64 = 0 + for entry in entries { + guard entry.byteCount >= 0, entry.byteCount <= Self.maximumEntryByteCount else { + throw UserDataBackupError.invalidBackup("payload entry is too large") + } + let (sum, overflow) = total.addingReportingOverflow(entry.byteCount) + guard !overflow, sum <= Self.maximumExpandedByteCount else { + throw UserDataBackupError.invalidBackup("expanded payload is too large") + } + total = sum + } + } + + func validateRealDirectory(_ directory: URL) throws { + let values = try directory.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) + guard values.isDirectory == true, values.isSymbolicLink != true else { + throw UserDataBackupError.invalidBackup("unsafe directory \(directory.lastPathComponent)") + } + } + + func regularFilesRecursively(in directory: URL) throws -> [URL] { + try validateRealDirectory(directory) + var enumerationError: Error? + guard let enumerator = fileManager.enumerator( + at: directory, + includingPropertiesForKeys: [.isRegularFileKey, .isDirectoryKey, .isSymbolicLinkKey], + options: [], + errorHandler: { url, error in + enumerationError = UserDataBackupError.fileOperation( + "could not enumerate \(url.lastPathComponent): \(error.localizedDescription)" + ) + return false + } + ) else { + throw UserDataBackupError.fileOperation("could not enumerate \(directory.lastPathComponent)") + } + var files: [URL] = [] + for case let url as URL in enumerator { + if let enumerationError { throw enumerationError } + let values = try url.resourceValues( + forKeys: [.isRegularFileKey, .isDirectoryKey, .isSymbolicLinkKey] + ) + if values.isSymbolicLink == true { + throw UserDataBackupError.invalidBackup("symbolic links are not allowed") + } + if values.isRegularFile == true { files.append(url) } + else if values.isDirectory != true { + throw UserDataBackupError.invalidBackup("unsupported filesystem entry") + } + } + if let enumerationError { throw enumerationError } + return files.sorted { $0.path < $1.path } + } + + func validatePayloadDirectories(_ payloadURL: URL, manifest: UserDataBackupManifest) throws { + var allowed = Set() + if manifest.includesReadingNoteAssetsDirectory { + allowed.insert(Self.readingNoteAssetsName) + } + for entry in manifest.entries where entry.kind == .readingNoteAsset { + var components = entry.relativePath.split(separator: "/").map(String.init) + guard components.count > 1 else { continue } + components.removeLast() + while !components.isEmpty { + allowed.insert(components.joined(separator: "/")) + components.removeLast() + } + } + guard let enumerator = fileManager.enumerator( + at: payloadURL, + includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey], + options: [] + ) else { + throw UserDataBackupError.invalidBackup("payload could not be enumerated") + } + for case let url as URL in enumerator { + let values = try url.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) + guard values.isSymbolicLink != true else { + throw UserDataBackupError.invalidBackup("payload contains a symbolic link") + } + guard values.isDirectory == true else { continue } + let path = relativePath(of: url, under: payloadURL) + guard allowed.contains(path) else { + throw UserDataBackupError.invalidBackup("payload contains undeclared directory \(path)") + } + } + } + + func relativePath(of url: URL, under root: URL) -> String { + String(url.standardizedFileURL.path.dropFirst(root.standardizedFileURL.path.count + 1)) + } + + func fileSize(_ url: URL) throws -> Int64 { + guard let value = try fileManager.attributesOfItem(atPath: url.path)[.size] as? NSNumber else { + throw UserDataBackupError.fileOperation("could not read file size") + } + return value.int64Value + } + + func sha256(_ url: URL) throws -> String { + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + var hasher = SHA256() + while true { + let data = try handle.read(upToCount: 1_024 * 1_024) ?? Data() + guard !data.isEmpty else { break } + hasher.update(data: data) + } + return hasher.finalize().map { String(format: "%02x", $0) }.joined() + } + + func snapshotSQLiteDatabase(from sourceURL: URL, to destinationURL: URL) throws { + var source: OpaquePointer? + var destination: OpaquePointer? + guard sqlite3_open_v2(sourceURL.path, &source, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else { + sqlite3_close(source) + throw UserDataBackupError.sqliteSnapshot(sourceURL.lastPathComponent) + } + defer { sqlite3_close(source) } + guard sqlite3_open(destinationURL.path, &destination) == SQLITE_OK else { + sqlite3_close(destination) + throw UserDataBackupError.sqliteSnapshot(destinationURL.lastPathComponent) + } + defer { sqlite3_close(destination) } + guard let backup = sqlite3_backup_init(destination, "main", source, "main") else { + throw UserDataBackupError.sqliteSnapshot(sourceURL.lastPathComponent) + } + var result: Int32 = SQLITE_OK + var retries = 0 + repeat { + result = sqlite3_backup_step(backup, -1) + if result == SQLITE_BUSY || result == SQLITE_LOCKED { + retries += 1 + sqlite3_sleep(10) + } + } while (result == SQLITE_BUSY || result == SQLITE_LOCKED) && retries < 100 + let finishResult = sqlite3_backup_finish(backup) + guard result == SQLITE_DONE, finishResult == SQLITE_OK else { + throw UserDataBackupError.sqliteSnapshot(sourceURL.lastPathComponent) + } + } + + func validateSQLiteIntegrity(_ url: URL) throws { + var database: OpaquePointer? + guard sqlite3_open_v2(url.path, &database, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else { + sqlite3_close(database) + throw UserDataBackupError.sqliteIntegrity(url.lastPathComponent) + } + defer { sqlite3_close(database) } + var statement: OpaquePointer? + guard sqlite3_prepare_v2(database, "PRAGMA integrity_check", -1, &statement, nil) == SQLITE_OK else { + throw UserDataBackupError.sqliteIntegrity(url.lastPathComponent) + } + defer { sqlite3_finalize(statement) } + guard sqlite3_step(statement) == SQLITE_ROW, + let value = sqlite3_column_text(statement, 0), + String(cString: value) == "ok" else { + throw UserDataBackupError.sqliteIntegrity(url.lastPathComponent) + } + } + + func preferencesDictionary(at url: URL) throws -> [String: Any] { + do { + let object = try PropertyListSerialization.propertyList( + from: Data(contentsOf: url), + options: [], + format: nil + ) + guard let dictionary = object as? [String: Any] else { + throw UserDataBackupError.preferences("root value is not a dictionary") + } + return dictionary + } catch let error as UserDataBackupError { + throw error + } catch { + throw UserDataBackupError.preferences(error.localizedDescription) + } + } +} diff --git a/mac-app/VocabularyTextPolicy.swift b/mac-app/VocabularyTextPolicy.swift index 9311168..3133b52 100644 --- a/mac-app/VocabularyTextPolicy.swift +++ b/mac-app/VocabularyTextPolicy.swift @@ -20,17 +20,27 @@ enum VocabularyTextPolicy { static func normalizedPDFVocabularyText( _ text: String, + lineBrokenHyphenRange: NSRange? = nil, + isKnownHyphenatedWord: (String) -> Bool = { _ in false }, isKnownWord: (String) -> Bool = { _ in false } ) -> String { - guard containsLineBrokenHyphen(text) else { + let lineBrokenText = textByMarkingLineBrokenHyphen(text, range: lineBrokenHyphenRange) + guard containsLineBrokenHyphen(lineBrokenText) else { return normalizedVocabularyText(text) } - let candidates = lineBrokenHyphenNormalizationCandidates(for: text) + let candidates = lineBrokenHyphenNormalizationCandidates(for: lineBrokenText) let dehyphenated = candidates.dehyphenated let hyphenated = candidates.hyphenated - if isSingleEnglishWord(dehyphenated), - (isKnownWord(dehyphenated) || shouldPreferDehyphenatedLineBreak(original: text, dehyphenated: dehyphenated)) { - return dehyphenated + if isSingleEnglishWord(dehyphenated) { + if isKnownWord(dehyphenated) { + return dehyphenated + } + if isKnownHyphenatedWord(hyphenated) { + return hyphenated + } + if shouldPreferDehyphenatedLineBreak(original: lineBrokenText, dehyphenated: dehyphenated) { + return dehyphenated + } } return hyphenated } @@ -78,7 +88,15 @@ enum VocabularyTextPolicy { } static func lineBrokenHyphenWordPattern(prefix: String) -> String { - boundedPrefixPattern(for: prefix) + #"[‐‑‒–—-]\s*"# + wordTokenPattern + boundedPrefixPattern(for: prefix) + #"(?[‐‑‒–—-])\s*"# + wordTokenPattern + } + + static func lineBrokenHyphenWordPattern(suffix: String) -> String { + wordBoundaryBefore + + wordTokenPattern + + #"(?[‐‑‒–—-])\s*"# + + NSRegularExpression.escapedPattern(for: normalized(suffix)) + + wordBoundaryAfter } static func pdfSearchQueries(for query: String) -> [String] { @@ -124,6 +142,20 @@ enum VocabularyTextPolicy { .trimmingCharacters(in: .whitespacesAndNewlines) } + private static func textByMarkingLineBrokenHyphen(_ text: String, range: NSRange?) -> String { + guard let range, + range.location >= 0, + NSMaxRange(range) <= (text as NSString).length, + let swiftRange = Range(range, in: text), + String(text[swiftRange]).range(of: #"[‐‑‒–—-]"#, options: .regularExpression) != nil else { + return text + } + return (text as NSString).replacingCharacters( + in: NSRange(location: NSMaxRange(range), length: 0), + with: "\n" + ) + } + private static func containsLineBrokenHyphen(_ value: String) -> Bool { value.range(of: #"[‐‑‒–—-]\s+"#, options: .regularExpression) != nil } diff --git a/tests/AISettingsLogicTests.swift b/tests/AISettingsLogicTests.swift index 92ce5c2..2fdeb67 100644 --- a/tests/AISettingsLogicTests.swift +++ b/tests/AISettingsLogicTests.swift @@ -1,6 +1,32 @@ import Foundation enum AISettingsLogicTests { + static func testAutomaticEmbeddingConfigurationAvoidsCredentialAccess() throws { + let suiteName = "LeafReaderTests.AutomaticEmbedding.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + throw TestFailure(description: "could not create automatic embedding defaults") + } + defer { defaults.removePersistentDomain(forName: suiteName) } + let secretStore = InMemoryLocalSecretStore() + try LocalEncryptedStore.withStore(secretStore, legacyDefaults: defaults) { + try AISettingsStore.withDefaults(defaults) { + defaults.set("https://api.openai.com/v1/embeddings", forKey: AISettingsStore.embeddingEndpointKey) + try expect( + EmbeddingClient.configFromCurrentAISettings(allowsCredentialAccess: false) == nil, + "automatic remote embedding should wait for an explicit credential-using action" + ) + try expectEqual(secretStore.readCount, 0, "automatic remote embedding should not read Keychain") + + defaults.set("http://127.0.0.1:11434/api/embed", forKey: AISettingsStore.embeddingEndpointKey) + try expect( + EmbeddingClient.configFromCurrentAISettings(allowsCredentialAccess: false) != nil, + "automatic local embedding should remain available without credentials" + ) + try expectEqual(secretStore.readCount, 0, "automatic local embedding should not read Keychain") + } + } + } + static func testSecureCredentialStoreRoundTripAndLegacyMigration() throws { let suiteName = "LeafReaderTests.LocalSecretStore.\(UUID().uuidString)" guard let defaults = UserDefaults(suiteName: suiteName) else { diff --git a/tests/AISettingsTestSupport.swift b/tests/AISettingsTestSupport.swift index 7f8258d..bce8f36 100644 --- a/tests/AISettingsTestSupport.swift +++ b/tests/AISettingsTestSupport.swift @@ -3,11 +3,13 @@ import Foundation final class InMemoryLocalSecretStore: LocalSecretStoring { var values: [String: String] = [:] + var readCount = 0 var failsReads = false var failsWrites = false var failsDeletes = false func read(account: String) throws -> String? { + readCount += 1 if failsReads { throw TestFailure(description: "injected secret read failure") } return values[account] } diff --git a/tests/LogicTests.swift b/tests/LogicTests.swift index d8c490c..47ec52b 100644 --- a/tests/LogicTests.swift +++ b/tests/LogicTests.swift @@ -24,9 +24,17 @@ private let tests: [(String, () throws -> Void)] = [ ("Vocabulary learning stats", VocabularyLogicTests.testVocabularyLearningStats), ("Personal vocabulary tokenizer and policy", VocabularyLogicTests.testPersonalVocabularyTokenizerAndPolicy), ("Vocabulary answer formatter", VocabularyLogicTests.testVocabularyAnswerFormatter), + ("PDF vocabulary layout hyphen normalization", PDFVocabularyHyphenTests.testLayoutHyphenNormalization), + ("PDF vocabulary split suffix matching", PDFVocabularyHyphenTests.testSplitSuffixPatternFindsWholeWord), ("Recent document sorting/import", ReaderShelfLogicTests.testRecentDocumentSortingAndImport), ("Dropped document actions", ReaderShelfLogicTests.testDroppedDocumentActions), ("Embedding defaults", AISettingsLogicTests.testEmbeddingDefaults), + ("Automatic embedding avoids credential access", AISettingsLogicTests.testAutomaticEmbeddingConfigurationAvoidsCredentialAccess), + ("User data backup round trip", UserDataBackupServiceTests.testRoundTripExcludesCredentialsAndPreservesCurrentKeyReferences), + ("User data backup tamper rejection", UserDataBackupServiceTests.testTamperedPayloadIsRejectedBeforeMutation), + ("User data restore rollback", UserDataBackupServiceTests.testFailedRestoreRollsBackReplacements), + ("User data interrupted restore recovery", UserDataBackupServiceTests.testInterruptedRestoreRecoveryUsesJournal), + ("User data backup symlink rejection", UserDataBackupServiceTests.testBackupRejectsManagedAssetSymlinks), ("Secure credential migration", AISettingsLogicTests.testSecureCredentialStoreRoundTripAndLegacyMigration), ("Secure credential migration failure", AISettingsLogicTests.testCredentialMigrationPreservesLegacyDataOnWriteFailure), ("AI settings injected defaults model selection", AISettingsLogicTests.testAISettingsStoreInjectedDefaultsModelSelection), diff --git a/tests/PDFVocabularyHyphenTests.swift b/tests/PDFVocabularyHyphenTests.swift new file mode 100644 index 0000000..8762461 --- /dev/null +++ b/tests/PDFVocabularyHyphenTests.swift @@ -0,0 +1,41 @@ +import Foundation + +enum PDFVocabularyHyphenTests { + static func testLayoutHyphenNormalization() throws { + try expectEqual( + VocabularyTextPolicy.normalizedPDFVocabularyText( + "Schadenser-satzforderung", + lineBrokenHyphenRange: NSRange(location: 10, length: 1), + isKnownWord: { $0 == "Schadensersatzforderung" } + ), + "Schadensersatzforderung", + "a marked PDF layout hyphen should be removed when the joined word is known" + ) + try expectEqual( + VocabularyTextPolicy.normalizedPDFVocabularyText( + "E-Mail", + lineBrokenHyphenRange: NSRange(location: 1, length: 1), + isKnownHyphenatedWord: { $0 == "E-Mail" } + ), + "E-Mail", + "a genuine known hyphenated word should preserve its hyphen" + ) + } + + static func testSplitSuffixPatternFindsWholeWord() throws { + let sample = "Damit die Nutzung si-\ncherzustellen ist." + let regex = try NSRegularExpression( + pattern: #"(?i)"# + VocabularyTextPolicy.lineBrokenHyphenWordPattern(suffix: "cherzustellen") + ) + let matches = regex.matches( + in: sample, + range: NSRange(location: 0, length: (sample as NSString).length) + ) + try expectEqual(matches.count, 1, "a selection on the second PDF line should find the whole wrapped word") + try expectEqual( + matches[0].range(withName: "layoutHyphen"), + NSRange(location: 20, length: 1), + "the layout hyphen should remain identifiable for normalization" + ) + } +} diff --git a/tests/UserDataBackupServiceTests.swift b/tests/UserDataBackupServiceTests.swift new file mode 100644 index 0000000..9d961f9 --- /dev/null +++ b/tests/UserDataBackupServiceTests.swift @@ -0,0 +1,253 @@ +import Foundation +import SQLite3 + +enum UserDataBackupServiceTests { + private struct Fixture { + let root: URL + let support: URL + let backup: URL + let domain: String + let defaults: UserDefaults + + init() throws { + root = FileManager.default.temporaryDirectory + .appendingPathComponent("LeafReaderBackupTests-\(UUID().uuidString)", isDirectory: true) + support = root.appendingPathComponent("LeafReader", isDirectory: true) + backup = root.appendingPathComponent("snapshot.leafreaderbackup", isDirectory: true) + domain = "LeafReaderBackupTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: domain) else { + throw TestFailure(description: "could not create backup test defaults") + } + self.defaults = defaults + try FileManager.default.createDirectory(at: support, withIntermediateDirectories: true) + } + + func service(checkpoint: ((Int) throws -> Void)? = nil) -> UserDataBackupService { + UserDataBackupService( + configuration: UserDataBackupConfiguration( + applicationSupportDirectory: support, + preferencesDomainName: domain, + applicationBundleIdentifier: "com.linlu.LeafReader.tests", + defaults: defaults + ), + restoreCheckpoint: checkpoint + ) + } + + func cleanUp() { + defaults.removePersistentDomain(forName: domain) + try? FileManager.default.removeItem(at: root) + } + } + + static func testRoundTripExcludesCredentialsAndPreservesCurrentKeyReferences() throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + try seedManagedData(fixture, value: "before") + fixture.defaults.set("before", forKey: "readerTheme") + fixture.defaults.set("backup-secret", forKey: "apiKey.legacy") + fixture.defaults.synchronize() + + let manifest = try fixture.service().createBackup(at: fixture.backup) + try expectEqual(manifest.schemaVersion, 1, "backup format should be versioned") + let preferencesURL = fixture.backup.appendingPathComponent("payload/preferences.plist") + let preferences = try propertyList(at: preferencesURL) + try expect(preferences["apiKey.legacy"] == nil, "backup preferences must exclude legacy API keys") + try expect( + !manifest.entries.contains { $0.relativePath.hasSuffix("-wal") || $0.relativePath.hasSuffix("-shm") }, + "SQLite sidecars should not be copied into a backup" + ) + + try seedManagedData(fixture, value: "after") + fixture.defaults.set("after", forKey: "readerTheme") + fixture.defaults.set("current-secret", forKey: "apiKey.legacy") + fixture.defaults.synchronize() + + let result = try fixture.service().restoreBackup(at: fixture.backup) + try expect(result.requiresRelaunch, "restored stores should be reopened on launch") + try expectEqual(try databaseValue(fixture.support.appendingPathComponent("word-records.sqlite3")), "before", "word records should restore") + try expectEqual(try databaseValue(fixture.support.appendingPathComponent("personal-vocabulary.sqlite3")), "before", "personal vocabulary should restore") + try expectEqual(try databaseValue(fixture.support.appendingPathComponent("reading-notes.sqlite")), "before", "reading notes should restore") + try expectEqual(fixture.defaults.string(forKey: "readerTheme"), "before", "preferences should restore") + try expectEqual(fixture.defaults.string(forKey: "apiKey.legacy"), "current-secret", "restore should preserve current-machine credentials") + let asset = fixture.support.appendingPathComponent("ReadingNoteAssets/note-image.txt") + try expectEqual(try String(contentsOf: asset, encoding: .utf8), "before", "reading-note assets should restore") + } + + static func testTamperedPayloadIsRejectedBeforeMutation() throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + try seedManagedData(fixture, value: "backup") + fixture.defaults.set("backup", forKey: "readerTheme") + _ = try fixture.service().createBackup(at: fixture.backup) + + try seedManagedData(fixture, value: "current") + fixture.defaults.set("current", forKey: "readerTheme") + let asset = fixture.backup.appendingPathComponent("payload/ReadingNoteAssets/note-image.txt") + try Data("tampered".utf8).write(to: asset, options: .atomic) + do { + _ = try fixture.service().restoreBackup(at: fixture.backup) + throw TestFailure(description: "tampered backup should not restore") + } catch is TestFailure { + throw TestFailure(description: "tampered backup should not restore") + } catch { + // Expected validation failure. + } + try expectEqual(try databaseValue(fixture.support.appendingPathComponent("word-records.sqlite3")), "current", "validation failure should preserve databases") + try expectEqual(fixture.defaults.string(forKey: "readerTheme"), "current", "validation failure should preserve preferences") + } + + static func testFailedRestoreRollsBackReplacements() throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + try seedManagedData(fixture, value: "backup") + fixture.defaults.set("backup", forKey: "readerTheme") + _ = try fixture.service().createBackup(at: fixture.backup) + try seedManagedData(fixture, value: "current") + fixture.defaults.set("current", forKey: "readerTheme") + + enum InjectedFailure: Error { case stop } + let service = fixture.service { count in + if count == 1 { throw InjectedFailure.stop } + } + do { + _ = try service.restoreBackup(at: fixture.backup) + throw TestFailure(description: "injected restore failure should surface") + } catch is TestFailure { + throw TestFailure(description: "injected restore failure should surface") + } catch { + // Expected rollback. + } + try expectEqual(try databaseValue(fixture.support.appendingPathComponent("word-records.sqlite3")), "current", "rollback should restore the first replaced database") + try expectEqual(try databaseValue(fixture.support.appendingPathComponent("personal-vocabulary.sqlite3")), "current", "rollback should preserve later databases") + try expectEqual(fixture.defaults.string(forKey: "readerTheme"), "current", "rollback should preserve preferences") + } + + static func testInterruptedRestoreRecoveryUsesJournal() throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + try seedManagedData(fixture, value: "before") + let transaction = fixture.root.appendingPathComponent( + UserDataBackupService.restoreTransactionPrefix + "interrupted", + isDirectory: true + ) + let rollback = transaction.appendingPathComponent("rollback", isDirectory: true) + try FileManager.default.createDirectory(at: rollback, withIntermediateDirectories: true) + let wordDatabase = fixture.support.appendingPathComponent("word-records.sqlite3") + try FileManager.default.moveItem( + at: wordDatabase, + to: rollback.appendingPathComponent("word-records.sqlite3") + ) + try createDatabase(at: wordDatabase, value: "after") + try writePropertyList([:], to: rollback.appendingPathComponent(UserDataBackupService.rollbackPreferencesName)) + let journal = UserDataRestoreJournal( + phase: .applying, + units: [ + .init(name: "word-records.sqlite3", hadOriginal: true, phase: .installed), + .init(name: "personal-vocabulary.sqlite3", hadOriginal: true, phase: .pending), + .init(name: "reading-notes.sqlite", hadOriginal: true, phase: .pending), + .init(name: "ReadingNoteAssets", hadOriginal: true, phase: .pending) + ], + preferencesApplyStarted: false, + preferencesApplied: false + ) + try JSONEncoder().encode(journal).write( + to: transaction.appendingPathComponent(UserDataBackupService.restoreJournalName), + options: .atomic + ) + + try fixture.service().recoverInterruptedRestoreIfNeeded() + try expectEqual(try databaseValue(wordDatabase), "before", "cold-start recovery should restore the original database") + try expect(!FileManager.default.fileExists(atPath: transaction.path), "successful recovery should remove its transaction") + } + + static func testBackupRejectsManagedAssetSymlinks() throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + try seedManagedData(fixture, value: "before") + let assets = fixture.support.appendingPathComponent("ReadingNoteAssets", isDirectory: true) + try FileManager.default.createSymbolicLink( + at: assets.appendingPathComponent("linked-secret.txt"), + withDestinationURL: fixture.root.appendingPathComponent("outside.txt") + ) + do { + _ = try fixture.service().createBackup(at: fixture.backup) + throw TestFailure(description: "asset symlink should be rejected") + } catch is TestFailure { + throw TestFailure(description: "asset symlink should be rejected") + } catch { + // Expected fail-closed behavior. + } + try expect(!FileManager.default.fileExists(atPath: fixture.backup.path), "rejected backup should not be published") + } + + private static func seedManagedData(_ fixture: Fixture, value: String) throws { + for name in UserDataBackupService.databaseNames { + let url = fixture.support.appendingPathComponent(name) + try? FileManager.default.removeItem(at: url) + try createDatabase(at: url, value: value) + } + let assets = fixture.support.appendingPathComponent("ReadingNoteAssets", isDirectory: true) + try? FileManager.default.removeItem(at: assets) + try FileManager.default.createDirectory(at: assets, withIntermediateDirectories: true) + try Data(value.utf8).write(to: assets.appendingPathComponent("note-image.txt"), options: .atomic) + } + + private static func createDatabase(at url: URL, value: String) throws { + var database: OpaquePointer? + guard sqlite3_open(url.path, &database) == SQLITE_OK else { + throw TestFailure(description: "could not create SQLite fixture") + } + defer { sqlite3_close(database) } + guard sqlite3_exec(database, "CREATE TABLE state(value TEXT NOT NULL);", nil, nil, nil) == SQLITE_OK else { + throw TestFailure(description: "could not create SQLite fixture table") + } + var statement: OpaquePointer? + guard sqlite3_prepare_v2(database, "INSERT INTO state(value) VALUES (?)", -1, &statement, nil) == SQLITE_OK else { + throw TestFailure(description: "could not prepare SQLite fixture insert") + } + defer { sqlite3_finalize(statement) } + sqlite3_bind_text(statement, 1, value, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self)) + guard sqlite3_step(statement) == SQLITE_DONE else { + throw TestFailure(description: "could not insert SQLite fixture") + } + } + + private static func databaseValue(_ url: URL) throws -> String { + var database: OpaquePointer? + guard sqlite3_open_v2(url.path, &database, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else { + throw TestFailure(description: "could not open SQLite fixture") + } + defer { sqlite3_close(database) } + var statement: OpaquePointer? + guard sqlite3_prepare_v2(database, "SELECT value FROM state LIMIT 1", -1, &statement, nil) == SQLITE_OK else { + throw TestFailure(description: "could not query SQLite fixture") + } + defer { sqlite3_finalize(statement) } + guard sqlite3_step(statement) == SQLITE_ROW, let value = sqlite3_column_text(statement, 0) else { + throw TestFailure(description: "SQLite fixture has no value") + } + return String(cString: value) + } + + private static func propertyList(at url: URL) throws -> [String: Any] { + let object = try PropertyListSerialization.propertyList( + from: Data(contentsOf: url), + options: [], + format: nil + ) + guard let dictionary = object as? [String: Any] else { + throw TestFailure(description: "fixture property list is not a dictionary") + } + return dictionary + } + + private static func writePropertyList(_ dictionary: [String: Any], to url: URL) throws { + let data = try PropertyListSerialization.data( + fromPropertyList: dictionary, + format: .binary, + options: 0 + ) + try data.write(to: url, options: .atomic) + } +} diff --git a/tests/VocabularyLogicTests.swift b/tests/VocabularyLogicTests.swift index 4e41fc1..348014a 100644 --- a/tests/VocabularyLogicTests.swift +++ b/tests/VocabularyLogicTests.swift @@ -94,7 +94,7 @@ enum VocabularyLogicTests { try expectEqual(VocabularyTextPolicy.normalizedPDFVocabularyText("con-\ntemptuous"), "contemptuous", "PDF line-broken plain words should drop the layout hyphen") try expectEqual(VocabularyTextPolicy.normalizedPDFVocabularyText("Nine-\ntenths"), "Nine-tenths", "PDF line-broken true hyphenated words should keep the hyphen") try expectEqual( - VocabularyTextPolicy.normalizedPDFVocabularyText("fam-\niliar") { $0 == "familiar" }, + VocabularyTextPolicy.normalizedPDFVocabularyText("fam-\niliar", isKnownWord: { $0 == "familiar" }), "familiar", "dictionary-backed PDF normalization should prefer known dehyphenated words" ) diff --git a/tests/run.sh b/tests/run.sh index d21a4a6..9008699 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -15,7 +15,7 @@ LOGIC_APP_SOURCES=() always_include_logic_app_source() { local base="$1" case "$base" in - ReadingNoteEditorViews.swift|SelectionToolbarConfiguration.swift) + EmbeddingClient.swift|ReadingNoteEditorViews.swift|SelectionToolbarConfiguration.swift) return 0 ;; esac @@ -191,6 +191,8 @@ LOGIC_TEST_SOURCES=( tests/SecurityHardeningTests.swift tests/ECDICTLogicTests.swift tests/VocabularyLogicTests.swift + tests/PDFVocabularyHyphenTests.swift + tests/UserDataBackupServiceTests.swift tests/ReaderCoreLogicTests.swift tests/ReaderReadAloudLogicTests.swift tests/LogicTests.swift