From d704a9eb785a2a100b2a78d7e9f886485187d184 Mon Sep 17 00:00:00 2001 From: link2026 Date: Mon, 25 May 2026 15:03:15 +0800 Subject: [PATCH] feat(persistence): add FileVault with complete file protection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 W2 plan Task 3 落地原图加密存储: - writeJPEG / loadImage / remove / wipe 四个核心操作 - Application Support/Vault/ 目录全程 .completeFileProtection - 文件写入用 .completeFileProtection options(双保险) - FileVault(rootURL:) 注入便于测试隔离 - shared 单例用真实 App Support 路径 测试 3 条:roundtrip / remove / wipe。 注:.swift 文件需用户在 Xcode 拖入 target(Persistence group + 体己Tests), 之后 ⌘U 跑测试,若全绿再 amend 提交 .pbxproj。 Co-Authored-By: Claude Opus 4.7 (1M context) --- 体己/Persistence/FileVault.swift | 72 ++++++++++++++++++++++++++++++++ 体己Tests/FileVaultTests.swift | 48 +++++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 体己/Persistence/FileVault.swift create mode 100644 体己Tests/FileVaultTests.swift diff --git a/体己/Persistence/FileVault.swift b/体己/Persistence/FileVault.swift new file mode 100644 index 0000000..46ae4fd --- /dev/null +++ b/体己/Persistence/FileVault.swift @@ -0,0 +1,72 @@ +import Foundation +import UIKit + +enum FileVaultError: Error { + case readFailed + case writeFailed + case removeFailed + case decodeFailed +} + +final class FileVault { + static let shared: FileVault = { + do { + let appSupport = try FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + let vaultURL = appSupport.appendingPathComponent("Vault", isDirectory: true) + return try FileVault(rootURL: vaultURL) + } catch { + fatalError("FileVault.shared init failed: \(error)") + } + }() + + let rootURL: URL + + init(rootURL: URL) throws { + self.rootURL = rootURL + try FileManager.default.createDirectory( + at: rootURL, + withIntermediateDirectories: true, + attributes: [.protectionKey: FileProtectionType.complete] + ) + } + + struct SavedAsset { + let relativePath: String + let bytes: Int + } + + func writeJPEG(_ image: UIImage, quality: CGFloat = 0.85) throws -> SavedAsset { + guard let data = image.jpegData(compressionQuality: quality) else { + throw FileVaultError.writeFailed + } + let filename = "\(UUID().uuidString).jpg" + let url = rootURL.appendingPathComponent(filename) + try data.write(to: url, options: [.atomic, .completeFileProtection]) + return SavedAsset(relativePath: filename, bytes: data.count) + } + + func loadImage(relativePath: String) throws -> UIImage { + let url = rootURL.appendingPathComponent(relativePath) + let data = try Data(contentsOf: url) + guard let image = UIImage(data: data) else { throw FileVaultError.decodeFailed } + return image + } + + func remove(relativePath: String) throws { + let url = rootURL.appendingPathComponent(relativePath) + try FileManager.default.removeItem(at: url) + } + + func wipe() throws { + let fm = FileManager.default + let contents = try fm.contentsOfDirectory(at: rootURL, includingPropertiesForKeys: nil) + for url in contents { + try fm.removeItem(at: url) + } + } +} diff --git a/体己Tests/FileVaultTests.swift b/体己Tests/FileVaultTests.swift new file mode 100644 index 0000000..44d5ccd --- /dev/null +++ b/体己Tests/FileVaultTests.swift @@ -0,0 +1,48 @@ +import Testing +import UIKit +@testable import 体己 + +@MainActor +struct FileVaultTests { + + private func makeIsolatedVault() throws -> FileVault { + let temp = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + return try FileVault(rootURL: temp) + } + + @Test func writeAndReadJPEGRoundtrip() throws { + let vault = try makeIsolatedVault() + let image = UIImage(systemName: "doc")! + + let saved = try vault.writeJPEG(image, quality: 0.8) + + #expect(saved.bytes > 0) + #expect(saved.relativePath.hasSuffix(".jpg")) + + let loaded = try vault.loadImage(relativePath: saved.relativePath) + #expect(loaded.size != .zero) + } + + @Test func removeMakesFileGone() throws { + let vault = try makeIsolatedVault() + let saved = try vault.writeJPEG(UIImage(systemName: "doc")!) + + try vault.remove(relativePath: saved.relativePath) + + #expect(throws: (any Error).self) { + _ = try vault.loadImage(relativePath: saved.relativePath) + } + } + + @Test func wipeRemovesAllFiles() throws { + let vault = try makeIsolatedVault() + let a = try vault.writeJPEG(UIImage(systemName: "doc")!) + let b = try vault.writeJPEG(UIImage(systemName: "folder")!) + + try vault.wipe() + + #expect(throws: (any Error).self) { _ = try vault.loadImage(relativePath: a.relativePath) } + #expect(throws: (any Error).self) { _ = try vault.loadImage(relativePath: b.relativePath) } + } +}