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
2 changes: 1 addition & 1 deletion BitMatchTests/SafetyValidatorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ final class SafetyValidatorTests: XCTestCase {
guard case FileOperationError.unsafeOperation(let message) = error else {
return XCTFail("Expected unsafeOperation, got \(error)")
}
XCTAssertTrue(message.contains("collide on case-insensitive filesystems"))
XCTAssertTrue(message.contains("differ only by case or Unicode form"))
}
}

Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

- Fixed crashes caused by Unicode-equivalent filenames. Conflicting source names are refused before copying; conflicting existing backup names prevent a successful result.

- Check no longer reports preserved Finder metadata as missing when it is listed in the saved checksums.
- Mac: Corrected physical-drive checks for selected subfolders, so two folders on one drive count as one backup.
- Cleaned up long names and page breaks in PDFs; metadata companions no longer inflate the clip-preview count. Job details now share the page footer instead of creating a nearly empty last page.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -428,9 +428,11 @@ public final class SafetyValidator {
.precomposedStringWithCanonicalMapping
.lowercased(with: locale)

if let original = seen[portableKey], original != relativePath {
// Swift String equality includes canonical equivalence. Two distinct
// directory entries (for example K and K on HFS+) can compare equal.
if let original = seen[portableKey] {
throw FileOperationError.unsafeOperation(
"Source contains paths that collide on case-insensitive filesystems: \(original) and \(relativePath)"
"Source contains names that differ only by case or Unicode form: \(original) and \(relativePath)"
)
}
seen[portableKey] = relativePath
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,18 @@ private func completionGateError(_ message: String) -> NSError {
)
}

func uniqueDestinationMetadata(_ entries: [FileEntry]) throws -> [String: FileEntry] {
var byPath: [String: FileEntry] = [:]
for entry in entries {
// Some filesystems allow distinct names that Swift compares as equal.
// Never trap or discard an entry while checking authoritative results.
guard byPath.updateValue(entry, forKey: entry.relativePath) == nil else {
throw completionGateError("Destination contains identical or Unicode-equivalent names. Keep the source media.")
}
}
return byPath
}

/// Owns the single operation admitted by a service instance.
/// Cancellation never releases the slot; only the matching operation's exit does.
public final class ActiveOperationRegistry: Sendable {
Expand Down Expand Up @@ -499,6 +511,9 @@ public final class TransferPipeline: FileOperationsService, Sendable {
// Step 2: Build one fail-closed source manifest before safety validation.
SharedLogger.debug("Prep: enumerating source manifest at \(operation.sourceURL.path)", category: .transfer)
let completeSourceManifest = try CardSource.enumerateRegularFiles(base: operation.sourceURL)
// Validate these captured paths before exclusions or String-keyed maps.
// A later live scan cannot prove this manifest has unambiguous names.
try SafetyValidator.validatePortableRelativePaths(completeSourceManifest.map(\.relativePath))
let excluded = try AppleDoubleSelection.excluded(in: completeSourceManifest, reviewedPaths: operation.settings.excludedAppleDoublePaths)
if !excluded.isEmpty {
SharedLogger.transferEvent(.appleDoubleExcluded, run: TransferDiagnostics.runID ?? operation.id, code: excluded.count)
Expand Down Expand Up @@ -1059,9 +1074,7 @@ public final class TransferPipeline: FileOperationsService, Sendable {
guard let pinned = pinnedDestinations[destIndex] else { continue }
let actual: [String: FileEntry]
do {
actual = Dictionary(
uniqueKeysWithValues: try pinned.regularFileMetadata().map { ($0.relativePath, $0) }
)
actual = try uniqueDestinationMetadata(pinned.regularFileMetadata())
} catch {
let reason = "\(destinationURL.lastPathComponent)'s files could not be checked after verification: \(error.localizedDescription)"
let failure = completionGateError(reason)
Expand Down
Loading
Loading