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:
72
体己/Persistence/FileVault.swift
Normal file
72
体己/Persistence/FileVault.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
48
体己Tests/FileVaultTests.swift
Normal file
48
体己Tests/FileVaultTests.swift
Normal file
@@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user