From 64e68734b49576df9cdc4872130c91bd5c3f2113 Mon Sep 17 00:00:00 2001 From: Michael Cerisano Date: Fri, 16 Oct 2026 22:52:46 -0400 Subject: [PATCH] Clarify off-site backup evidence states --- .../RemoteBackupDestinationView.swift | 4 +- .../PhotographerJobPresentationTests.swift | 4 +- .../PhotographerJobViewModelTests.swift | 259 +++++++++++++++++- BitMatchTests/PhotographerReportTests.swift | 4 +- .../Models/PhotographerJobPresentation.swift | 6 +- .../ViewModels/PhotographerJobViewModel.swift | 110 ++++++-- docs/GUIDE.md | 2 +- 7 files changed, 364 insertions(+), 25 deletions(-) diff --git a/BitMatch/Views/Photographer/RemoteBackupDestinationView.swift b/BitMatch/Views/Photographer/RemoteBackupDestinationView.swift index 49ee6718..0d14d079 100644 --- a/BitMatch/Views/Photographer/RemoteBackupDestinationView.swift +++ b/BitMatch/Views/Photographer/RemoteBackupDestinationView.swift @@ -52,7 +52,9 @@ struct RemoteBackupDestinationView: View { Text("SFTP · \(profile.username)@\(profile.host) · \(profile.root.description)") .font(DesignSystem.Typography.monoSmall) .foregroundColor(DesignSystem.Colors.textSecondary) - Text(profile.verificationMode == .sha256 ? "Remote SHA-256 verification required." : "Upload-only reports Uploaded · Unverified.") + Text(profile.verificationMode == .sha256 + ? "After upload, BitMatch downloads each file and checks its SHA-256 hash." + : "Upload only: files are not downloaded and checked.") .font(DesignSystem.Typography.caption) .foregroundColor(profile.verificationMode == .sha256 ? DesignSystem.Colors.textTertiary : DesignSystem.Colors.warning) } diff --git a/BitMatchTests/PhotographerJobPresentationTests.swift b/BitMatchTests/PhotographerJobPresentationTests.swift index 91574104..c503ed4a 100644 --- a/BitMatchTests/PhotographerJobPresentationTests.swift +++ b/BitMatchTests/PhotographerJobPresentationTests.swift @@ -119,12 +119,13 @@ struct PhotographerJobPresentationTests { (.queued, .none, "Remote Queued"), (.uploading, .none, "Remote Uploading"), (.uploadedUnverified, .none, "Uploaded · Unverified"), + (.verifying, .none, "Finishing off-site backup"), (.paused, .none, "Remote Paused"), (.conflict, .none, "Remote Conflict"), (.failed, .none, "Remote Failed"), (.verified, .none, "Verification Evidence Missing"), (.verified, .sha256(validDigest), "Remote checksum verified"), - (.verified, .readBackSHA256(validDigest), "Remote readback verified") + (.verified, .readBackSHA256(validDigest), "Uploaded · Downloaded and checked") ] for (state, evidence, expectedTitle) in states { @@ -133,6 +134,7 @@ struct PhotographerJobPresentationTests { evidence: evidence ) #expect(presentation.title == expectedTitle) + if state == .verifying { #expect(!presentation.isFullyBackedUp) } } } diff --git a/BitMatchTests/PhotographerJobViewModelTests.swift b/BitMatchTests/PhotographerJobViewModelTests.swift index 1965aa4a..006f0fe1 100644 --- a/BitMatchTests/PhotographerJobViewModelTests.swift +++ b/BitMatchTests/PhotographerJobViewModelTests.swift @@ -455,10 +455,267 @@ struct PhotographerJobViewModelTests { reopened.refreshRemoteBackupSummary(for: cardID, items: [itemA, itemB]) let readBack = try #require(store.storedJobs.first?.cardIngests.first?.remoteBackupSummaries[targetID]) #expect(readBack.verificationEvidence == .readBackSHA256(entryA.sha256)) - #expect(RemoteBackupStatusPresentation.make(summary: readBack).title == "Remote readback verified") + #expect(RemoteBackupStatusPresentation.make(summary: readBack).title == "Uploaded · Downloaded and checked") #expect(readBack.updatedAt == itemB.updatedAt) } + @Test(arguments: [RemoteBackupState.queued, .uploading, .retrying, .paused, .verifying, .verified]) + func uploadedFileDoesNotHideAnotherFileStillPendingOrVerified(otherState: RemoteBackupState) throws { + let store = InMemoryPhotographerJobStore() + let jobID = UUID() + let cardID = UUID() + let targetID = UUID() + try store.save(summaryJob(id: jobID, cardID: cardID, updatedAt: eventDate)) + let first = RemoteManifestEntry( + id: UUID(), relativePath: try RemoteRelativePath(components: ["A.mov"]), + byteCount: 10, sha256: String(repeating: "a", count: 64) + ) + let second = RemoteManifestEntry( + id: UUID(), relativePath: try RemoteRelativePath(components: ["B.mov"]), + byteCount: 20, sha256: String(repeating: "b", count: 64) + ) + let manifest = try RemoteManifest( + id: UUID(), jobID: jobID, cardIngestID: cardID, + destinationProfileID: targetID, + packageRelativePath: RemoteRelativePath(components: ["Jobs", "Background"]), + entries: [first, second], createdAt: eventDate + ) + try store.save(manifest) + let uploaded = summaryItem( + jobID: jobID, cardID: cardID, targetID: targetID, + manifest: manifest, entry: first, state: .uploadedUnverified, uploadedBytes: 10 + ) + var other = summaryItem( + jobID: jobID, cardID: cardID, targetID: targetID, + manifest: manifest, entry: second, state: otherState, + uploadedBytes: otherState == .verified ? 20 : 0 + ) + if otherState == .verified { other.verificationEvidence = .readBackSHA256(second.sha256) } + + let viewModel = makeViewModel(store: store) + viewModel.refreshRemoteBackupSummary(for: cardID, items: [uploaded, other]) + + let summary = try #require(store.storedJobs.first?.cardIngests.first?.remoteBackupSummaries[targetID]) + #expect(summary.state == (otherState == .verified ? .uploadedUnverified : otherState)) + #expect(summary.verificationEvidence == .none) + #expect(!RemoteBackupStatusPresentation.make(summary: summary).isFullyBackedUp) + #expect(summary.uploadedFileCount < summary.totalFileCount || otherState == .verified) + } + + @Test func verifiedQueueItemWithoutDigestShowsMissingEvidenceInsteadOfQueued() throws { + let store = InMemoryPhotographerJobStore() + let jobID = UUID() + let cardID = UUID() + let targetID = UUID() + try store.save(summaryJob(id: jobID, cardID: cardID, updatedAt: eventDate)) + let entry = RemoteManifestEntry( + id: UUID(), relativePath: try RemoteRelativePath(components: ["A.mov"]), + byteCount: 10, sha256: String(repeating: "a", count: 64) + ) + let manifest = try RemoteManifest( + id: UUID(), jobID: jobID, cardIngestID: cardID, + destinationProfileID: targetID, + packageRelativePath: RemoteRelativePath(components: ["Jobs", "Background"]), + entries: [entry], createdAt: eventDate + ) + try store.save(manifest) + var item = summaryItem( + jobID: jobID, cardID: cardID, targetID: targetID, + manifest: manifest, entry: entry, state: .verified, uploadedBytes: 10 + ) + + let viewModel = makeViewModel(store: store) + viewModel.refreshRemoteBackupSummary(for: cardID, items: [item]) + + let summary = try #require(store.storedJobs.first?.cardIngests.first?.remoteBackupSummaries[targetID]) + let presentation = RemoteBackupStatusPresentation.make(summary: summary) + #expect(summary.state == .verified) + #expect(summary.verificationEvidence == .none) + #expect(presentation.title == "Verification Evidence Missing") + #expect(!presentation.isFullyBackedUp) + + item.verificationEvidence = .readBackSHA256(String(repeating: "b", count: 64)) + viewModel.refreshRemoteBackupSummary(for: cardID, items: [item]) + let mismatch = try #require(store.storedJobs.first?.cardIngests.first?.remoteBackupSummaries[targetID]) + #expect(mismatch.state == .failed) + #expect(mismatch.verificationEvidence == .none) + #expect(mismatch.errorSummary?.contains("does not match") == true) + #expect(!RemoteBackupStatusPresentation.make(summary: mismatch).isFullyBackedUp) + } + + @Test(arguments: [RemoteBackupState.uploadedUnverified, .verified]) + func partialManifestCannotClaimCompletedOffSiteBackup(terminalState: RemoteBackupState) throws { + let store = InMemoryPhotographerJobStore() + let jobID = UUID() + let cardID = UUID() + let targetID = UUID() + try store.save(summaryJob(id: jobID, cardID: cardID, updatedAt: eventDate)) + let first = RemoteManifestEntry( + id: UUID(), relativePath: try RemoteRelativePath(components: ["A.mov"]), + byteCount: 10, sha256: String(repeating: "a", count: 64) + ) + let second = RemoteManifestEntry( + id: UUID(), relativePath: try RemoteRelativePath(components: ["B.mov"]), + byteCount: 20, sha256: String(repeating: "b", count: 64) + ) + let manifest = try RemoteManifest( + id: UUID(), jobID: jobID, cardIngestID: cardID, + destinationProfileID: targetID, + packageRelativePath: RemoteRelativePath(components: ["Jobs", "Background"]), + entries: [first, second], createdAt: eventDate + ) + try store.save(manifest) + var itemA = summaryItem( + jobID: jobID, cardID: cardID, targetID: targetID, + manifest: manifest, entry: first, state: terminalState, uploadedBytes: 10 + ) + if terminalState == .verified { itemA.verificationEvidence = .readBackSHA256(first.sha256) } + let viewModel = makeViewModel(store: store) + + viewModel.refreshRemoteBackupSummary(for: cardID, items: [itemA]) + + let partial = try #require(store.storedJobs.first?.cardIngests.first?.remoteBackupSummaries[targetID]) + #expect(partial.state == .failed) + #expect(partial.totalFileCount == 2) + #expect(partial.uploadedFileCount == 1) + #expect(partial.verificationEvidence == .none) + #expect(partial.errorSummary?.contains("incomplete") == true) + #expect(!RemoteBackupStatusPresentation.make(summary: partial).isFullyBackedUp) + + var itemB = summaryItem( + jobID: jobID, cardID: cardID, targetID: targetID, + manifest: manifest, entry: second, state: terminalState, uploadedBytes: 20 + ) + if terminalState == .verified { itemB.verificationEvidence = .readBackSHA256(second.sha256) } + viewModel.refreshRemoteBackupSummary(for: cardID, items: [itemA, itemB]) + + let complete = try #require(store.storedJobs.first?.cardIngests.first?.remoteBackupSummaries[targetID]) + #expect(complete.state == terminalState) + #expect(complete.totalFileCount == 2) + #expect(complete.uploadedFileCount == 2) + #expect(complete.errorSummary == nil) + #expect(RemoteBackupStatusPresentation.make(summary: complete).title == + (terminalState == .verified ? "Uploaded · Downloaded and checked" : "Uploaded · Unverified")) + #expect(RemoteBackupStatusPresentation.make(summary: complete).isFullyBackedUp == (terminalState == .verified)) + } + + @Test(arguments: [RemoteBackupState.uploadedUnverified, .verified], [Int64(-1), 0, 9, 11]) + func terminalRemoteByteCountMustMatchManifest(terminalState: RemoteBackupState, recordedBytes: Int64) throws { + let store = InMemoryPhotographerJobStore() + let jobID = UUID() + let cardID = UUID() + let targetID = UUID() + try store.save(summaryJob(id: jobID, cardID: cardID, updatedAt: eventDate)) + let entry = RemoteManifestEntry( + id: UUID(), relativePath: try RemoteRelativePath(components: ["A.mov"]), + byteCount: 10, sha256: String(repeating: "a", count: 64) + ) + let manifest = try RemoteManifest( + id: UUID(), jobID: jobID, cardIngestID: cardID, + destinationProfileID: targetID, + packageRelativePath: RemoteRelativePath(components: ["Jobs", "Background"]), + entries: [entry], createdAt: eventDate + ) + try store.save(manifest) + var item = summaryItem( + jobID: jobID, cardID: cardID, targetID: targetID, + manifest: manifest, entry: entry, state: terminalState, uploadedBytes: recordedBytes + ) + if terminalState == .verified { item.verificationEvidence = .readBackSHA256(entry.sha256) } + let viewModel = makeViewModel(store: store) + + viewModel.refreshRemoteBackupSummary(for: cardID, items: [item]) + + let inconsistent = try #require(store.storedJobs.first?.cardIngests.first?.remoteBackupSummaries[targetID]) + #expect(inconsistent.state == .failed) + #expect(inconsistent.uploadedFileCount == 0) + #expect(inconsistent.verificationEvidence == .none) + #expect(inconsistent.errorSummary?.contains("inconsistent") == true) + #expect(!RemoteBackupStatusPresentation.make(summary: inconsistent).isFullyBackedUp) + + item.uploadedByteCount = entry.byteCount + viewModel.refreshRemoteBackupSummary(for: cardID, items: [item]) + + let consistent = try #require(store.storedJobs.first?.cardIngests.first?.remoteBackupSummaries[targetID]) + #expect(consistent.state == terminalState) + #expect(consistent.uploadedFileCount == 1) + #expect(consistent.errorSummary == nil) + #expect(RemoteBackupStatusPresentation.make(summary: consistent).isFullyBackedUp == (terminalState == .verified)) + } + + @Test func zeroByteRemoteReadbackRemainsValid() throws { + let store = InMemoryPhotographerJobStore() + let jobID = UUID() + let cardID = UUID() + let targetID = UUID() + try store.save(summaryJob(id: jobID, cardID: cardID, updatedAt: eventDate)) + let entry = RemoteManifestEntry( + id: UUID(), relativePath: try RemoteRelativePath(components: ["empty.txt"]), + byteCount: 0, sha256: String(repeating: "a", count: 64) + ) + let manifest = try RemoteManifest( + id: UUID(), jobID: jobID, cardIngestID: cardID, + destinationProfileID: targetID, + packageRelativePath: RemoteRelativePath(components: ["Jobs", "Background"]), + entries: [entry], createdAt: eventDate + ) + try store.save(manifest) + var item = summaryItem( + jobID: jobID, cardID: cardID, targetID: targetID, + manifest: manifest, entry: entry, state: .verified, uploadedBytes: 0 + ) + item.verificationEvidence = .readBackSHA256(entry.sha256) + + makeViewModel(store: store).refreshRemoteBackupSummary(for: cardID, items: [item]) + + let summary = try #require(store.storedJobs.first?.cardIngests.first?.remoteBackupSummaries[targetID]) + #expect(summary.state == .verified) + #expect(summary.totalByteCount == 0) + #expect(RemoteBackupStatusPresentation.make(summary: summary).isFullyBackedUp) + } + + @Test func overflowingPersistedRemoteTotalsFailClosedWithoutTrapping() throws { + let store = InMemoryPhotographerJobStore() + let jobID = UUID() + let cardID = UUID() + let targetID = UUID() + try store.save(summaryJob(id: jobID, cardID: cardID, updatedAt: eventDate)) + let first = RemoteManifestEntry( + id: UUID(), relativePath: try RemoteRelativePath(components: ["A.mov"]), + byteCount: Int64.max, sha256: String(repeating: "a", count: 64) + ) + let second = RemoteManifestEntry( + id: UUID(), relativePath: try RemoteRelativePath(components: ["B.mov"]), + byteCount: 1, sha256: String(repeating: "b", count: 64) + ) + let manifest = try RemoteManifest( + id: UUID(), jobID: jobID, cardIngestID: cardID, + destinationProfileID: targetID, + packageRelativePath: RemoteRelativePath(components: ["Jobs", "Background"]), + entries: [first, second], createdAt: eventDate + ) + try store.save(manifest) + var itemA = summaryItem( + jobID: jobID, cardID: cardID, targetID: targetID, + manifest: manifest, entry: first, state: .verified, uploadedBytes: Int64.max + ) + itemA.verificationEvidence = .readBackSHA256(first.sha256) + var itemB = summaryItem( + jobID: jobID, cardID: cardID, targetID: targetID, + manifest: manifest, entry: second, state: .verified, uploadedBytes: 1 + ) + itemB.verificationEvidence = .readBackSHA256(second.sha256) + + makeViewModel(store: store).refreshRemoteBackupSummary(for: cardID, items: [itemA, itemB]) + + let summary = try #require(store.storedJobs.first?.cardIngests.first?.remoteBackupSummaries[targetID]) + #expect(summary.state == .failed) + #expect(summary.totalByteCount == Int64.max) + #expect(summary.uploadedByteCount == Int64.max) + #expect(summary.errorSummary?.contains("inconsistent") == true) + #expect(!RemoteBackupStatusPresentation.make(summary: summary).isFullyBackedUp) + } + @Test func terminalCardsCannotBeginAgainAndRequireNextCardSetup() throws { let viewModel = preparedViewModel(store: InMemoryPhotographerJobStore()) _ = viewModel.beginIngest(destinationCount: 2) diff --git a/BitMatchTests/PhotographerReportTests.swift b/BitMatchTests/PhotographerReportTests.swift index 20341b4c..042f5d42 100644 --- a/BitMatchTests/PhotographerReportTests.swift +++ b/BitMatchTests/PhotographerReportTests.swift @@ -313,9 +313,11 @@ struct PhotographerReportTests { let payload = try PhotographerReportPayload.make(context: context, results: rows) #expect(payload.isLocallySafe == (kind == "valid")) #expect(payload.fullyBackedUpAt == (kind == "valid" ? locallySafeAt : nil)) - #expect(payload.remoteBackupEvidence.first?.status == "Remote readback verified") + #expect(payload.remoteBackupEvidence.first?.status == "Uploaded · Downloaded and checked") + #expect(String(decoding: try JSONEncoder().encode(payload), as: UTF8.self).contains("Uploaded · Downloaded and checked")) let csv = try ReportExporter.projectCSVEvidence(context: context, results: rows) #expect(csv.summaryRows.contains(["Fully Backed Up", kind == "valid" ? locallySafeAt.ISO8601Format() : "—"])) + #expect(csv.summaryRows.contains(where: { $0.first == "Off-site Backup" && $0.dropFirst().first == "Uploaded · Downloaded and checked" })) } @Test func unverifiedRemoteEvidenceKeepsLocalSafetyWithoutFullyBackedUpTimestamp() throws { diff --git a/Shared/Core/Models/PhotographerJobPresentation.swift b/Shared/Core/Models/PhotographerJobPresentation.swift index 97fa1e71..e6be2acd 100644 --- a/Shared/Core/Models/PhotographerJobPresentation.swift +++ b/Shared/Core/Models/PhotographerJobPresentation.swift @@ -169,13 +169,15 @@ struct RemoteBackupStatusPresentation: Equatable, Sendable { case .uploadedUnverified: return Self(title: "Uploaded · Unverified", symbol: "exclamationmark.shield", isFullyBackedUp: false, isWarning: true, state: state) case .verifying: - return Self(title: "Remote Verifying", symbol: "checkmark.shield", isFullyBackedUp: false, isWarning: false, state: state) + // The queue also uses this state while publishing an upload-only + // file. Do not imply a remote readback has begun in that mode. + return Self(title: "Finishing off-site backup", symbol: "arrow.up.circle", isFullyBackedUp: false, isWarning: false, state: state) case .verified where evidence.digest != nil: // This value describes one remote destination, not source health // or a whole-card safe-to-erase verdict. Preserve older server-side // evidence without claiming it was a downloaded readback. let title: String - if case .readBackSHA256 = evidence { title = "Remote readback verified" } + if case .readBackSHA256 = evidence { title = "Uploaded · Downloaded and checked" } else { title = "Remote checksum verified" } return Self(title: title, symbol: "checkmark.icloud.fill", isFullyBackedUp: true, isWarning: false, state: state) case .verified: diff --git a/Shared/Core/ViewModels/PhotographerJobViewModel.swift b/Shared/Core/ViewModels/PhotographerJobViewModel.swift index 8865364f..54205314 100644 --- a/Shared/Core/ViewModels/PhotographerJobViewModel.swift +++ b/Shared/Core/ViewModels/PhotographerJobViewModel.swift @@ -742,27 +742,102 @@ final class PhotographerJobViewModel: ObservableObject { } return $0.id.uuidString < $1.id.uuidString } - let totalBytes = orderedItems.reduce(Int64(0)) { $0 + $1.uploadedByteCount } - let expectedBytes = try orderedItems.reduce(Int64(0)) { (total, item) throws -> Int64 in - total + (try remoteManifestEntryByteCount(for: item)) + var totalBytes: Int64 = 0 + var hasValidUploadedCounts = true + for item in orderedItems { + guard item.uploadedByteCount >= 0 else { + hasValidUploadedCounts = false + continue + } + let (sum, overflow) = totalBytes.addingReportingOverflow(item.uploadedByteCount) + if overflow { hasValidUploadedCounts = false; totalBytes = Int64.max } + else { totalBytes = sum } + } + let savedManifests = try store.manifests() + let itemsByManifest = Dictionary(grouping: orderedItems, by: \.manifestID) + var expectedFileCount = 0 + var expectedBytes: Int64 = 0 + var expectedEntryByItemID: [UUID: RemoteManifestEntry] = [:] + var hasCompleteManifestCoverage = hasValidUploadedCounts + for (manifestID, manifestItems) in itemsByManifest { + let matches = savedManifests.filter { $0.id == manifestID } + guard matches.count == 1, let manifest = matches.first, + manifest.jobID == manifestItems[0].jobID, + manifest.cardIngestID == manifestItems[0].cardIngestID, + manifest.destinationProfileID == targetID, + manifest.pendingCleanupBookmarkReference == nil else { + hasCompleteManifestCoverage = false + expectedFileCount += manifestItems.count + continue + } + expectedFileCount += manifest.entries.count + for entry in manifest.entries { + guard entry.byteCount >= 0 else { + hasCompleteManifestCoverage = false + continue + } + let (sum, overflow) = expectedBytes.addingReportingOverflow(entry.byteCount) + if overflow { hasCompleteManifestCoverage = false; expectedBytes = Int64.max } + else { expectedBytes = sum } + } + let entriesByID = Dictionary(manifest.entries.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + let itemEntryIDs = Set(manifestItems.map(\.manifestEntryID)) + if entriesByID.count != manifest.entries.count + || Set(manifestItems.map(\.id)).count != manifestItems.count + || manifestItems.count != manifest.entries.count + || itemEntryIDs != Set(entriesByID.keys) { + hasCompleteManifestCoverage = false + } + for item in manifestItems { + guard let entry = entriesByID[item.manifestEntryID], + entry.relativePath == item.localArtifactRelativePath, + (try? manifest.packageRelativePath.appending(path: entry.relativePath)) == item.remoteRelativePath else { + hasCompleteManifestCoverage = false + continue + } + if (item.state == .verified || item.state == .uploadedUnverified) + && item.uploadedByteCount != entry.byteCount { + hasCompleteManifestCoverage = false + } + expectedEntryByItemID[item.id] = entry + } } // A card is fully backed up only when every manifest entry has durable // verified evidence. Never let the first item conceal a later conflict, // failure, or upload-only terminal state. let state: RemoteBackupState - let allVerified = orderedItems.allSatisfy { - $0.state == .verified && RemoteBackupStatusPresentation.make(state: $0.state, evidence: $0.verificationEvidence).isFullyBackedUp + let allVerified = hasCompleteManifestCoverage && orderedItems.allSatisfy { item in + guard item.state == .verified, + let entry = expectedEntryByItemID[item.id], + item.uploadedByteCount == entry.byteCount, + let digest = item.verificationEvidence.digest else { return false } + return digest.caseInsensitiveCompare(entry.sha256) == .orderedSame + } + let hasMismatchedDigest = orderedItems.contains { item in + guard item.state == .verified, + let entry = expectedEntryByItemID[item.id], + let digest = item.verificationEvidence.digest else { return false } + return digest.caseInsensitiveCompare(entry.sha256) != .orderedSame } if allVerified { state = .verified } else if orderedItems.contains(where: { $0.state == .conflict }) { state = .conflict } else if orderedItems.contains(where: { $0.state == .failed }) { state = .failed } else if orderedItems.contains(where: { $0.state == .cancelled }) { state = .cancelled } - else if orderedItems.contains(where: { $0.state == .uploadedUnverified }) { state = .uploadedUnverified } + else if hasMismatchedDigest { state = .failed } + else if !hasCompleteManifestCoverage { state = .failed } else if orderedItems.contains(where: { $0.state == .verifying }) { state = .verifying } else if orderedItems.contains(where: { $0.state == .paused }) { state = .paused } else if orderedItems.contains(where: { $0.state == .retrying }) { state = .retrying } else if orderedItems.contains(where: { $0.state == .uploading }) { state = .uploading } - else { state = .queued } + else if orderedItems.contains(where: { $0.state == .queued }) { state = .queued } + // Only report an upload-only target as uploaded after every file has + // reached a terminal upload state. A prior file must not hide pending + // work on another file in the same target. + else if orderedItems.contains(where: { $0.state == .uploadedUnverified }) { state = .uploadedUnverified } + // A persisted verified state without matching digest evidence remains + // visible as missing evidence, never as a successful backup or a new + // queued transfer. + else { state = .verified } let verificationEvidence: RemoteVerificationEvidence if allVerified { // A card-level readback claim requires readback evidence for every @@ -779,27 +854,26 @@ final class PhotographerJobViewModel: ObservableObject { return RemoteBackupCardSummary( targetID: targetID, state: state, - totalFileCount: orderedItems.count, - uploadedFileCount: orderedItems.filter { $0.state == .uploadedUnverified || $0.state == .verified }.count, + totalFileCount: expectedFileCount, + uploadedFileCount: Set(orderedItems.compactMap { item -> UUID? in + guard item.state == .uploadedUnverified || item.state == .verified, + let entry = expectedEntryByItemID[item.id], entry.byteCount >= 0, + item.uploadedByteCount == entry.byteCount else { return nil } + return item.manifestEntryID + }).count, totalByteCount: expectedBytes, uploadedByteCount: totalBytes, verificationEvidence: verificationEvidence, remotePath: orderedItems.first?.remoteRelativePath, - errorSummary: orderedItems.compactMap(\.errorSummary).first, + errorSummary: orderedItems.compactMap(\.errorSummary).first + ?? (hasMismatchedDigest ? "Remote verification evidence does not match the saved manifest." : nil) + ?? (hasCompleteManifestCoverage ? nil : "Off-site file evidence is incomplete or inconsistent. Queue a new backup."), // Refreshing saved queue items (including on launch) is not a new // verification event. Keep the durable item's latest event time. updatedAt: orderedItems.map(\.updatedAt).max() ) } - private func remoteManifestEntryByteCount(for item: RemoteQueueItem) throws -> Int64 { - guard let manifest = try store.manifests().first(where: { $0.id == item.manifestID }), - let entry = manifest.entries.first(where: { $0.id == item.manifestEntryID }) else { - throw ProjectRemoteCoordinatorError.manifestUnavailable - } - return entry.byteCount - } - private func replaceLoadedJob(_ updatedJob: PhotographerJob) { if let index = jobs.firstIndex(where: { $0.id == updatedJob.id }) { jobs[index] = updatedJob diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 7ead5aad..fd878f7b 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -82,7 +82,7 @@ A few things to know: - The server needs SFTP **and SSH shell access**, with standard file utilities and hard-link support. An SFTP-only account won't do. - **SHA-256 read-back** downloads the remote file to check it. That costs extra bandwidth and local temporary space. - **Upload only** means **Uploaded · Unverified**. Getting it onto the server isn't proof that the contents match. -- **Remote readback verified** describes the remote file matching the verified local backup. Older server-checksum evidence says **Remote checksum verified**. Neither label establishes a new source-card reread or a whole-card safe-to-erase verdict; the local transfer retains its own verification mode, coverage and outcome. +- **Uploaded · Downloaded and checked** means BitMatch downloaded the remote file and confirmed it matches the verified local backup. Older server-checksum evidence says **Remote checksum verified**. Neither label establishes a new source-card reread or a whole-card safe-to-erase verdict; the local transfer retains its own verification mode, coverage and outcome. - iPad can keep the project's remote settings, but uploads run on Mac. S3 and WebDAV aren't available yet. You can ignore SFTP entirely and keep everything local. No app analytics uploads. If you set up remote backup, your selected data goes to the server you chose.