diff --git a/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/ConversionTypes.swift b/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/ConversionTypes.swift index 8f226ad..10d04d5 100644 --- a/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/ConversionTypes.swift +++ b/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/ConversionTypes.swift @@ -238,13 +238,18 @@ public struct DryRunFingerprint: Equatable, Sendable { /// explicit new-export authorization and is protected by the CLI's /// `--expected-target-absent` precondition instead. public let targetSHA256: String? + /// Hash of the exact bytes the read-only converter preview said it would + /// install. A later `written` report must echo this value as `output`. + public let outputSHA256: String public init( sourceSHA256: String, - targetSHA256: String? + targetSHA256: String?, + outputSHA256: String ) { self.sourceSHA256 = sourceSHA256 self.targetSHA256 = targetSHA256 + self.outputSHA256 = outputSHA256 } public var exportsNewTarget: Bool { @@ -260,6 +265,7 @@ public struct RepairDryRunFingerprint: Equatable, Sendable { public let sourceSetSHA256: String public let currentSetSHA256: String public let previewSHA256: String + public let components: [RepairComponentFingerprint] public init( source: URL, @@ -268,7 +274,8 @@ public struct RepairDryRunFingerprint: Equatable, Sendable { fromVersion: HistoricalConverterRevision?, sourceSetSHA256: String, currentSetSHA256: String, - previewSHA256: String + previewSHA256: String, + components: [RepairComponentFingerprint] ) { self.source = source.standardizedFileURL self.current = current.standardizedFileURL @@ -277,9 +284,19 @@ public struct RepairDryRunFingerprint: Equatable, Sendable { self.sourceSetSHA256 = sourceSetSHA256 self.currentSetSHA256 = currentSetSHA256 self.previewSHA256 = previewSHA256 + self.components = components } } +public struct RepairComponentFingerprint: Equatable, Sendable { + public let component: String + public let target: URL + public let sourceSHA256: String + public let currentSHA256: String + public let mergedSHA256: String + public let modified: Bool +} + /// `system` is a distinct 3DS/Wii U file pair, so it must retain its own /// authorization instead of borrowing the selected `user#` slot fingerprint. public struct SystemDryRunFingerprint: Equatable, Sendable { @@ -287,12 +304,20 @@ public struct SystemDryRunFingerprint: Equatable, Sendable { public let target: URL public let sourceSHA256: String public let targetSHA256: String + public let outputSHA256: String - public init(source: URL, target: URL, sourceSHA256: String, targetSHA256: String) { + public init( + source: URL, + target: URL, + sourceSHA256: String, + targetSHA256: String, + outputSHA256: String + ) { self.source = source.standardizedFileURL self.target = target.standardizedFileURL self.sourceSHA256 = sourceSHA256 self.targetSHA256 = targetSHA256 + self.outputSHA256 = outputSHA256 } } @@ -342,22 +367,34 @@ public struct ExtrasInstallDryRunFingerprint: Equatable, Sendable { public let groups: Set public let stagingSetSHA256: String public let targetSetSHA256: String + public let entries: [ExtraInstallEntryFingerprint] public init( stagingDirectory: URL, targetDirectory: URL, groups: Set, stagingSetSHA256: String, - targetSetSHA256: String + targetSetSHA256: String, + entries: [ExtraInstallEntryFingerprint] ) { self.stagingDirectory = stagingDirectory.standardizedFileURL self.targetDirectory = targetDirectory.standardizedFileURL self.groups = groups self.stagingSetSHA256 = stagingSetSHA256 self.targetSetSHA256 = targetSetSHA256 + self.entries = entries } } +public struct ExtraInstallEntryFingerprint: Equatable, Sendable { + public let group: ExtraGroup + public let component: String + public let target: URL + public let beforeSHA256: String? + public let afterSHA256: String + public let targetPreviouslyExisted: Bool +} + /// CEC is a mailbox directory plus a separate Cemu cache, not a `user#` /// component. Its Dry Run therefore records the complete received record-set /// fingerprint and cache fingerprint independently from the core-slot @@ -367,17 +404,23 @@ public struct CECDryRunFingerprint: Equatable, Sendable { public let target: URL public let sourceRecordSetSHA256: String public let targetSHA256Before: String + public let targetSHA256After: String + public let targetExisted: Bool public init( sourceDirectory: URL, target: URL, sourceRecordSetSHA256: String, - targetSHA256Before: String + targetSHA256Before: String, + targetSHA256After: String, + targetExisted: Bool ) { self.sourceDirectory = sourceDirectory.standardizedFileURL self.target = target.standardizedFileURL self.sourceRecordSetSHA256 = sourceRecordSetSHA256 self.targetSHA256Before = targetSHA256Before + self.targetSHA256After = targetSHA256After + self.targetExisted = targetExisted } } @@ -467,6 +510,15 @@ public struct ConverterReport: Decodable, Sendable { public let detection: ConverterRevisionDetection? public let manifests: [String]? public let compatibilityManifest: String? + public let sourceDirectory: String? + public let outputDirectory: String? + public let stagingDirectory: String? + public let targetDirectory: String? + public let source: String? + public let current: String? + public let target: String? + public let entries: [ConverterExtraInstallEntry]? + public let backupPaths: [String]? public let output: String? public let backup: String? public let manifest: String? @@ -474,7 +526,13 @@ public struct ConverterReport: Decodable, Sendable { enum CodingKeys: String, CodingKey { case operation, status, profile, size, hashes, output, backup, manifest, stderr, components, groups, manifests, detection + case source, current, target, entries case compatibilityManifest = "compatibility_manifest" + case sourceDirectory = "source_dir" + case outputDirectory = "output_dir" + case stagingDirectory = "staging_dir" + case targetDirectory = "target_dir" + case backupPaths = "backup_paths" case sourceSHA256 = "source_sha256" case targetSHA256Before = "target_sha256_before" case targetSHA256After = "target_sha256_after" @@ -502,9 +560,14 @@ public struct ConverterExtraComponent: Decodable, Sendable { public let sourceSHA256: String? public let outputSHA256: String? public let detection: ConverterRevisionDetection? + public let output: String? + public let size: Int? + public let target: String? + public let modified: Bool? + public let merge: ConverterCompatibilityMerge? enum CodingKeys: String, CodingKey { - case component, detection + case component, detection, output, size, target, modified, merge case sourceSHA256 = "source_sha256" case outputSHA256 = "output_sha256" } @@ -517,6 +580,67 @@ public struct ConverterExtraComponent: Decodable, Sendable { outputSHA256: outputSHA256 ) } + + public func repairFingerprint() -> RepairComponentFingerprint? { + guard let target, + let modified, + let merge, + merge.component == component, + ConverterEvidence.isValidSHA256(merge.sourceSHA256), + ConverterEvidence.isValidSHA256(merge.currentSHA256), + ConverterEvidence.isValidSHA256(merge.mergedSHA256) + else { return nil } + return RepairComponentFingerprint( + component: component, + target: URL(fileURLWithPath: target).standardizedFileURL, + sourceSHA256: merge.sourceSHA256, + currentSHA256: merge.currentSHA256, + mergedSHA256: merge.mergedSHA256, + modified: modified + ) + } +} + +public struct ConverterCompatibilityMerge: Decodable, Sendable { + public let component: String + public let sourceSHA256: String + public let currentSHA256: String + public let mergedSHA256: String + + enum CodingKeys: String, CodingKey { + case component + case sourceSHA256 = "source_sha256" + case currentSHA256 = "current_sha256" + case mergedSHA256 = "merged_sha256" + } +} + +public struct ConverterExtraInstallEntry: Decodable, Sendable { + public let group: ExtraGroup + public let component: String + public let target: String + public let beforeSHA256: String? + public let afterSHA256: String + public let backup: String? + public let targetPreviouslyExisted: Bool + + enum CodingKeys: String, CodingKey { + case group, component, target, backup + case beforeSHA256 = "before_sha256" + case afterSHA256 = "after_sha256" + case targetPreviouslyExisted = "target_previously_existed" + } + + public func fingerprint() -> ExtraInstallEntryFingerprint { + ExtraInstallEntryFingerprint( + group: group, + component: component, + target: URL(fileURLWithPath: target).standardizedFileURL, + beforeSHA256: beforeSHA256, + afterSHA256: afterSHA256, + targetPreviouslyExisted: targetPreviouslyExisted + ) + } } public struct ConverterRevisionDetection: Decodable, Sendable { diff --git a/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/ConversionWorkflow.swift b/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/ConversionWorkflow.swift index 683f454..56ac462 100644 --- a/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/ConversionWorkflow.swift +++ b/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/ConversionWorkflow.swift @@ -102,7 +102,8 @@ public final class ConversionWorkflow { let authorized = dryRunFingerprint, let current = currentFingerprint() else { return false } - return authorized == current + return authorized.sourceSHA256 == current.sourceSHA256 + && authorized.targetSHA256 == current.targetSHA256 } public var canWriteCEC: Bool { @@ -296,11 +297,15 @@ public final class ConversionWorkflow { ) guard report.status == "dry-run", let sourceSetSHA256 = report.sourceSetSHA256, + ConverterEvidence.isValidSHA256(sourceSetSHA256), let currentSetSHA256 = report.currentSetSHA256, + ConverterEvidence.isValidSHA256(currentSetSHA256), let previewSHA256 = report.previewSHA256, + ConverterEvidence.isValidSHA256(previewSHA256), let detection = report.detection, let repairComponents = report.components, - !repairComponents.isEmpty + !repairComponents.isEmpty, + repairComponents.allSatisfy({ $0.repairFingerprint() != nil }) else { throw self.failureAndRethrow( .repairConverted, @@ -324,7 +329,8 @@ public final class ConversionWorkflow { fromVersion: self.repairFromVersion, sourceSetSHA256: sourceSetSHA256, currentSetSHA256: currentSetSHA256, - previewSHA256: previewSHA256 + previewSHA256: previewSHA256, + components: repairComponents.compactMap { $0.repairFingerprint() } ) self.latestReport = report self.state = .dryRun @@ -348,10 +354,14 @@ public final class ConversionWorkflow { ) } let current = try self.requireCurrentFingerprint() - guard let reportedSource = report.hash(named: "source") else { + guard let reportedSource = report.hash(named: "source"), + ConverterEvidence.isValidSHA256(reportedSource), + let reportedOutput = report.hash(named: "output"), + ConverterEvidence.isValidSHA256(reportedOutput) + else { throw self.failureAndRethrow( .convert, - ConversionWorkflowError.invalidReport("Dry Run requires a source SHA-256"), + ConversionWorkflowError.invalidReport("Dry Run requires valid source and output SHA-256"), stderr: report.stderr ?? "" ) } @@ -364,7 +374,8 @@ public final class ConversionWorkflow { } let reportedTarget = report.hash(named: "target_before") switch (current.targetSHA256, reportedTarget) { - case let (.some(expected), .some(reported)) where expected == reported: + case let (.some(expected), .some(reported)) + where ConverterEvidence.isValidSHA256(reported) && expected == reported: break case (nil, nil): break @@ -387,7 +398,11 @@ public final class ConversionWorkflow { stderr: report.stderr ?? "" ) } - self.dryRunFingerprint = current + self.dryRunFingerprint = DryRunFingerprint( + sourceSHA256: current.sourceSHA256, + targetSHA256: current.targetSHA256, + outputSHA256: reportedOutput + ) self.latestReport = report self.state = .dryRun } @@ -429,17 +444,7 @@ public final class ConversionWorkflow { arguments: arguments, lease: lease ) - guard report.status == "written" || report.status == "no-changes" else { - throw self.failureAndRethrow( - .repairConverted, - ConversionWorkflowError.invalidReport("expected written or no-changes status"), - stderr: report.stderr ?? "" - ) - } - self.latestReport = report - self.state = .success - self.coreWriteCompleted = true - self.repairDryRunFingerprint = nil + try self.completeRepair(with: report, fingerprint: fingerprint, input: input) } return } @@ -477,7 +482,11 @@ public final class ConversionWorkflow { ) guard report.status == "dry-run", let sourceSHA256 = report.hash(named: "source"), - let targetSHA256 = report.hash(named: "target_before") + ConverterEvidence.isValidSHA256(sourceSHA256), + let targetSHA256 = report.hash(named: "target_before"), + ConverterEvidence.isValidSHA256(targetSHA256), + let outputSHA256 = report.hash(named: "output"), + ConverterEvidence.isValidSHA256(outputSHA256) else { throw self.failureAndRethrow( .convertSystem, @@ -489,7 +498,8 @@ public final class ConversionWorkflow { source: source, target: target, sourceSHA256: sourceSHA256, - targetSHA256: targetSHA256 + targetSHA256: targetSHA256, + outputSHA256: outputSHA256 ) self.latestReport = report self.state = .dryRun @@ -594,7 +604,17 @@ public final class ConversionWorkflow { let reportedGroups = report.groups, Set(reportedGroups) == self.components.selectedGroups, let stagingSetSHA256 = report.stagingSetSHA256, - let targetSetSHA256 = report.targetSetSHA256Before + ConverterEvidence.isValidSHA256(stagingSetSHA256), + let targetSetSHA256 = report.targetSetSHA256Before, + ConverterEvidence.isValidSHA256(targetSetSHA256), + ConverterEvidence.path(report.stagingDirectory, equals: paths.staging), + ConverterEvidence.path(report.targetDirectory, equals: paths.target), + let entries = report.entries, + self.validExtraInstallEntries( + entries, + groups: self.components.selectedGroups, + targetDirectory: paths.target + ) else { throw self.failureAndRethrow( .installExtras, @@ -607,7 +627,8 @@ public final class ConversionWorkflow { targetDirectory: paths.target, groups: self.components.selectedGroups, stagingSetSHA256: stagingSetSHA256, - targetSetSHA256: targetSetSHA256 + targetSetSHA256: targetSetSHA256, + entries: entries.map { $0.fingerprint() } ) self.latestReport = report self.state = .dryRun @@ -651,7 +672,10 @@ public final class ConversionWorkflow { ) guard verification.status == "dry-run", verification.sourceRecordSetSHA256 == fingerprint.sourceRecordSetSHA256, - verification.targetSHA256Before == fingerprint.targetSHA256Before + verification.targetSHA256Before == fingerprint.targetSHA256Before, + verification.targetSHA256After == fingerprint.targetSHA256After, + ConverterEvidence.path(verification.sourceDirectory, equals: source), + ConverterEvidence.path(verification.target, equals: target) else { self.cecDryRunFingerprint = nil throw self.failureAndRethrow( @@ -697,8 +721,16 @@ public final class ConversionWorkflow { ) guard report.status == "dry-run", let sourceRecordSetSHA256 = report.sourceRecordSetSHA256, - !sourceRecordSetSHA256.isEmpty, - let targetSHA256Before = report.targetSHA256Before + ConverterEvidence.isValidSHA256(sourceRecordSetSHA256), + let targetSHA256Before = report.targetSHA256Before, + ConverterEvidence.isValidSHA256(targetSHA256Before), + let targetSHA256After = report.targetSHA256After, + ConverterEvidence.isValidSHA256(targetSHA256After), + let recordHashes = report.sourceRecordSHA256, + !recordHashes.isEmpty, + recordHashes.allSatisfy(ConverterEvidence.isValidSHA256), + ConverterEvidence.path(report.sourceDirectory, equals: source), + ConverterEvidence.path(report.target, equals: target) else { throw self.failureAndRethrow( .convertCEC, @@ -710,7 +742,9 @@ public final class ConversionWorkflow { sourceDirectory: source, target: target, sourceRecordSetSHA256: sourceRecordSetSHA256, - targetSHA256Before: targetSHA256Before + targetSHA256Before: targetSHA256Before, + targetSHA256After: targetSHA256After, + targetExisted: FileManager.default.fileExists(atPath: target.path) ) self.latestReport = report self.state = .dryRun @@ -742,7 +776,8 @@ public final class ConversionWorkflow { with: report, expectedStatus: "rolled-back", operation: operation, - scope: authorizationScope + scope: authorizationScope, + rollbackManifest: manifest ) } } @@ -823,7 +858,12 @@ public final class ConversionWorkflow { /// deterministic authorization state without replacing the production /// Dry Run path with a fake UI-only bypass. func authorizeDryRunForTesting() throws { - dryRunFingerprint = try requireCurrentFingerprint() + let current = try requireCurrentFingerprint() + dryRunFingerprint = DryRunFingerprint( + sourceSHA256: current.sourceSHA256, + targetSHA256: current.targetSHA256, + outputSHA256: String(repeating: "d", count: 64) + ) state = .dryRun } @@ -845,8 +885,10 @@ public final class ConversionWorkflow { guard let current = currentFingerprint(), let authorized = dryRunFingerprint else { throw ConversionWorkflowError.dryRunRequired } - guard current == authorized else { throw ConversionWorkflowError.staleDryRun } - return current + guard current.sourceSHA256 == authorized.sourceSHA256, + current.targetSHA256 == authorized.targetSHA256 + else { throw ConversionWorkflowError.staleDryRun } + return authorized } private func currentAuthorizedSystemFingerprint(source: URL, target: URL) throws -> SystemDryRunFingerprint { @@ -904,7 +946,8 @@ public final class ConversionWorkflow { guard let sourceInspection else { return nil } return DryRunFingerprint( sourceSHA256: sourceInspection.sha256, - targetSHA256: targetInspection?.sha256 + targetSHA256: targetInspection?.sha256, + outputSHA256: "" ) } @@ -991,7 +1034,16 @@ public final class ConversionWorkflow { ) } let fingerprints = components.compactMap { $0.fingerprint() } - guard fingerprints.count == components.count else { + guard fingerprints.count == components.count, + components.allSatisfy({ component in + ConverterEvidence.isValidSHA256(component.sourceSHA256) + && ConverterEvidence.isValidSHA256(component.outputSHA256) + && ConverterEvidence.hasPath(component.output) + && (component.size ?? 0) > 0 + }), + ConverterEvidence.path(report.sourceDirectory, equals: paths.source), + ConverterEvidence.path(report.outputDirectory, equals: paths.staging) + else { throw failureAndRethrow( .convertExtras, ConversionWorkflowError.invalidReport("ExtData stage components are missing source/output fingerprints"), @@ -1064,7 +1116,8 @@ public final class ConversionWorkflow { with report: ConverterReport, expectedStatus: String, operation: ConverterOperation, - scope: AuthorizationScope? = nil + scope: AuthorizationScope? = nil, + rollbackManifest: URL? = nil ) throws { guard report.status == expectedStatus else { throw failureAndRethrow( @@ -1073,6 +1126,20 @@ public final class ConversionWorkflow { stderr: report.stderr ?? "" ) } + do { + try validateCompletionEvidence( + report, + operation: operation, + rollbackManifest: rollbackManifest + ) + } catch { + throw failureAndRethrow( + operation, + error, + stderr: report.stderr ?? "", + scope: scope + ) + } latestReport = report state = .success let completionScope = scope ?? authorizationScope(for: operation) @@ -1080,6 +1147,322 @@ public final class ConversionWorkflow { invalidateAuthorization(in: completionScope) } + private func completeRepair( + with report: ConverterReport, + fingerprint: RepairDryRunFingerprint, + input: ConversionInput + ) throws { + guard report.status == "written" || report.status == "no-changes" else { + throw failureAndRethrow( + .repairConverted, + ConversionWorkflowError.invalidReport("expected written or no-changes status"), + stderr: report.stderr ?? "" + ) + } + do { + guard report.operation == ConverterOperation.repairConverted.rawValue, + ConverterEvidence.path(report.source, equals: input.source), + ConverterEvidence.path(report.current, equals: input.target), + report.sourceSetSHA256 == fingerprint.sourceSetSHA256, + report.currentSetSHA256 == fingerprint.currentSetSHA256, + report.previewSHA256 == fingerprint.previewSHA256, + ConverterEvidence.isValidSHA256(report.sourceSetSHA256), + ConverterEvidence.isValidSHA256(report.currentSetSHA256), + ConverterEvidence.isValidSHA256(report.previewSHA256), + let components = report.components, + !components.isEmpty, + components.compactMap({ $0.repairFingerprint() }) == fingerprint.components + else { + throw ConversionWorkflowError.invalidReport( + "repair completion requires exact source/current paths, set hashes, preview hash, and components" + ) + } + if report.status == "written" { + guard let manifests = report.manifests, + !manifests.isEmpty, + manifests.allSatisfy(ConverterEvidence.hasPath), + ConverterEvidence.hasPath(report.compatibilityManifest) + else { + throw ConversionWorkflowError.invalidReport( + "written repair requires component manifests and a compatibility manifest" + ) + } + } else { + guard report.manifests?.isEmpty != false, + !ConverterEvidence.hasPath(report.compatibilityManifest) + else { + throw ConversionWorkflowError.invalidReport( + "no-changes repair must not claim write manifests" + ) + } + } + } catch { + throw failureAndRethrow( + .repairConverted, + error, + stderr: report.stderr ?? "", + scope: .core + ) + } + latestReport = report + state = .success + coreWriteCompleted = true + invalidateAuthorization(in: .core) + } + + private func validateCompletionEvidence( + _ report: ConverterReport, + operation: ConverterOperation, + rollbackManifest: URL? + ) throws { + switch operation { + case .convert: + guard let input, let fingerprint = dryRunFingerprint else { + throw ConversionWorkflowError.invalidReport("core write authorization is missing") + } + try validateFileWriteReport( + report, + output: input.target, + sourceSHA256: fingerprint.sourceSHA256, + targetSHA256Before: fingerprint.targetSHA256, + outputSHA256: fingerprint.outputSHA256 + ) + case .convertSystem: + guard let fingerprint = systemDryRunFingerprint else { + throw ConversionWorkflowError.invalidReport("system write authorization is missing") + } + try validateFileWriteReport( + report, + output: fingerprint.target, + sourceSHA256: fingerprint.sourceSHA256, + targetSHA256Before: fingerprint.targetSHA256, + outputSHA256: fingerprint.outputSHA256 + ) + case .convertExtras: + try validateExtrasStageWrite(report) + case .installExtras: + try validateExtrasInstallWrite(report) + case .convertCEC: + try validateCECWrite(report) + case .rollback, .rollbackRepair, .rollbackExtras, .rollbackCEC: + try validateRollbackReport(report, operation: operation, manifest: rollbackManifest) + case .inspect, .repairConverted: + throw ConversionWorkflowError.invalidReport("unsupported completion operation") + } + } + + private func validateFileWriteReport( + _ report: ConverterReport, + output: URL, + sourceSHA256: String, + targetSHA256Before: String?, + outputSHA256: String + ) throws { + guard ConverterEvidence.path(report.output, equals: output), + ConverterEvidence.hasPath(report.manifest), + report.hash(named: "source") == sourceSHA256, + report.hash(named: "output") == outputSHA256, + ConverterEvidence.isValidSHA256(report.hash(named: "source")), + ConverterEvidence.isValidSHA256(report.hash(named: "output")) + else { + throw ConversionWorkflowError.invalidReport( + "written file requires exact output path, manifest, source hash, and output hash" + ) + } + if let targetSHA256Before { + guard report.hash(named: "target_before") == targetSHA256Before, + ConverterEvidence.isValidSHA256(report.hash(named: "target_before")), + ConverterEvidence.hasPath(report.backup) + else { + throw ConversionWorkflowError.invalidReport( + "replacing an existing target requires its exact before hash and backup" + ) + } + } else { + guard report.hash(named: "target_before") == nil, + !ConverterEvidence.hasPath(report.backup) + else { + throw ConversionWorkflowError.invalidReport( + "new export must not claim an existing-target hash or backup" + ) + } + } + } + + private func validateExtrasStageWrite(_ report: ConverterReport) throws { + guard let fingerprint = extrasStageDryRunFingerprint, + ConverterEvidence.path(report.sourceDirectory, equals: fingerprint.sourceDirectory), + ConverterEvidence.path(report.outputDirectory, equals: fingerprint.stagingDirectory), + let components = report.components, + !components.isEmpty, + components.allSatisfy({ component in + ConverterEvidence.isValidSHA256(component.sourceSHA256) + && ConverterEvidence.isValidSHA256(component.outputSHA256) + && ConverterEvidence.hasPath(component.output) + && (component.size ?? 0) > 0 + }), + components.compactMap({ $0.fingerprint() }) == fingerprint.components + else { + throw ConversionWorkflowError.invalidReport( + "ExtData staging requires exact source/output directories and component output evidence" + ) + } + } + + private func validateExtrasInstallWrite(_ report: ConverterReport) throws { + guard let fingerprint = extrasInstallDryRunFingerprint, + report.operation == ConverterOperation.installExtras.rawValue, + Set(report.groups ?? []) == fingerprint.groups, + ConverterEvidence.hasPath(report.manifest), + ConverterEvidence.path(report.stagingDirectory, equals: fingerprint.stagingDirectory), + ConverterEvidence.path(report.targetDirectory, equals: fingerprint.targetDirectory), + report.stagingSetSHA256 == fingerprint.stagingSetSHA256, + report.targetSetSHA256Before == fingerprint.targetSetSHA256, + ConverterEvidence.isValidSHA256(report.stagingSetSHA256), + ConverterEvidence.isValidSHA256(report.targetSetSHA256Before), + let entries = report.entries, + !entries.isEmpty + else { + throw ConversionWorkflowError.invalidReport( + "ExtData install requires exact groups, directories, set hashes, manifest, and entries" + ) + } + let expectedComponents = Set(fingerprint.groups.flatMap(\.componentNames)) + guard Set(entries.map(\.component)) == expectedComponents, + entries.count == expectedComponents.count, + Set(entries.map(\.group)) == fingerprint.groups, + validExtraInstallEntries( + entries, + groups: fingerprint.groups, + targetDirectory: fingerprint.targetDirectory + ), + entries.map({ $0.fingerprint() }) == fingerprint.entries + else { + throw ConversionWorkflowError.invalidReport( + "ExtData install entries do not prove every selected component replacement" + ) + } + let reportedBackups = Set((report.backupPaths ?? []).filter(ConverterEvidence.hasPath)) + let entryBackups = Set(entries.compactMap(\.backup).filter(ConverterEvidence.hasPath)) + guard report.backupPaths?.count == entryBackups.count, + reportedBackups.count == entryBackups.count, + reportedBackups == entryBackups + else { + throw ConversionWorkflowError.invalidReport("ExtData backup list does not match entry evidence") + } + } + + private func validExtraInstallEntries( + _ entries: [ConverterExtraInstallEntry], + groups: Set, + targetDirectory: URL + ) -> Bool { + let expectedPairs = Set(groups.flatMap { group in + group.componentNames.map { "\(group.rawValue):\($0)" } + }) + let reportedPairs = entries.map { "\($0.group.rawValue):\($0.component)" } + guard entries.count == expectedPairs.count, + Set(reportedPairs) == expectedPairs, + Set(reportedPairs).count == reportedPairs.count + else { return false } + return entries.allSatisfy { entry in + let target = URL(fileURLWithPath: entry.target).standardizedFileURL + // URL equality includes a directory/file resource hint. For a + // target directory that does not exist yet, Foundation may omit + // the trailing directory marker while `deletingLastPathComponent` + // always includes it. Compare normalized filesystem paths so the + // fail-closed evidence check does not depend on host filesystem + // state. + let targetMatches = target.deletingLastPathComponent().path + == targetDirectory.standardizedFileURL.path + && target.lastPathComponent == entry.component + return targetMatches + && ConverterEvidence.isValidSHA256(entry.afterSHA256) + && entry.targetPreviouslyExisted + && ConverterEvidence.isValidSHA256(entry.beforeSHA256) + && ConverterEvidence.hasPath(entry.backup) + } + } + + private func validateCECWrite(_ report: ConverterReport) throws { + guard let fingerprint = cecDryRunFingerprint, + ConverterEvidence.path(report.sourceDirectory, equals: fingerprint.sourceDirectory), + ConverterEvidence.path(report.target, equals: fingerprint.target), + report.sourceRecordSetSHA256 == fingerprint.sourceRecordSetSHA256, + report.targetSHA256Before == fingerprint.targetSHA256Before, + report.targetSHA256After == fingerprint.targetSHA256After, + ConverterEvidence.isValidSHA256(report.sourceRecordSetSHA256), + ConverterEvidence.isValidSHA256(report.targetSHA256Before), + ConverterEvidence.isValidSHA256(report.targetSHA256After), + let recordHashes = report.sourceRecordSHA256, + !recordHashes.isEmpty, + recordHashes.allSatisfy(ConverterEvidence.isValidSHA256), + ConverterEvidence.hasPath(report.manifest), + !fingerprint.targetExisted || ConverterEvidence.hasPath(report.backup) + else { + throw ConversionWorkflowError.invalidReport( + "CEC write requires exact mailbox/target hashes, manifest, and conditional backup evidence" + ) + } + } + + private func validateRollbackReport( + _ report: ConverterReport, + operation: ConverterOperation, + manifest: URL? + ) throws { + guard let manifest, + ConverterEvidence.path(report.manifest, equals: manifest) + else { + throw ConversionWorkflowError.invalidReport("rollback must echo the exact recovery manifest") + } + switch operation { + case .rollbackRepair: + guard report.operation == operation.rawValue else { + throw ConversionWorkflowError.invalidReport("repair rollback operation evidence is missing") + } + case .rollbackExtras: + guard report.operation == operation.rawValue, + let groups = report.groups, !groups.isEmpty, + let entries = report.entries, !entries.isEmpty, + validExtraRollbackEntries(entries, groups: Set(groups)) + else { + throw ConversionWorkflowError.invalidReport( + "ExtData rollback requires operation, groups, and restored entry evidence" + ) + } + case .rollback, .rollbackCEC: + break + case .inspect, .convert, .repairConverted, .convertSystem, .convertExtras, .installExtras, .convertCEC: + throw ConversionWorkflowError.invalidReport("unsupported rollback operation") + } + } + + private func validExtraRollbackEntries( + _ entries: [ConverterExtraInstallEntry], + groups: Set + ) -> Bool { + let expectedPairs = Set(groups.flatMap { group in + group.componentNames.map { "\(group.rawValue):\($0)" } + }) + let reportedPairs = entries.map { "\($0.group.rawValue):\($0.component)" } + guard entries.count == expectedPairs.count, + Set(reportedPairs) == expectedPairs, + Set(reportedPairs).count == reportedPairs.count + else { return false } + let targetParents = Set(entries.map { + URL(fileURLWithPath: $0.target).standardizedFileURL.deletingLastPathComponent() + }) + return targetParents.count == 1 && entries.allSatisfy { entry in + let target = URL(fileURLWithPath: entry.target).standardizedFileURL + return target.lastPathComponent == entry.component + && ConverterEvidence.isValidSHA256(entry.afterSHA256) + && entry.targetPreviouslyExisted + && ConverterEvidence.isValidSHA256(entry.beforeSHA256) + && ConverterEvidence.hasPath(entry.backup) + } + } + private enum AuthorizationScope { case core case system diff --git a/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/ConverterEvidence.swift b/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/ConverterEvidence.swift new file mode 100644 index 0000000..fa6556c --- /dev/null +++ b/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/ConverterEvidence.swift @@ -0,0 +1,20 @@ +import Foundation + +enum ConverterEvidence { + static func isValidSHA256(_ value: String?) -> Bool { + guard let value, value.count == 64 else { return false } + return value.utf8.allSatisfy { byte in + (48...57).contains(byte) || (97...102).contains(byte) + } + } + + static func hasPath(_ value: String?) -> Bool { + guard let value else { return false } + return !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + static func path(_ value: String?, equals expected: URL) -> Bool { + guard hasPath(value), let value else { return false } + return URL(fileURLWithPath: value).standardizedFileURL == expected.standardizedFileURL + } +} diff --git a/apps/mh3g-save-converter-macos/Tests/ConverterPresentationTests/ConversionWorkflowTests.swift b/apps/mh3g-save-converter-macos/Tests/ConverterPresentationTests/ConversionWorkflowTests.swift index b83de4c..a4e52e5 100644 --- a/apps/mh3g-save-converter-macos/Tests/ConverterPresentationTests/ConversionWorkflowTests.swift +++ b/apps/mh3g-save-converter-macos/Tests/ConverterPresentationTests/ConversionWorkflowTests.swift @@ -79,6 +79,26 @@ final class ConversionWorkflowTests: XCTestCase { XCTAssertTrue(commands[1].arguments.containsAdjacent("--expected-preview-sha256", fixtureRepairPreviewSHA256)) } + func testRepairNoChangesCompletesWithoutInventingWriteManifests() async throws { + let executor = FakeConverterCommandExecutor(results: [ + .success(repairDryRunResult(confidence: "selected", candidates: ["0.0.5"], modified: false)), + .success(repairNoChangesResult()), + ]) + let workflow = ConversionWorkflow(executable: fixtureExecutable, executor: executor) + workflow.setMode(.repairConverted) + workflow.setRepairFromVersion(.v0_0_5) + workflow.configure(input: fixtureInput) + workflow.applyInspections(source: fixtureSourceInspection, target: fixtureTargetInspection) + + try await workflow.runCoreDryRun() + try await workflow.writeCore() + + XCTAssertEqual(workflow.state, .success) + XCTAssertEqual(workflow.latestReport?.status, "no-changes") + XCTAssertTrue(workflow.coreWriteCompleted) + XCTAssertNil(workflow.repairDryRunFingerprint) + } + func testCompatibilityManifestUsesRollbackRepair() async throws { let executor = FakeConverterCommandExecutor(results: [ .success(rolledBackResult(operation: ConverterOperation.rollbackRepair.rawValue)), @@ -99,7 +119,7 @@ final class ConversionWorkflowTests: XCTestCase { func testNewExportAuthorizesAnAbsentTargetAndWritesWithAbsencePrecondition() async throws { let executor = FakeConverterCommandExecutor(results: [ .success(newExportDryRunResult()), - .success(writtenResult(operation: ConverterOperation.convert.rawValue)), + .success(newExportWrittenResult()), ]) let workflow = ConversionWorkflow(executable: fixtureExecutable, executor: executor) workflow.configure(input: fixtureInput) @@ -249,7 +269,7 @@ final class ConversionWorkflowTests: XCTestCase { } catch { XCTAssertEqual( error as? ConversionWorkflowError, - .invalidReport("Dry Run requires source and target_before SHA-256") + .invalidReport("Dry Run requires valid source and output SHA-256") ) } @@ -431,7 +451,7 @@ final class ConversionWorkflowTests: XCTestCase { workflow.setComponents( ComponentSelection( cecSourceDirectory: URL(fileURLWithPath: "/tmp/CEC/00048100"), - cecTarget: URL(fileURLWithPath: "/tmp/cec"), + cecTarget: URL(fileURLWithPath: "/tmp/cemu/cec"), acknowledgeExperimentalCEC: false ) ) @@ -444,7 +464,7 @@ final class ConversionWorkflowTests: XCTestCase { workflow.setComponents( ComponentSelection( cecSourceDirectory: URL(fileURLWithPath: "/tmp/CEC/00048100"), - cecTarget: URL(fileURLWithPath: "/tmp/cec"), + cecTarget: URL(fileURLWithPath: "/tmp/cemu/cec"), acknowledgeExperimentalCEC: true ) ) @@ -684,6 +704,362 @@ final class ConversionWorkflowTests: XCTestCase { XCTAssertEqual(workflow.state, .failure) } + func testCoreWrittenStatusWithoutTransactionEvidenceFailsClosed() async throws { + let executor = FakeConverterCommandExecutor(results: [ + .success(dryRunResult()), + .success(statusOnlyWrittenResult(operation: ConverterOperation.convert.rawValue)), + ]) + let workflow = ConversionWorkflow(executable: fixtureExecutable, executor: executor) + workflow.configure(input: fixtureInput) + workflow.applyInspections(source: fixtureSourceInspection, target: fixtureTargetInspection) + + try await workflow.runCoreDryRun() + do { + try await workflow.writeCore() + XCTFail("written status without output, backup, manifest, and hashes must fail closed") + } catch { + XCTAssertTrue(error is ConversionWorkflowError) + } + + XCTAssertEqual(workflow.state, .failure) + XCTAssertFalse(workflow.canWrite) + XCTAssertFalse(workflow.coreWriteCompleted) + } + + func testCoreWrittenOutputHashMustMatchTheAuthorizedDryRun() async throws { + let executor = FakeConverterCommandExecutor(results: [ + .success(dryRunResult()), + .success(coreWrittenResult(outputSHA256: validSHA("c"))), + ]) + let workflow = ConversionWorkflow(executable: fixtureExecutable, executor: executor) + workflow.configure(input: fixtureInput) + workflow.applyInspections(source: fixtureSourceInspection, target: fixtureTargetInspection) + + try await workflow.runCoreDryRun() + do { + try await workflow.writeCore() + XCTFail("a write report for different bytes must not consume the authorized Dry Run") + } catch { + XCTAssertTrue(error is ConversionWorkflowError) + } + + XCTAssertEqual(workflow.state, .failure) + XCTAssertFalse(workflow.canWrite) + XCTAssertFalse(workflow.coreWriteCompleted) + } + + func testEvidenceHashesRejectUppercaseShortAndEmptyValues() { + XCTAssertTrue(ConverterEvidence.isValidSHA256(validSHA("a"))) + XCTAssertFalse(ConverterEvidence.isValidSHA256(validSHA("a").uppercased())) + XCTAssertFalse(ConverterEvidence.isValidSHA256("abc123")) + XCTAssertFalse(ConverterEvidence.isValidSHA256("")) + XCTAssertFalse(ConverterEvidence.isValidSHA256(nil)) + } + + func testSystemWrittenStatusWithoutTransactionEvidenceFailsClosed() async throws { + let executor = FakeConverterCommandExecutor(results: [ + .success(systemDryRunResult()), + .success(statusOnlyWrittenResult(operation: ConverterOperation.convertSystem.rawValue)), + ]) + let workflow = ConversionWorkflow(executable: fixtureExecutable, executor: executor) + workflow.setComponents( + ComponentSelection( + includeSystem: true, + systemSource: fixtureSystemSource, + systemTarget: fixtureSystemTarget + ) + ) + + try await workflow.runSystemDryRun() + do { + try await workflow.writeSystem() + XCTFail("system written status without transaction evidence must fail closed") + } catch { + XCTAssertTrue(error is ConversionWorkflowError) + } + + XCTAssertEqual(workflow.state, .failure) + XCTAssertFalse(workflow.canWriteSystem) + XCTAssertFalse(workflow.systemWriteCompleted) + } + + func testSystemEvidenceFailureRevokesOnlySystemAuthorization() async throws { + let executor = FakeConverterCommandExecutor(results: [ + .success(dryRunResult()), + .success(systemDryRunResult()), + .success(statusOnlyWrittenResult(operation: ConverterOperation.convertSystem.rawValue)), + ]) + let workflow = ConversionWorkflow(executable: fixtureExecutable, executor: executor) + workflow.configure(input: fixtureInput) + workflow.applyInspections(source: fixtureSourceInspection, target: fixtureTargetInspection) + workflow.setComponents( + ComponentSelection( + includeSystem: true, + systemSource: fixtureSystemSource, + systemTarget: fixtureSystemTarget + ) + ) + + try await workflow.runCoreDryRun() + try await workflow.runSystemDryRun() + do { + try await workflow.writeSystem() + XCTFail("invalid system evidence must fail") + } catch { + XCTAssertTrue(error is ConversionWorkflowError) + } + + XCTAssertTrue(workflow.canWrite) + XCTAssertFalse(workflow.canWriteSystem) + } + + func testExtrasStageWrittenStatusWithoutComponentEvidenceFailsClosed() async throws { + let executor = FakeConverterCommandExecutor(results: [ + .success(extrasStageDryRunResult()), + .success(extrasStageDryRunResult()), + .success(statusOnlyWrittenResult(operation: ConverterOperation.convertExtras.rawValue)), + ]) + let workflow = ConversionWorkflow(executable: fixtureExecutable, executor: executor) + workflow.setComponents(fixtureExtrasSelection) + + try await workflow.runExtrasStageDryRun() + do { + try await workflow.stageExtras() + XCTFail("staging must require exact output/component evidence") + } catch { + XCTAssertTrue(error is ConversionWorkflowError) + } + + XCTAssertEqual(workflow.state, .failure) + XCTAssertFalse(workflow.canStageExtras) + } + + func testExtrasInstallWrittenStatusWithoutManifestAndSetEvidenceFailsClosed() async throws { + let executor = FakeConverterCommandExecutor(results: [ + .success(extrasInstallDryRunResult()), + .success(statusOnlyWrittenResult(operation: ConverterOperation.installExtras.rawValue)), + ]) + let workflow = ConversionWorkflow(executable: fixtureExecutable, executor: executor) + workflow.setComponents(fixtureExtrasSelection) + + try await workflow.runExtrasInstallDryRun() + do { + try await workflow.installExtraGroups() + XCTFail("ExtData install must require manifest, set hashes, and entry evidence") + } catch { + XCTAssertTrue(error is ConversionWorkflowError) + } + + XCTAssertEqual(workflow.state, .failure) + XCTAssertFalse(workflow.canInstallExtras) + XCTAssertFalse(workflow.extrasInstallCompleted) + } + + func testExtrasInstallRejectsDuplicateComponentEvidence() async throws { + let executor = FakeConverterCommandExecutor(results: [ + .success(extrasInstallDryRunResult()), + .success(extrasInstallWrittenResult(components: ["card1", "card2", "card3", "card1"])), + ]) + let workflow = ConversionWorkflow(executable: fixtureExecutable, executor: executor) + workflow.setComponents(fixtureExtrasSelection) + + try await workflow.runExtrasInstallDryRun() + do { + try await workflow.installExtraGroups() + XCTFail("duplicate group/component evidence must fail closed") + } catch { + XCTAssertTrue(error is ConversionWorkflowError) + } + + XCTAssertFalse(workflow.canInstallExtras) + XCTAssertFalse(workflow.extrasInstallCompleted) + } + + func testExtrasInstallDryRunDoesNotDependOnTargetDirectoryExisting() async throws { + let targetDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("mh3g-nonexistent-\(UUID().uuidString)") + XCTAssertFalse(FileManager.default.fileExists(atPath: targetDirectory.path)) + + let executor = FakeConverterCommandExecutor(results: [ + .success(extrasInstallDryRunResult(targetDirectory: targetDirectory)), + ]) + let workflow = ConversionWorkflow(executable: fixtureExecutable, executor: executor) + workflow.setComponents( + ComponentSelection( + includeGuildCards: true, + extraSourceDirectory: URL(fileURLWithPath: "/tmp/extdata/user"), + extraStagingDirectory: URL(fileURLWithPath: "/tmp/mh3g-staging"), + extraTargetDirectory: targetDirectory + ) + ) + + try await workflow.runExtrasInstallDryRun() + + XCTAssertTrue(workflow.canInstallExtras) + } + + func testCECWrittenStatusWithoutManifestAndHashEvidenceFailsClosed() async throws { + let executor = FakeConverterCommandExecutor(results: [ + .success(cecDryRunResult()), + .success(cecDryRunResult()), + .success(statusOnlyWrittenResult(operation: ConverterOperation.convertCEC.rawValue)), + ]) + let workflow = ConversionWorkflow(executable: fixtureExecutable, executor: executor) + workflow.setComponents(fixtureCECSelection) + + try await workflow.runCECDryRun() + do { + try await workflow.writeCEC() + XCTFail("CEC write must require manifest and exact before/after hash evidence") + } catch { + XCTAssertTrue(error is ConversionWorkflowError) + } + + XCTAssertEqual(workflow.state, .failure) + XCTAssertFalse(workflow.canWriteCEC) + } + + func testCECWrittenTargetAfterMustMatchTheAuthorizedPreview() async throws { + let executor = FakeConverterCommandExecutor(results: [ + .success(cecDryRunResult()), + .success(cecDryRunResult()), + .success(cecWrittenResult(targetAfterSHA256: validSHA("c"))), + ]) + let workflow = ConversionWorkflow(executable: fixtureExecutable, executor: executor) + workflow.setComponents(fixtureCECSelection) + + try await workflow.runCECDryRun() + do { + try await workflow.writeCEC() + XCTFail("CEC target-after hash must match the authorized preview") + } catch { + XCTAssertTrue(error is ConversionWorkflowError) + } + + XCTAssertFalse(workflow.canWriteCEC) + XCTAssertEqual(workflow.state, .failure) + } + + func testRepairWrittenStatusWithoutCoordinatorEvidenceFailsClosed() async throws { + let executor = FakeConverterCommandExecutor(results: [ + .success(repairDryRunResult(confidence: "selected", candidates: ["0.0.5"])), + .success(statusOnlyWrittenResult(operation: ConverterOperation.repairConverted.rawValue)), + ]) + let workflow = ConversionWorkflow(executable: fixtureExecutable, executor: executor) + workflow.setMode(.repairConverted) + workflow.setRepairFromVersion(.v0_0_5) + workflow.configure(input: fixtureInput) + workflow.applyInspections(source: fixtureSourceInspection, target: fixtureTargetInspection) + + try await workflow.runCoreDryRun() + do { + try await workflow.writeCore() + XCTFail("repair write must require compatibility manifest and exact set hashes") + } catch { + XCTAssertTrue(error is ConversionWorkflowError) + } + + XCTAssertEqual(workflow.state, .failure) + XCTAssertFalse(workflow.canWrite) + XCTAssertFalse(workflow.coreWriteCompleted) + } + + func testRepairWrittenComponentEvidenceMustMatchTheDryRun() async throws { + let executor = FakeConverterCommandExecutor(results: [ + .success(repairDryRunResult(confidence: "selected", candidates: ["0.0.5"])), + .success( + repairWrittenResult( + manifest: "/tmp/.mh3g-compatibility-repair-test.json", + mergedSHA256: validSHA("7") + ) + ), + ]) + let workflow = ConversionWorkflow(executable: fixtureExecutable, executor: executor) + workflow.setMode(.repairConverted) + workflow.setRepairFromVersion(.v0_0_5) + workflow.configure(input: fixtureInput) + workflow.applyInspections(source: fixtureSourceInspection, target: fixtureTargetInspection) + + try await workflow.runCoreDryRun() + do { + try await workflow.writeCore() + XCTFail("repair component evidence must match the authorized Dry Run") + } catch { + XCTAssertTrue(error is ConversionWorkflowError) + } + + XCTAssertFalse(workflow.canWrite) + XCTAssertFalse(workflow.coreWriteCompleted) + } + + func testRollbackStatusWithoutExactManifestEvidenceFailsClosed() async throws { + let manifest = URL(fileURLWithPath: "/tmp/core-rollback.json") + let executor = FakeConverterCommandExecutor(results: [ + .success(statusOnlyRolledBackResult(operation: ConverterOperation.rollback.rawValue)), + ]) + let workflow = ConversionWorkflow(executable: fixtureExecutable, executor: executor) + + do { + try await workflow.rollback(manifest: manifest) + XCTFail("rollback status without the exact manifest must fail closed") + } catch { + XCTAssertTrue(error is ConversionWorkflowError) + } + + XCTAssertEqual(workflow.state, .failure) + } + + func testRollbackWrongManifestEvidenceFailsClosed() async throws { + let expected = URL(fileURLWithPath: "/tmp/core-rollback.json") + let executor = FakeConverterCommandExecutor(results: [ + .success( + rollbackResult( + operation: .rollback, + manifest: URL(fileURLWithPath: "/tmp/other-rollback.json") + ) + ), + ]) + let workflow = ConversionWorkflow(executable: fixtureExecutable, executor: executor) + + do { + try await workflow.rollback(manifest: expected) + XCTFail("rollback must echo the exact selected manifest") + } catch { + XCTAssertTrue(error is ConversionWorkflowError) + } + + XCTAssertEqual(workflow.state, .failure) + } + + func testCoreAndCECRollbackAcceptOnlyTheExactEchoedManifest() async throws { + let coreManifest = URL(fileURLWithPath: "/tmp/core-rollback.json") + let cecManifest = URL(fileURLWithPath: "/tmp/cec-rollback.json") + let executor = FakeConverterCommandExecutor(results: [ + .success(rollbackResult(operation: .rollback, manifest: coreManifest)), + .success(rollbackResult(operation: .rollbackCEC, manifest: cecManifest)), + ]) + let workflow = ConversionWorkflow(executable: fixtureExecutable, executor: executor) + + try await workflow.rollback(manifest: coreManifest) + XCTAssertEqual(workflow.state, .success) + try await workflow.rollback(manifest: cecManifest, cec: true) + XCTAssertEqual(workflow.state, .success) + } + + func testExtrasRollbackRequiresGroupAndEntryEvidence() async throws { + let manifest = URL(fileURLWithPath: "/tmp/extras-rollback.json") + let executor = FakeConverterCommandExecutor(results: [ + .success(extrasRollbackResult(manifest: manifest)), + ]) + let workflow = ConversionWorkflow(executable: fixtureExecutable, executor: executor) + + try await workflow.rollback(manifest: manifest, extraGroup: true) + + XCTAssertEqual(workflow.state, .success) + XCTAssertEqual(workflow.latestReport?.groups, [.guildCards]) + XCTAssertEqual(workflow.latestReport?.entries?.count, 4) + } + func testWriteRejectsSuccessfulProcessWithNonWrittenStatus() async throws { let executor = FakeConverterCommandExecutor(results: [ .success(dryRunResult()), @@ -732,11 +1108,22 @@ private let fixtureSystemSource = URL(fileURLWithPath: "/tmp/3ds/system") private let fixtureSystemTarget = URL(fileURLWithPath: "/tmp/cemu/system") private let fixtureSystemSourceSHA256 = "c".repeated(64) private let fixtureSystemTargetSHA256 = "d".repeated(64) -private let fixtureCECSourceRecordSetSHA256 = "f".repeated(64) -private let fixtureCECTargetSHA256 = "g".repeated(64) -private let fixtureRepairSourceSetSHA256 = "h".repeated(64) -private let fixtureRepairCurrentSetSHA256 = "i".repeated(64) -private let fixtureRepairPreviewSHA256 = "j".repeated(64) +private let fixtureCECSourceRecordSetSHA256 = validSHA("e") +private let fixtureCECTargetSHA256 = validSHA("f") +private let fixtureRepairSourceSetSHA256 = validSHA("1") +private let fixtureRepairCurrentSetSHA256 = validSHA("2") +private let fixtureRepairPreviewSHA256 = validSHA("3") +private let fixtureExtrasSelection = ComponentSelection( + includeGuildCards: true, + extraSourceDirectory: URL(fileURLWithPath: "/tmp/extdata/user"), + extraStagingDirectory: URL(fileURLWithPath: "/tmp/mh3g-staging"), + extraTargetDirectory: URL(fileURLWithPath: "/tmp/cemu") +) +private let fixtureCECSelection = ComponentSelection( + cecSourceDirectory: URL(fileURLWithPath: "/tmp/CEC/00048100"), + cecTarget: URL(fileURLWithPath: "/tmp/cemu/cec"), + acknowledgeExperimentalCEC: true +) private func dryRunResult() -> ConverterCommandResult { let json = """ @@ -752,6 +1139,13 @@ private func newExportDryRunResult() -> ConverterCommandResult { return ConverterCommandResult(exitCode: 0, stdout: Data(json.utf8), stderr: Data()) } +private func newExportWrittenResult() -> ConverterCommandResult { + let json = """ + {"status":"written","hashes":{"source":"\(fixtureSourceInspection.sha256)","output":"\(validSHA("d"))"},"output":"\(fixtureInput.target.path)","backup":null,"manifest":"/tmp/cemu/.user2.manifest.json"} + """ + return ConverterCommandResult(exitCode: 0, stdout: Data(json.utf8), stderr: Data()) +} + private func systemDryRunResult() -> ConverterCommandResult { let json = """ {"operation":"convert-system","status":"dry-run","hashes":{"source":"\(fixtureSystemSourceSHA256)","target_before":"\(fixtureSystemTargetSHA256)","output":"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"}} @@ -760,49 +1154,158 @@ private func systemDryRunResult() -> ConverterCommandResult { } private func writtenResult(operation: String) -> ConverterCommandResult { + let json: String + switch operation { + case ConverterOperation.convert.rawValue: + json = """ + {"status":"written","hashes":{"source":"\(fixtureSourceInspection.sha256)","target_before":"\(fixtureTargetInspection.sha256)","output":"\(validSHA("d"))"},"output":"\(fixtureInput.target.path)","backup":"/tmp/cemu/.user2.backup","manifest":"/tmp/cemu/.user2.manifest.json"} + """ + case ConverterOperation.convertSystem.rawValue: + json = """ + {"status":"written","hashes":{"source":"\(fixtureSystemSourceSHA256)","target_before":"\(fixtureSystemTargetSHA256)","output":"\(validSHA("e"))"},"output":"\(fixtureSystemTarget.path)","backup":"/tmp/cemu/.system.backup","manifest":"/tmp/cemu/.system.manifest.json"} + """ + case ConverterOperation.convertExtras.rawValue: + json = """ + {"status":"written","source_dir":"/tmp/extdata/user","output_dir":"/tmp/mh3g-staging","components":[{"component":"card1","source_sha256":"\(validSHA("4"))","output_sha256":"\(validSHA("5"))","output":"/tmp/mh3g-staging/card1","size":64}]} + """ + case ConverterOperation.installExtras.rawValue: + return extrasInstallWrittenResult() + case ConverterOperation.convertCEC.rawValue: + return cecWrittenResult() + default: + preconditionFailure("missing written fixture for \(operation)") + } + return ConverterCommandResult(exitCode: 0, stdout: Data(json.utf8), stderr: Data()) +} + +private func coreWrittenResult(outputSHA256: String) -> ConverterCommandResult { + let json = """ + {"status":"written","hashes":{"source":"\(fixtureSourceInspection.sha256)","target_before":"\(fixtureTargetInspection.sha256)","output":"\(outputSHA256)"},"output":"\(fixtureInput.target.path)","backup":"/tmp/cemu/.user2.backup","manifest":"/tmp/cemu/.user2.manifest.json"} + """ + return ConverterCommandResult(exitCode: 0, stdout: Data(json.utf8), stderr: Data()) +} + +private func statusOnlyWrittenResult(operation: String) -> ConverterCommandResult { let json = "{\"operation\":\"\(operation)\",\"status\":\"written\"}" return ConverterCommandResult(exitCode: 0, stdout: Data(json.utf8), stderr: Data()) } -private func repairDryRunResult(confidence: String, candidates: [String]) -> ConverterCommandResult { +private func repairDryRunResult( + confidence: String, + candidates: [String], + modified: Bool = true +) -> ConverterCommandResult { let candidateJSON = candidates.map { "\"\($0)\"" }.joined(separator: ",") + let mergedSHA256 = modified ? validSHA("6") : validSHA("5") let json = """ - {"operation":"repair-converted","status":"dry-run","source_set_sha256":"\(fixtureRepairSourceSetSHA256)","current_set_sha256":"\(fixtureRepairCurrentSetSHA256)","preview_sha256":"\(fixtureRepairPreviewSHA256)","detection":{"confidence":"\(confidence)","candidates":[\(candidateJSON)]},"components":[{"component":"user2","detection":{"confidence":"\(confidence)","candidates":[\(candidateJSON)]}}]} + {"operation":"repair-converted","status":"dry-run","source_set_sha256":"\(fixtureRepairSourceSetSHA256)","current_set_sha256":"\(fixtureRepairCurrentSetSHA256)","preview_sha256":"\(fixtureRepairPreviewSHA256)","detection":{"confidence":"\(confidence)","candidates":[\(candidateJSON)]},"components":[{"component":"user2","target":"\(fixtureInput.target.path)","modified":\(modified),"detection":{"confidence":"\(confidence)","candidates":[\(candidateJSON)]},"merge":{"component":"user2","source_sha256":"\(validSHA("4"))","current_sha256":"\(validSHA("5"))","merged_sha256":"\(mergedSHA256)"}}]} """ return ConverterCommandResult(exitCode: 0, stdout: Data(json.utf8), stderr: Data()) } -private func repairWrittenResult(manifest: String) -> ConverterCommandResult { +private func repairWrittenResult( + manifest: String, + mergedSHA256: String = validSHA("6") +) -> ConverterCommandResult { let json = """ - {"operation":"repair-converted","status":"written","compatibility_manifest":"\(manifest)"} + {"operation":"repair-converted","status":"written","source":"\(fixtureInput.source.path)","current":"\(fixtureInput.target.path)","source_set_sha256":"\(fixtureRepairSourceSetSHA256)","current_set_sha256":"\(fixtureRepairCurrentSetSHA256)","preview_sha256":"\(fixtureRepairPreviewSHA256)","components":[{"component":"user2","target":"\(fixtureInput.target.path)","modified":true,"detection":{"confidence":"selected","candidates":["0.0.5"]},"merge":{"component":"user2","source_sha256":"\(validSHA("4"))","current_sha256":"\(validSHA("5"))","merged_sha256":"\(mergedSHA256)"}}],"manifests":["/tmp/.mh3g-user2-repair.json"],"compatibility_manifest":"\(manifest)"} + """ + return ConverterCommandResult(exitCode: 0, stdout: Data(json.utf8), stderr: Data()) +} + +private func repairNoChangesResult() -> ConverterCommandResult { + let json = """ + {"operation":"repair-converted","status":"no-changes","source":"\(fixtureInput.source.path)","current":"\(fixtureInput.target.path)","source_set_sha256":"\(fixtureRepairSourceSetSHA256)","current_set_sha256":"\(fixtureRepairCurrentSetSHA256)","preview_sha256":"\(fixtureRepairPreviewSHA256)","components":[{"component":"user2","target":"\(fixtureInput.target.path)","modified":false,"detection":{"confidence":"selected","candidates":["0.0.5"]},"merge":{"component":"user2","source_sha256":"\(validSHA("4"))","current_sha256":"\(validSHA("5"))","merged_sha256":"\(validSHA("5"))"}}],"manifests":[],"compatibility_manifest":null} """ return ConverterCommandResult(exitCode: 0, stdout: Data(json.utf8), stderr: Data()) } private func rolledBackResult(operation: String) -> ConverterCommandResult { + let manifest = operation == ConverterOperation.rollbackRepair.rawValue + ? "/tmp/.mh3g-compatibility-repair-test.json" + : "/tmp/rollback.json" + let json = "{\"operation\":\"\(operation)\",\"status\":\"rolled-back\",\"manifest\":\"\(manifest)\"}" + return ConverterCommandResult(exitCode: 0, stdout: Data(json.utf8), stderr: Data()) +} + +private func statusOnlyRolledBackResult(operation: String) -> ConverterCommandResult { let json = "{\"operation\":\"\(operation)\",\"status\":\"rolled-back\"}" return ConverterCommandResult(exitCode: 0, stdout: Data(json.utf8), stderr: Data()) } +private func rollbackResult(operation: ConverterOperation, manifest: URL) -> ConverterCommandResult { + let operationField = operation == .rollbackCEC ? "" : "\"operation\":\"\(operation.rawValue)\"," + let json = "{\(operationField)\"status\":\"rolled-back\",\"manifest\":\"\(manifest.path)\"}" + return ConverterCommandResult(exitCode: 0, stdout: Data(json.utf8), stderr: Data()) +} + +private func extrasRollbackResult(manifest: URL) -> ConverterCommandResult { + let entries = ["card1", "card2", "card3", "cardbox"].map { component in + """ + {"group":"guild-cards","component":"\(component)","target":"/tmp/cemu/\(component)","temporary":"/tmp/cemu/.\(component).tmp","before_sha256":"\(validSHA("8"))","after_sha256":"\(validSHA("9"))","backup":"/tmp/cemu/.\(component).backup","target_previously_existed":true} + """ + }.joined(separator: ",") + let json = """ + {"operation":"rollback-extras","status":"rolled-back","groups":["guild-cards"],"entries":[\(entries)],"manifest":"\(manifest.path)"} + """ + return ConverterCommandResult(exitCode: 0, stdout: Data(json.utf8), stderr: Data()) +} + private func cecDryRunResult() -> ConverterCommandResult { - let json = "{\"operation\":\"convert-cec\",\"status\":\"dry-run\",\"source_record_sha256\":[\"\("e".repeated(64))\"],\"source_record_set_sha256\":\"\(fixtureCECSourceRecordSetSHA256)\",\"target_sha256_before\":\"\(fixtureCECTargetSHA256)\"}" + let json = "{\"operation\":\"convert-cec\",\"status\":\"dry-run\",\"source_dir\":\"/tmp/CEC/00048100\",\"target\":\"/tmp/cemu/cec\",\"source_record_sha256\":[\"\(validSHA("a"))\"],\"source_record_set_sha256\":\"\(fixtureCECSourceRecordSetSHA256)\",\"target_sha256_before\":\"\(fixtureCECTargetSHA256)\",\"target_sha256_after\":\"\(validSHA("b"))\"}" + return ConverterCommandResult(exitCode: 0, stdout: Data(json.utf8), stderr: Data()) +} + +private func cecWrittenResult(targetAfterSHA256: String = validSHA("b")) -> ConverterCommandResult { + let json = """ + {"status":"written","source_dir":"/tmp/CEC/00048100","target":"/tmp/cemu/cec","source_record_sha256":["\(validSHA("a"))"],"source_record_set_sha256":"\(fixtureCECSourceRecordSetSHA256)","target_sha256_before":"\(fixtureCECTargetSHA256)","target_sha256_after":"\(targetAfterSHA256)","manifest":"/tmp/cemu/.cec.manifest.json"} + """ return ConverterCommandResult(exitCode: 0, stdout: Data(json.utf8), stderr: Data()) } private func extrasStageDryRunResult() -> ConverterCommandResult { let json = """ - {"status":"dry-run","components":[{"component":"card1","source_sha256":"\("1".repeated(64))","output_sha256":"\("2".repeated(64))","output":"/tmp/mh3g-staging/card1","size":64}]} + {"status":"dry-run","source_dir":"/tmp/extdata/user","output_dir":"/tmp/mh3g-staging","components":[{"component":"card1","source_sha256":"\(validSHA("4"))","output_sha256":"\(validSHA("5"))","output":"/tmp/mh3g-staging/card1","size":64}]} """ return ConverterCommandResult(exitCode: 0, stdout: Data(json.utf8), stderr: Data()) } -private func extrasInstallDryRunResult() -> ConverterCommandResult { +private func extrasInstallDryRunResult( + targetDirectory: URL = URL(fileURLWithPath: "/tmp/cemu") +) -> ConverterCommandResult { + let entries = ["card1", "card2", "card3", "cardbox"].map { component in + """ + {"group":"guild-cards","component":"\(component)","target":"\(targetDirectory.path)/\(component)","temporary":"\(targetDirectory.path)/.dryrun-\(component).tmp","before_sha256":"\(validSHA("8"))","after_sha256":"\(validSHA("9"))","backup":"\(targetDirectory.path)/.dryrun-\(component).backup","target_previously_existed":true} + """ + }.joined(separator: ",") let json = """ - {"operation":"install-extras","status":"dry-run","groups":["guild-cards"],"staging_set_sha256":"\("3".repeated(64))","target_set_sha256_before":"\("4".repeated(64))"} + {"operation":"install-extras","status":"dry-run","groups":["guild-cards"],"entries":[\(entries)],"manifest":"\(targetDirectory.path)/.mh3g-extra-install.json","staging_dir":"/tmp/mh3g-staging","target_dir":"\(targetDirectory.path)","staging_set_sha256":"\(validSHA("6"))","target_set_sha256_before":"\(validSHA("7"))"} """ return ConverterCommandResult(exitCode: 0, stdout: Data(json.utf8), stderr: Data()) } +private func extrasInstallWrittenResult( + components: [String] = ["card1", "card2", "card3", "cardbox"] +) -> ConverterCommandResult { + let entries = components.map { component in + """ + {"group":"guild-cards","component":"\(component)","target":"/tmp/cemu/\(component)","temporary":"/tmp/cemu/.\(component).tmp","before_sha256":"\(validSHA("8"))","after_sha256":"\(validSHA("9"))","backup":"/tmp/cemu/.\(component).backup","target_previously_existed":true} + """ + }.joined(separator: ",") + let backups = components + .map { "\"/tmp/cemu/.\($0).backup\"" } + .joined(separator: ",") + let json = """ + {"operation":"install-extras","status":"written","groups":["guild-cards"],"entries":[\(entries)],"manifest":"/tmp/cemu/.mh3g-extra-install.json","staging_dir":"/tmp/mh3g-staging","target_dir":"/tmp/cemu","staging_set_sha256":"\(validSHA("6"))","target_set_sha256_before":"\(validSHA("7"))","backup_paths":[\(backups)]} + """ + return ConverterCommandResult(exitCode: 0, stdout: Data(json.utf8), stderr: Data()) +} + +private func validSHA(_ character: Character) -> String { + precondition("0123456789abcdef".contains(character)) + return String(repeating: String(character), count: 64) +} + private actor FakeConverterCommandExecutor: ConverterCommandExecuting { private var results: [Result] private var commands: [ConverterCommand] = [] diff --git a/crates/mh3g-save-convert/tests/cli.rs b/crates/mh3g-save-convert/tests/cli.rs index 4b26ef1..a230a0d 100644 --- a/crates/mh3g-save-convert/tests/cli.rs +++ b/crates/mh3g-save-convert/tests/cli.rs @@ -1225,8 +1225,11 @@ fn write_then_rollback_restores_previous_slot() { let _guard = PROCESS_GUARD.lock().unwrap(); let temp = tempfile::tempdir().unwrap(); let source = slot_fixture(&temp, "user2"); + let source_before = fs::read(&source).unwrap(); + let source_sha256_before = sha2::Sha256::digest(&source_before); let target = target_slot(&temp, "user2"); let previous = vec![0xA5; CEMU_SIZE]; + let previous_sha256 = sha2::Sha256::digest(&previous); fs::write(&target, &previous).unwrap(); let dry_run = run_json(&[ @@ -1254,6 +1257,11 @@ fn write_then_rollback_restores_previous_slot() { assert!(target.exists()); assert!(manifest.exists()); assert!(backup.exists()); + assert_eq!(fs::read(&source).unwrap(), source_before); + assert_eq!( + sha2::Sha256::digest(fs::read(&source).unwrap()), + source_sha256_before + ); let rollback = run_json_with_stopped_emulators(&[ "rollback".into(), @@ -1262,6 +1270,14 @@ fn write_then_rollback_restores_previous_slot() { ]); assert_eq!(rollback["status"], "rolled-back"); assert_eq!(fs::read(&target).unwrap(), previous); + assert_eq!( + sha2::Sha256::digest(fs::read(&target).unwrap()), + previous_sha256 + ); + assert_eq!( + sha2::Sha256::digest(fs::read(&source).unwrap()), + source_sha256_before + ); assert!(!manifest.exists()); assert!(!backup.exists()); }