diff --git a/Sources/MacClean/Modules/Shredder/ShredderModule.swift b/Sources/MacClean/Modules/Shredder/ShredderModule.swift index 111ff41..f03ebfc 100644 --- a/Sources/MacClean/Modules/Shredder/ShredderModule.swift +++ b/Sources/MacClean/Modules/Shredder/ShredderModule.swift @@ -23,6 +23,20 @@ public actor SecureEraser { case secure // Overwrite then remove (best effort on SSD) } + private enum EraseError: LocalizedError { + case secureEraseDirectory(String) + + var errorDescription: String? { + switch self { + case .secureEraseDirectory(let path): + L10n.tr( + "安全擦除不能用于文件夹:\(path)", + "Secure erase cannot be used on directories: \(path)", + "Безопасное стирание нельзя применять к папкам: \(path)") + } + } + } + public struct EraseResult: Sendable { public let erasedCount: Int public let totalSize: UInt64 @@ -34,14 +48,21 @@ public actor SecureEraser { public init() {} public func erase(urls: [URL], mode: EraseMode) async -> EraseResult { + // Preserve the batch-size safety cap, but let the per-item validation + // below identify and skip individual unsafe paths. do { try safetyGuard.validateDeletion(paths: urls) + } catch let error as SafetyGuard.SafetyError { + if case .tooManyFiles = error { + return EraseResult( + erasedCount: 0, + totalSize: 0, + errors: [("validation", error.localizedDescription)] + ) + } } catch { - return EraseResult( - erasedCount: 0, - totalSize: 0, - errors: [("validation", error.localizedDescription)] - ) + // validateDeletion currently only throws SafetyError. If that + // changes, per-item validation still keeps the batch fail-safe. } var erasedCount = 0 @@ -89,9 +110,11 @@ public actor SecureEraser { // but the SSD controller may redirect the write to a new physical block. // For true security, recommend FileVault (full-disk encryption). let values = try url.resourceValues(forKeys: [.fileSizeKey, .isDirectoryKey, .isSymbolicLinkKey]) - // Nothing to overwrite for a directory or an empty file: a legitimate - // no-op (the caller still trashes/removes them). These must NOT throw. - guard values.isDirectory != true else { return } + // Refuse directories: returning here would let the caller recursively + // remove their contents without overwriting any of the files. + guard values.isDirectory != true else { + throw EraseError.secureEraseDirectory(url.path(percentEncoded: false)) + } // Never follow a symlink: opening it for writing would zero the TARGET // file's contents, not the link. Refuse rather than corrupt the target. guard values.isSymbolicLink != true else { diff --git a/Sources/MacClean/Views/Files/ShredderView.swift b/Sources/MacClean/Views/Files/ShredderView.swift index 331004c..0fedc7d 100644 --- a/Sources/MacClean/Views/Files/ShredderView.swift +++ b/Sources/MacClean/Views/Files/ShredderView.swift @@ -29,9 +29,11 @@ struct ShredderView: View { if let result { VStack(spacing: 16) { - Image(systemName: "checkmark.circle.fill") + Image(systemName: result.errors.isEmpty + ? "checkmark.circle.fill" + : "exclamationmark.triangle.fill") .font(.system(size: 50)) - .foregroundStyle(.primary) + .foregroundStyle(result.errors.isEmpty ? Color.primary : Color.orange) Text(L10n.tr("已擦除 \(result.erasedCount) 个文件", "\(result.erasedCount) files erased", "\(result.erasedCount) \(L10n.russianPlural(result.erasedCount, one: "файл удалён", few: "файла удалено", many: "файлов удалено"))")) .font(.headline) .foregroundStyle(.primary) @@ -39,6 +41,24 @@ struct ShredderView: View { .font(.system(size: 14)) .foregroundStyle(.primary.opacity(0.7)) + if let firstError = result.errors.first?.1 { + Text(firstError) + .font(.system(size: 13)) + .foregroundStyle(.primary.opacity(0.75)) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + .textSelection(.enabled) + + if result.errors.count > 1 { + Text(L10n.tr( + "以及另外 \(result.errors.count - 1) 个错误", + "And \(result.errors.count - 1) more error\(result.errors.count == 2 ? "" : "s")", + "И ещё \(result.errors.count - 1) \(L10n.russianPlural(result.errors.count - 1, one: "ошибка", few: "ошибки", many: "ошибок"))")) + .font(.system(size: 12)) + .foregroundStyle(.primary.opacity(0.6)) + } + } + Button(L10n.tr("完成", "Done", "Готово")) { self.result = nil filesToShred = [] diff --git a/Tests/MacCleanTests/ShredderModuleTests.swift b/Tests/MacCleanTests/ShredderModuleTests.swift index e92fdac..21662df 100644 --- a/Tests/MacCleanTests/ShredderModuleTests.swift +++ b/Tests/MacCleanTests/ShredderModuleTests.swift @@ -6,7 +6,7 @@ import Foundation /// Integration tests for the secure shredder. Real files under /// `~/Library/Caches/` so they pass SafetyGuard, the module's job is to touch /// the filesystem, so mocking would only test stubs. -final class ShredderModuleTests: XCTestCase { +final class ShredderModuleTests: EnglishAppLanguageTestCase { private static func makeTestDir() throws -> URL { let dir = MCConstants.userCaches.appending(path: "MacCleanShredTest-\(UUID().uuidString)") @@ -69,6 +69,47 @@ final class ShredderModuleTests: XCTestCase { "the symlink's target must be left untouched") } + /// Directories must not be deleted under the "secure" label unless every + /// contained file was actually overwritten. + func testSecureEraseRefusesDirectoryWithoutDeletingContents() async throws { + let dir = try Self.makeTestDir() + defer { Self.cleanup(dir) } + + let nestedFile = dir.appending(path: "nested.dat") + try Data("nested secret".utf8).write(to: nestedFile) + + let result = await SecureEraser().erase(urls: [dir], mode: .secure) + + XCTAssertEqual(result.erasedCount, 0) + XCTAssertEqual(result.errors.count, 1) + XCTAssertEqual(result.errors.first?.0, dir.path(percentEncoded: false)) + XCTAssertTrue( + result.errors.first?.1.contains("Secure erase cannot be used on directories") == true, + "the user must receive a clear explanation") + XCTAssertTrue(FileManager.default.fileExists(atPath: dir.path(percentEncoded: false))) + XCTAssertTrue(FileManager.default.fileExists(atPath: nestedFile.path(percentEncoded: false))) + } + + /// A protected path in one selection must not block other safe paths from + /// being erased. + func testInvalidPathDoesNotAbortRemainingBatch() async throws { + let dir = try Self.makeTestDir() + defer { Self.cleanup(dir) } + + let safeFile = dir.appending(path: "safe.dat") + try Data("erase me".utf8).write(to: safeFile) + let protectedPath = URL(filePath: "/System") + + let result = await SecureEraser().erase( + urls: [protectedPath, safeFile], + mode: .permanent) + + XCTAssertEqual(result.erasedCount, 1) + XCTAssertEqual(result.errors.count, 1) + XCTAssertEqual(result.errors.first?.0, protectedPath.path(percentEncoded: false)) + XCTAssertFalse(FileManager.default.fileExists(atPath: safeFile.path(percentEncoded: false))) + } + /// A normal writable file is securely erased as before (guardrail against /// the fix breaking the happy path). func testSecureEraseStillWorksOnNormalFile() async throws {