Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 31 additions & 8 deletions Sources/MacClean/Modules/Shredder/ShredderModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
24 changes: 22 additions & 2 deletions Sources/MacClean/Views/Files/ShredderView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,36 @@ 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)
Text(FileSizeFormatter.format(result.totalSize))
.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 = []
Expand Down
43 changes: 42 additions & 1 deletion Tests/MacCleanTests/ShredderModuleTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down Expand Up @@ -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 {
Expand Down
Loading