feat(persistence): add FileVault with complete file protection

按 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) <noreply@anthropic.com>
This commit is contained in:
link2026
2026-05-25 15:03:15 +08:00
parent 2b6c4b9726
commit d704a9eb78
2 changed files with 120 additions and 0 deletions

View File

@@ -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)
}
}
}