From 0739ccea2b60bc321946cabc0470a7637a6c5a38 Mon Sep 17 00:00:00 2001 From: link2026 Date: Mon, 25 May 2026 15:06:49 +0800 Subject: [PATCH] harden(persistence): FileVault path traversal guard + error unification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 code quality review 反馈(P0 + 4×P1): - 加 resolveSafePath() 拒绝 / 和 .. 并验证 hasPrefix(rootURL) - loadImage/remove 统一抛 FileVaultError(readFailed/removeFailed) - 删除测试 struct 上多余的 @MainActor - 每个 @Test 加 defer cleanup,不泄漏 temp 目录 - 测试图片改用生成 16x16 红色,不依赖 SF Symbol Co-Authored-By: Claude Opus 4.7 (1M context) --- 体己/Persistence/FileVault.swift | 34 ++++++++++++++++++++++++++++---- 体己Tests/FileVaultTests.swift | 21 +++++++++++++++----- 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/体己/Persistence/FileVault.swift b/体己/Persistence/FileVault.swift index 46ae4fd..f11675d 100644 --- a/体己/Persistence/FileVault.swift +++ b/体己/Persistence/FileVault.swift @@ -40,6 +40,23 @@ final class FileVault { let bytes: Int } + // MARK: - Path Safety + + private func resolveSafePath(_ relativePath: String) throws -> URL { + guard !relativePath.contains("/"), + !relativePath.contains(".."), + !relativePath.isEmpty else { + throw FileVaultError.readFailed + } + let url = rootURL.appendingPathComponent(relativePath) + guard url.path.hasPrefix(rootURL.path) else { + throw FileVaultError.readFailed + } + return url + } + + // MARK: - Public API + func writeJPEG(_ image: UIImage, quality: CGFloat = 0.85) throws -> SavedAsset { guard let data = image.jpegData(compressionQuality: quality) else { throw FileVaultError.writeFailed @@ -51,15 +68,24 @@ final class FileVault { } func loadImage(relativePath: String) throws -> UIImage { - let url = rootURL.appendingPathComponent(relativePath) - let data = try Data(contentsOf: url) + let url = try resolveSafePath(relativePath) + let data: Data + do { + data = try Data(contentsOf: url) + } catch { + throw FileVaultError.readFailed + } 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) + let url = try resolveSafePath(relativePath) + do { + try FileManager.default.removeItem(at: url) + } catch { + throw FileVaultError.removeFailed + } } func wipe() throws { diff --git a/体己Tests/FileVaultTests.swift b/体己Tests/FileVaultTests.swift index 44d5ccd..5fac4aa 100644 --- a/体己Tests/FileVaultTests.swift +++ b/体己Tests/FileVaultTests.swift @@ -2,7 +2,6 @@ import Testing import UIKit @testable import 体己 -@MainActor struct FileVaultTests { private func makeIsolatedVault() throws -> FileVault { @@ -11,9 +10,19 @@ struct FileVaultTests { return try FileVault(rootURL: temp) } + private func makeTestImage() -> UIImage { + let size = CGSize(width: 16, height: 16) + let renderer = UIGraphicsImageRenderer(size: size) + return renderer.image { ctx in + UIColor.red.setFill() + ctx.fill(CGRect(origin: .zero, size: size)) + } + } + @Test func writeAndReadJPEGRoundtrip() throws { let vault = try makeIsolatedVault() - let image = UIImage(systemName: "doc")! + defer { try? FileManager.default.removeItem(at: vault.rootURL) } + let image = makeTestImage() let saved = try vault.writeJPEG(image, quality: 0.8) @@ -26,7 +35,8 @@ struct FileVaultTests { @Test func removeMakesFileGone() throws { let vault = try makeIsolatedVault() - let saved = try vault.writeJPEG(UIImage(systemName: "doc")!) + defer { try? FileManager.default.removeItem(at: vault.rootURL) } + let saved = try vault.writeJPEG(makeTestImage()) try vault.remove(relativePath: saved.relativePath) @@ -37,8 +47,9 @@ struct FileVaultTests { @Test func wipeRemovesAllFiles() throws { let vault = try makeIsolatedVault() - let a = try vault.writeJPEG(UIImage(systemName: "doc")!) - let b = try vault.writeJPEG(UIImage(systemName: "folder")!) + defer { try? FileManager.default.removeItem(at: vault.rootURL) } + let a = try vault.writeJPEG(makeTestImage()) + let b = try vault.writeJPEG(makeTestImage()) try vault.wipe()