commit de87ac0c86ed27af949a8c1e3927513aa7d75c51 parent d690e6f407d218fe08139452ab6bc90eb77b3c1e Author: triesap <tyson@radroots.org> Date: Tue, 15 Sep 2026 20:13:57 +0000 media: bind staged bytes to durable draft ownership - Validate and flush durable staged media before committing composer references - Confirm media ownership before saved acknowledgment and restart recovery - Retain identifiable orphan bytes across failed and unknown database outcomes - Verify file faults, shared ownership, late confirmation and exact generated evidence Diffstat:
20 files changed, 388 insertions(+), 38 deletions(-)
diff --git a/Package.swift b/Package.swift @@ -75,6 +75,7 @@ let package = Package( "Runtime/TeraComposerModels.swift", "Runtime/TeraDraftInventoryModels.swift", "Runtime/TeraDurableMediaRoots.swift", + "Runtime/TeraComposerMediaOwnership.swift", "Runtime/TeraErrorRecovery.swift", "Runtime/TeraGeneratedCalendarCards.swift", "Runtime/TeraGeneratedComposer.swift", diff --git a/Tera.xcodeproj/project.pbxproj b/Tera.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 08A44543B171791AF858C517 /* TeraAddSignerFixture.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1CC48A95774B931BE8D1E4A /* TeraAddSignerFixture.swift */; }; 093A6AC6C9B724E465507004 /* TeraPublicationEvidenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B954B7006CC5904C98138C6 /* TeraPublicationEvidenceTests.swift */; }; 095FBECA7268AA594F614EFA /* TeraScopeBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9ED6D3E35C3235B6068E1902 /* TeraScopeBackend.swift */; }; + 1381A92874CF80925EEC06F5 /* TeraComposerMediaOwnershipTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE713B98919CB7692E3104A9 /* TeraComposerMediaOwnershipTests.swift */; }; 141327BC6BAE2CFA2C62C584 /* TeraTodayReconciliationTaskTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EB21C33D6173E4FED05FDA0 /* TeraTodayReconciliationTaskTests.swift */; }; 17805D95512D7144DAC59D85 /* TeraTodayRenderingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7283D1C8F6E90A236BC84AF6 /* TeraTodayRenderingTests.swift */; }; 1D45B3D566A0A60063190854 /* TeraApp in Frameworks */ = {isa = PBXBuildFile; productRef = E666175A800272732A27CBEB /* TeraApp */; }; @@ -205,6 +206,7 @@ B4D3FEE2830E056E82AF05FD /* TeraTodayBackfillTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraTodayBackfillTests.swift; sourceTree = "<group>"; }; BAD45A3134A16704CBE4B1A5 /* TeraStateTestFixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraStateTestFixtures.swift; sourceTree = "<group>"; }; BD39C5F3460EEF38B98D78B4 /* TeraStoreRecoveryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraStoreRecoveryTests.swift; sourceTree = "<group>"; }; + BE713B98919CB7692E3104A9 /* TeraComposerMediaOwnershipTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraComposerMediaOwnershipTests.swift; sourceTree = "<group>"; }; C0636AEDEB50DDDD328374A9 /* TeraSupportingStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraSupportingStoreTests.swift; sourceTree = "<group>"; }; C1DBB5D9AC2FE8EA3AE68458 /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; }; C4721B7A69E2FB989732A64D /* TeraSubmissionReservationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraSubmissionReservationTests.swift; sourceTree = "<group>"; }; @@ -310,6 +312,7 @@ 2A506546B0EC1792240C36F7 /* TeraClockTests.swift */, E55E47B15C42F13ABF2DFF27 /* TeraComposerAutosaveTests.swift */, 6D536D6625083ACB3DA4A674 /* TeraComposerDurabilityBoundaryTests.swift */, + BE713B98919CB7692E3104A9 /* TeraComposerMediaOwnershipTests.swift */, A5938176A92F96A30CC39DFB /* TeraComposerPersistenceFFITests.swift */, 0C4A465852C5D5BDFC879D6E /* TeraComposerSaveLatencyTests.swift */, 3C51E210CC4A6734CD302452 /* TeraComposerSaveSchedulingTests.swift */, @@ -604,6 +607,7 @@ 70417FB635F06CC9829FB38C /* TeraClockTests.swift in Sources */, 4B093458FAF4C6E4860E313A /* TeraComposerAutosaveTests.swift in Sources */, 84429B5DEB5BB41EFD25A8ED /* TeraComposerDurabilityBoundaryTests.swift in Sources */, + 1381A92874CF80925EEC06F5 /* TeraComposerMediaOwnershipTests.swift in Sources */, 295A19317FC58A73A68C98DC /* TeraComposerPersistenceFFITests.swift in Sources */, E892ED14E1F39D81128A8F0E /* TeraComposerSaveLatencyTests.swift in Sources */, F8DB757CAC7AB2136483EC2B /* TeraComposerSaveSchedulingTests.swift in Sources */, diff --git a/Tera/Runtime/TeraAddMediaCoordinator.swift b/Tera/Runtime/TeraAddMediaCoordinator.swift @@ -19,6 +19,7 @@ struct TeraAddBackgroundUploadReceipt: Sendable, Equatable { } protocol TeraAddMediaHandling: Sendable { + func confirmDurableComposerMedia(_ media: [TeraComposerMedia]) async throws func prefersSharedForegroundUpload(ownerID: String) async throws -> Bool func support() async throws -> TeraAddMediaSupport func importImages(limit: Int) async throws -> [TeraPreparedMedia] @@ -36,6 +37,10 @@ protocol TeraAddMediaHandling: Sendable { } extension TeraAddMediaHandling { + func confirmDurableComposerMedia(_ media: [TeraComposerMedia]) async throws { + guard media.isEmpty else { throw TeraComposerAcknowledgment.unconfirmed } + } + func prefersSharedForegroundUpload(ownerID _: String) async throws -> Bool { false } @@ -72,6 +77,10 @@ actor TeraAddMediaCoordinator: TeraAddMediaHandling { /// releases this caller's admission; OS transfer state remains authoritative. private var activeUploadDrafts: Set<String> = [] + func confirmDurableComposerMedia(_ media: [TeraComposerMedia]) throws { + try TeraComposerMediaOwnership.confirm(media, roots: roots) + } + func prefersSharedForegroundUpload(ownerID: String) async throws -> Bool { guard !RadrootsAppleBackgroundTransferAdapters.supportsNewEnqueue(for: .publicHTTPS) else { return false } // Preserve an existing native attempt for its own reconciliation path. diff --git a/Tera/Runtime/TeraComposerMediaOwnership.swift b/Tera/Runtime/TeraComposerMediaOwnership.swift @@ -0,0 +1,40 @@ +import CryptoKit +import Foundation +import RadrootsKit + +/// The Apple host proves file durability; Rust remains the sole owner of scoped +/// composer references. The two stores deliberately have no shared transaction. +enum TeraComposerMediaOwnership { + static func confirm(_ media: [TeraComposerMedia], roots: RadrootsAppleFileRoots) throws { + guard !media.isEmpty else { return } + let durable = try TeraDurableMediaRoots.selectingStaging(in: roots) + guard media.count <= 20, roots.stagedBlobsRoot == durable.stagedBlobsRoot else { + throw TeraComposerAcknowledgment.unconfirmed + } + let access = RadrootsAppleFileAccess(roots: roots) + for item in media { + try Task.checkCancellation() + let blob = try reference(item) + try TeraDurableMediaRoots.restoreLegacyBlob(blob, roots: roots) + // Governed reads reject symlinks and oversized/replaced files. Process one + // derivative at a time under the preparer's existing 10 MiB output bound. + let bytes = try access.readStagedBlob(blob) + let digest = SHA256.hash(data: bytes).map { String(format: "%02x", $0) }.joined() + guard digest == item.sha256 else { throw TeraComposerAcknowledgment.unconfirmed } + // This exact install flushes both file and directory, including an + // existing matching object. It never deletes another draft's reference. + try access.installStagedBlob(bytes, reference: blob) + } + // Hash-named published files are identifiable orphans if the following DB + // commit fails or its result is lost. Keep them for C087 reconciliation. + } + + private static func reference(_ item: TeraComposerMedia) throws -> RadrootsStagedBlobReference { + guard item.sha256.range(of: "^[0-9a-f]{64}$", options: .regularExpression) != nil, + item.opaqueReference == "media:\(item.sha256)", item.mediaType == "image/png", + (1 ... 10 * 1024 * 1024).contains(item.byteSize), let size = Int(exactly: item.byteSize) + else { throw TeraComposerAcknowledgment.unconfirmed } + return try RadrootsStagedBlobReference(blobID: item.sha256, sizeBytes: size, + mediaType: item.mediaType, filenameHint: "\(item.sha256).png") + } +} diff --git a/Tera/State/TeraAddStore.swift b/Tera/State/TeraAddStore.swift @@ -61,8 +61,8 @@ final class TeraAddStore: ObservableObject { TeraRuntimeObservationBackoff.sleep ) { self.runtimeClient = runtimeClient - recovery = TeraDraftRecoveryStore(client: runtimeClient) - composer = TeraComposerAutosave(persistence: TeraComposerPersistence(client: runtimeClient)) + recovery = TeraDraftRecoveryStore(client: runtimeClient, media: media) + composer = TeraComposerAutosave(persistence: TeraComposerPersistence(client: runtimeClient).protectingMedia(media)) submissions = TeraSubmissionStore(client: runtimeClient, composer: composer, media: media) self.media = media self.identifier = identifier diff --git a/Tera/State/TeraComposerAutosave.swift b/Tera/State/TeraComposerAutosave.swift @@ -171,6 +171,8 @@ final class TeraComposerAutosave { try ensureCurrent(requested) } guard !isDirty, let acknowledged else { throw lastFailure ?? TeraComposerAcknowledgment.unconfirmed } + try await confirmSaved(acknowledged, generation: requested) + guard !isDirty else { throw TeraComposerAcknowledgment.unconfirmed } return acknowledged } @@ -286,6 +288,23 @@ final class TeraComposerAutosave { /// Capture scheduling shares the private autosave worker; it owns no second persistence path. extension TeraComposerAutosave { + private func confirmSaved(_ draft: TeraComposerDraft, generation requested: TeraSessionGeneration) async throws { + do { + try await persistence.confirm(draft) + try ensureCurrent(requested) + guard acknowledged == draft else { throw TeraComposerAcknowledgment.unconfirmed } + if !isDirty { + state = .saved + } + } catch { + if generation == requested { + state = .failed + lastFailure = error + } + throw error + } + } + var hasSubmissionCapture: Bool { capture != nil } @@ -327,6 +346,7 @@ extension TeraComposerAutosave { guard capture == value, let acknowledged, matchesCapture(acknowledged, value) else { throw lastFailure ?? TeraComposerAcknowledgment.unconfirmed } + try await confirmSaved(acknowledged, generation: requested) return acknowledged } diff --git a/Tera/State/TeraComposerSaveState.swift b/Tera/State/TeraComposerSaveState.swift @@ -28,6 +28,7 @@ enum TeraComposerSaveState: Equatable { } struct TeraComposerPersistence: Sendable { + var confirm: @Sendable (TeraComposerDraft) async throws -> Void = { _ in } var reserve: @Sendable () async throws -> String var save: @Sendable (TeraComposerSaveRequest) async throws -> TeraComposerSaveReceipt var load: @Sendable (TeraComposerScope, String) async throws -> TeraComposerDraft @@ -47,6 +48,27 @@ struct TeraComposerPersistence: Sendable { self.save = save self.load = load } + + func protectingMedia(_ media: (any TeraAddMediaHandling)?) -> Self { + var guarded = Self(reserve: reserve, save: { request in + try await Self.confirm(request.form.media, using: media) + let receipt = try await save(request) + try await Self.confirm(receipt.draft.form.media, using: media) + return receipt + }, load: { scope, id in + let draft = try await load(scope, id) + try await Self.confirm(draft.form.media, using: media) + return draft + }) + guarded.confirm = { try await Self.confirm($0.form.media, using: media) } + return guarded + } + + private static func confirm(_ references: [TeraComposerMedia], using media: (any TeraAddMediaHandling)?) async throws { + guard !references.isEmpty else { return } + guard let media else { throw TeraComposerAcknowledgment.unconfirmed } + try await media.confirmDurableComposerMedia(references) + } } enum TeraComposerAcknowledgment { diff --git a/Tera/State/TeraDraftRecoveryStore.swift b/Tera/State/TeraDraftRecoveryStore.swift @@ -14,14 +14,16 @@ final class TeraDraftRecoveryStore: ObservableObject { @Published private(set) var isLoading = false private(set) var scope: TeraComposerScope? private let client: TeraRuntimeClient + private let composer: TeraComposerPersistence private var generation = TeraSessionGeneration.initial private var task: Task<Void, Never>? private var pending: Request? private enum Request { case first, composers(String), legacy(String) } - init(client: TeraRuntimeClient) { + init(client: TeraRuntimeClient, media: (any TeraAddMediaHandling)? = nil) { self.client = client + composer = TeraComposerPersistence(client: client).protectingMedia(media) } deinit { task?.cancel() } @@ -55,7 +57,7 @@ final class TeraDraftRecoveryStore: ObservableObject { let result: TeraRecoveredDraft switch selection { case let .composer(id): - let draft = try await client.loadComposer(scope: scope, id: id) + let draft = try await composer.load(scope, id) guard draft.scope == scope, draft.id == id else { throw TeraComposerAcknowledgment.unconfirmed } result = .composer(draft) case let .legacy(id): diff --git a/TeraFFI/provenance.json b/TeraFFI/provenance.json @@ -94,17 +94,17 @@ { "bytes": 100086, "path": "source/aarch64-apple-darwin.json", - "sha256": "29d8c8188c837eacc95d458c5b1748ddf6aac92f596e252eb4767cf790f41598" + "sha256": "5b023e8b835ebaba7fa1217a8ad7b230ac038cad68397d0cb57c462ccc6033a6" }, { "bytes": 99930, "path": "source/aarch64-apple-ios-sim.json", - "sha256": "c6b534fdb15f39071bcd25bbd8c99d04a5d401b8cfec95d7f82f4f694d365196" + "sha256": "619a7dc9d88e0870878aa0a81d7472272d9f9b9e21f44bd5105db82af5e1b5b3" }, { "bytes": 99926, "path": "source/aarch64-apple-ios.json", - "sha256": "1cb19169a4f27e72434cf03b67344a1b5e3518bb2d50a8790a52ab491950ad4c" + "sha256": "92fc8547484ec06b456e4dd8037d9134aa232162a471aa443da1619e893db9b3" } ], "language": "swift", @@ -112,7 +112,7 @@ "schema": "radroots.artifact-manifest.v2", "source": { "repository": "https://github.com/radrootslabs/tera", - "tree": "ed0cfde826cbbf8e8cc31c6a7e31f32abbf03405" + "tree": "abea66d625420814e307654d6777e86796424e24" }, "source_records": { "aarch64-apple-darwin": "source/aarch64-apple-darwin.json", @@ -176,17 +176,17 @@ { "bytes": 100086, "path": "TeraFFI/source/aarch64-apple-darwin.json", - "sha256": "29d8c8188c837eacc95d458c5b1748ddf6aac92f596e252eb4767cf790f41598" + "sha256": "5b023e8b835ebaba7fa1217a8ad7b230ac038cad68397d0cb57c462ccc6033a6" }, { "bytes": 99930, "path": "TeraFFI/source/aarch64-apple-ios-sim.json", - "sha256": "c6b534fdb15f39071bcd25bbd8c99d04a5d401b8cfec95d7f82f4f694d365196" + "sha256": "619a7dc9d88e0870878aa0a81d7472272d9f9b9e21f44bd5105db82af5e1b5b3" }, { "bytes": 99926, "path": "TeraFFI/source/aarch64-apple-ios.json", - "sha256": "1cb19169a4f27e72434cf03b67344a1b5e3518bb2d50a8790a52ab491950ad4c" + "sha256": "92fc8547484ec06b456e4dd8037d9134aa232162a471aa443da1619e893db9b3" } ], "schema": "tera.installed-native-artifacts.v1" diff --git a/TeraFFI/source.lock b/TeraFFI/source.lock @@ -1,7 +1,7 @@ schema = "tera.installed-source.v1" repository = "https://github.com/radrootslabs/tera" -source_tree = "ed0cfde826cbbf8e8cc31c6a7e31f32abbf03405" -manifest_sha256 = "a0404373b46709690e4a15bf027a4f0dfc304a3e6ae04858209fe65029a49662" +source_tree = "abea66d625420814e307654d6777e86796424e24" +manifest_sha256 = "6e587ec3129d2f90dd87d269593c2f200fd13ac29f411c5a2de850be36520698" source_date_epoch = 1787871027 [foundation] diff --git a/TeraFFI/source/aarch64-apple-darwin.json b/TeraFFI/source/aarch64-apple-darwin.json @@ -2133,13 +2133,13 @@ "sha256": "b052a73a824e8f8b26af2646a4758f13655e04de9551a8271890d3cf2b63209d" }, "test-fixtures/legacy-identifiers.v1.json": { - "bytes": 139987, - "git_blob": "8abe63f158fb94da5299a2a90d337110e92a7aaf", + "bytes": 140645, + "git_blob": "648d2b9887bc5864fcae84d9725f50fe634d89b3", "mode": "100644", - "sha256": "71ec0936fb41b90ed277259beb600f1456a20ca0871668052cc7a383fd564ffe" + "sha256": "91733c94feafe7a7e0f52863d2e98471782491177e4ddf9fddb7bc2e470a2c1e" } }, "policy": "staged_inputs", - "tree": "ed0cfde826cbbf8e8cc31c6a7e31f32abbf03405" + "tree": "abea66d625420814e307654d6777e86796424e24" } } diff --git a/TeraFFI/source/aarch64-apple-ios-sim.json b/TeraFFI/source/aarch64-apple-ios-sim.json @@ -2129,13 +2129,13 @@ "sha256": "b052a73a824e8f8b26af2646a4758f13655e04de9551a8271890d3cf2b63209d" }, "test-fixtures/legacy-identifiers.v1.json": { - "bytes": 139987, - "git_blob": "8abe63f158fb94da5299a2a90d337110e92a7aaf", + "bytes": 140645, + "git_blob": "648d2b9887bc5864fcae84d9725f50fe634d89b3", "mode": "100644", - "sha256": "71ec0936fb41b90ed277259beb600f1456a20ca0871668052cc7a383fd564ffe" + "sha256": "91733c94feafe7a7e0f52863d2e98471782491177e4ddf9fddb7bc2e470a2c1e" } }, "policy": "staged_inputs", - "tree": "ed0cfde826cbbf8e8cc31c6a7e31f32abbf03405" + "tree": "abea66d625420814e307654d6777e86796424e24" } } diff --git a/TeraFFI/source/aarch64-apple-ios.json b/TeraFFI/source/aarch64-apple-ios.json @@ -2129,13 +2129,13 @@ "sha256": "b052a73a824e8f8b26af2646a4758f13655e04de9551a8271890d3cf2b63209d" }, "test-fixtures/legacy-identifiers.v1.json": { - "bytes": 139987, - "git_blob": "8abe63f158fb94da5299a2a90d337110e92a7aaf", + "bytes": 140645, + "git_blob": "648d2b9887bc5864fcae84d9725f50fe634d89b3", "mode": "100644", - "sha256": "71ec0936fb41b90ed277259beb600f1456a20ca0871668052cc7a383fd564ffe" + "sha256": "91733c94feafe7a7e0f52863d2e98471782491177e4ddf9fddb7bc2e470a2c1e" } }, "policy": "staged_inputs", - "tree": "ed0cfde826cbbf8e8cc31c6a7e31f32abbf03405" + "tree": "abea66d625420814e307654d6777e86796424e24" } } diff --git a/TeraTests/TeraAddMediaHarness.swift b/TeraTests/TeraAddMediaHarness.swift @@ -2,6 +2,9 @@ import Foundation @testable import TeraApp actor AddMediaHarness: TeraAddMediaHandling { + /// Metadata-only fake backend tests; real file durability has separate fixtures. + func confirmDurableComposerMedia(_: [TeraComposerMedia]) {} + private let delayFirstUpload: Bool private let delaySettlement: Bool private let openPause: ResourceTestPause? diff --git a/TeraTests/TeraComposerMediaOwnershipTests.swift b/TeraTests/TeraComposerMediaOwnershipTests.swift @@ -0,0 +1,215 @@ +import Foundation +import RadrootsKit +@testable import TeraApp +import XCTest + +@MainActor +final class TeraComposerMediaOwnershipTests: XCTestCase { + func testLateFileConfirmationCannotAcknowledgeNewerEditing() async throws { + let storage = ComposerTestStorage() + let pause = ResourceTestPause() + var persistence = storage.port + persistence.confirm = { _ in await pause.wait() } + let autosave = TeraComposerAutosave(persistence: persistence, delay: {}) + autosave.reset(scope: TeraComposerScope(authorPublicKey: String(repeating: "a", count: 64), localNetworkID: "default")) + var first = TeraComposerForm(commandType: .createUpdate) + first.content = "first" + let original = first + let saving = Task { try await autosave.save(original) } + await pause.entered.wait() + first.content = "newer editing" + autosave.change(first) + await pause.resume.open() + do { _ = try await saving.value; XCTFail("Old file proof must not acknowledge new editing") } catch {} + let latest = try await autosave.save(first) + XCTAssertEqual(latest.form.content, "newer editing") + XCTAssertEqual(autosave.state, .saved) + autosave.stop() + } + + func testMissingCorruptSymlinkAndLockedFilesCannotCommitReferences() async throws { + for damage in ["missing", "corrupt", "symlink", "locked"] { + let fixture = try OfflineMediaFixture() + defer { fixture.remove() } + let client = TeraRuntimeClient.production() + let snapshot = try await client.start(configuration: fixture.configuration(ComposerForbiddenSigner())) + let coordinator = fixture.coordinator() + let request = try await request(client, coordinator: coordinator, snapshot: snapshot) + let item = try XCTUnwrap(request.form.media.first) + let path = fixture.roots.stagedBlobsRoot.appendingPathComponent(item.sha256) + let original = try Data(contentsOf: path) + try damageFile(path, bytes: original, damage: damage, root: fixture.runtime.root) + let persistence = TeraComposerPersistence(client: client).protectingMedia(coordinator) + do { + _ = try await persistence.save(request) + XCTFail("Unowned bytes must not be acknowledged: \(damage)") + } catch {} + let page = try await client.listComposers(scope: request.scope) + XCTAssertTrue(page.entries.isEmpty, damage) + if damage == "locked" { + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: path.path) + } + _ = try await client.stop() + } + } + + func testInterruptedBeforeDatabaseCommitRetainsIdentifiableOrphanAndRetries() async throws { + let fixture = try OfflineMediaFixture() + defer { fixture.remove() } + let client = TeraRuntimeClient.production() + let snapshot = try await client.start(configuration: fixture.configuration(ComposerForbiddenSigner())) + let coordinator = fixture.coordinator() + let request = try await request(client, coordinator: coordinator, snapshot: snapshot) + let path = try path(request, fixture: fixture) + let bytes = try Data(contentsOf: path) + // Inject the storage result at the file/DB seam. Producer tests separately + // exercise write/flush faults; this is not a physical disk exhaustion claim. + let failed = TeraComposerPersistence(reserve: { request.id }, save: { _ in throw POSIXError(.ENOSPC) }, + load: { try await client.loadComposer(scope: $0, id: $1) }).protectingMedia(coordinator) + do { _ = try await failed.save(request); XCTFail("Full storage must fail acknowledgment") } catch {} + XCTAssertEqual(try Data(contentsOf: path), bytes) + let absent = try await client.listComposers(scope: request.scope) + XCTAssertTrue(absent.entries.isEmpty) + _ = try await client.stop() + _ = try await client.start(configuration: fixture.configuration(ComposerForbiddenSigner())) + let restarted = TeraComposerPersistence(client: client).protectingMedia(fixture.coordinator()) + let saved = try await restarted.save(request) + XCTAssertEqual(saved.draft.form, request.form) + XCTAssertEqual(try Data(contentsOf: path), bytes) + _ = try await client.stop() + } + + func testLostDatabaseReplyReconcilesExactReferenceWithoutSecondWrite() async throws { + let fixture = try OfflineMediaFixture() + defer { fixture.remove() } + let client = TeraRuntimeClient.production() + let snapshot = try await client.start(configuration: fixture.configuration(ComposerForbiddenSigner())) + let coordinator = fixture.coordinator() + let request = try await request(client, coordinator: coordinator, snapshot: snapshot) + let unknown = TeraComposerPersistence(reserve: { request.id }, save: { value in + _ = try await client.saveComposer(request: value) + throw TeraComposerAcknowledgment.unconfirmed + }, load: { try await client.loadComposer(scope: $0, id: $1) }).protectingMedia(coordinator) + let autosave = TeraComposerAutosave(persistence: unknown) + autosave.reset(scope: request.scope) + do { _ = try await autosave.save(request.form); XCTFail("Lost reply is initially unknown") } catch {} + XCTAssertEqual(autosave.state, .failed) + XCTAssertNil(autosave.acknowledged) + let recovered = try await autosave.save(request.form) + XCTAssertEqual(recovered.revision, 1) + XCTAssertEqual(autosave.state, .saved) + let page = try await client.listComposers(scope: request.scope) + XCTAssertEqual(page.entries.count, 1) + autosave.stop() + _ = try await client.stop() + } + + func testMissingAfterCommitBlocksAcknowledgmentAndRestartUntilBytesRecover() async throws { + let fixture = try OfflineMediaFixture() + defer { fixture.remove() } + let client = TeraRuntimeClient.production() + let snapshot = try await client.start(configuration: fixture.configuration(ComposerForbiddenSigner())) + let coordinator = fixture.coordinator() + let request = try await request(client, coordinator: coordinator, snapshot: snapshot) + let path = try path(request, fixture: fixture) + let bytes = try Data(contentsOf: path) + let interrupted = TeraComposerPersistence(reserve: { request.id }, save: { value in + let receipt = try await client.saveComposer(request: value) + try FileManager.default.removeItem(at: path) + return receipt + }, load: { try await client.loadComposer(scope: $0, id: $1) }).protectingMedia(coordinator) + do { _ = try await interrupted.save(request); XCTFail("Missing post-commit bytes cannot report saved") } catch {} + let raw = try await client.loadComposer(scope: request.scope, id: request.id) + XCTAssertEqual(raw.form, request.form) + _ = try await client.stop() + let restarted = try await client.start(configuration: fixture.configuration(ComposerForbiddenSigner())) + let store = TeraAddStore(runtimeClient: client, media: fixture.coordinator()) + store.configure(snapshot: restarted) + await store.start() + let refused = await store.reopenSaved(.composer(request.id)) + XCTAssertFalse(refused) + XCTAssertNil(store.savedComposer) + try bytes.write(to: path) + let reopened = await store.reopenSaved(.composer(request.id)) + XCTAssertTrue(reopened) + XCTAssertEqual(store.savedComposer, raw) + try FileManager.default.removeItem(at: path) + await store.save() + XCTAssertEqual(store.composerState, .failed) + XCTAssertNotEqual(store.message, "Draft saved on this device.") + store.stop() + _ = try await client.stop() + } + + func testSharedHashSurvivesFailedOtherOwnerAndCachePurge() async throws { + let fixture = try OfflineMediaFixture() + defer { fixture.remove() } + let client = TeraRuntimeClient.production() + let snapshot = try await client.start(configuration: fixture.configuration(ComposerForbiddenSigner())) + let coordinator = fixture.coordinator() + let first = try await request(client, coordinator: coordinator, snapshot: snapshot) + let persistence = TeraComposerPersistence(client: client).protectingMedia(coordinator) + let saved = try await persistence.save(first) + let second = try await TeraComposerSaveRequest(scope: first.scope, id: client.reserveComposerID(), + expectedRevision: 1, editSequence: 1, form: first.form) + do { _ = try await persistence.save(second); XCTFail("Missing owner revision must fail") } catch {} + try FileManager.default.removeItem(at: fixture.roots.cacheRoot) + if FileManager.default.fileExists(atPath: fixture.roots.temporaryRoot.path) { + try FileManager.default.removeItem(at: fixture.roots.temporaryRoot) + } + _ = try await client.stop() + _ = try await client.start(configuration: fixture.configuration(ComposerForbiddenSigner())) + let recovered = try await TeraComposerPersistence(client: client).protectingMedia(fixture.coordinator()) + .load(first.scope, first.id) + XCTAssertEqual(recovered, saved.draft) + _ = try await client.stop() + } + + func testEvictableRootsAndAbsentMediaOwnerFailClosed() async throws { + let fixture = try OfflineMediaFixture() + defer { fixture.remove() } + let client = TeraRuntimeClient.production() + let snapshot = try await client.start(configuration: fixture.configuration(ComposerForbiddenSigner())) + let request = try await request(client, coordinator: fixture.coordinator(), snapshot: snapshot) + let legacy = try RadrootsAppleFileRoots(appIdentifier: fixture.roots.appIdentifier, + dataRoot: fixture.roots.dataRoot, cacheRoot: fixture.roots.cacheRoot, + temporaryRoot: fixture.roots.temporaryRoot) + XCTAssertThrowsError(try TeraComposerMediaOwnership.confirm(request.form.media, roots: legacy)) + do { + _ = try await TeraComposerPersistence(client: client).protectingMedia(nil).save(request) + XCTFail("Media needs an explicit durable file owner") + } catch {} + let page = try await client.listComposers(scope: request.scope) + XCTAssertTrue(page.entries.isEmpty) + _ = try await client.stop() + } + + private func request(_ client: TeraRuntimeClient, coordinator: TeraAddMediaCoordinator, + snapshot: TeraRuntimeSnapshot) async throws -> TeraComposerSaveRequest + { + var editing = TeraAddPresentation.newForm(type: .createPhotoUpdate, identifier: { "draft" }, clock: .system) + editing.media = try await coordinator.importImages(limit: 1) + let configuration = TeraPresentationConfiguration(snapshot: snapshot) + return try await TeraComposerSaveRequest( + scope: TeraComposerScope(authorPublicKey: configuration.publicKey, localNetworkID: configuration.context.id), + id: client.reserveComposerID(), expectedRevision: nil, editSequence: 1, form: TeraComposerForm(editing: editing) + ) + } + + private func path(_ request: TeraComposerSaveRequest, fixture: OfflineMediaFixture) throws -> URL { + try fixture.roots.stagedBlobsRoot.appendingPathComponent(XCTUnwrap(request.form.media.first).sha256) + } + + private func damageFile(_ path: URL, bytes: Data, damage: String, root: URL) throws { + switch damage { + case "missing": try FileManager.default.removeItem(at: path) + case "corrupt": try Data(repeating: 0, count: bytes.count).write(to: path) + case "locked": try FileManager.default.setAttributes([.posixPermissions: 0], ofItemAtPath: path.path) + default: + let outside = root.appendingPathComponent("outside") + try bytes.write(to: outside) + try FileManager.default.removeItem(at: path) + try FileManager.default.createSymbolicLink(at: path, withDestinationURL: outside) + } + } +} diff --git a/TeraTests/TeraComposerPersistenceFFITests.swift b/TeraTests/TeraComposerPersistenceFFITests.swift @@ -153,14 +153,14 @@ final class TeraComposerPersistenceFFITests: XCTestCase { @MainActor func testStartupRecoveryPagesBeyondOneHundredAndReopensExactIncompleteRevision() async throws { - let fixture = try MediaOwnershipFixture() + let fixture = try OfflineMediaFixture() defer { fixture.remove() } let signer = ComposerForbiddenSigner() - let configuration = configuration(fixture, signer: signer) + let configuration = configuration(fixture.runtime, signer: signer) let client = TeraRuntimeClient.production() _ = try await client.start(configuration: configuration) let scope = TeraComposerScope(authorPublicKey: scope.authorPublicKey, localNetworkID: "default") - let form = partialForm(fixture) + let form = try await durablePartialForm(fixture) var ids = Set<String>() var saved: TeraComposerDraft? for _ in 0 ..< 101 { @@ -173,7 +173,7 @@ final class TeraComposerPersistenceFFITests: XCTestCase { let selected = try XCTUnwrap(saved) _ = try await client.stop() let snapshot = try await client.start(configuration: configuration) - let store = TeraAddStore(runtimeClient: client) + let store = TeraAddStore(runtimeClient: client, media: fixture.coordinator()) store.configure(snapshot: snapshot) await store.start() try await assertRecoveryPages(store.recovery, ids: ids) @@ -201,6 +201,15 @@ final class TeraComposerPersistenceFFITests: XCTestCase { } @MainActor + private func durablePartialForm(_ fixture: OfflineMediaFixture) async throws -> TeraComposerForm { + var form = partialForm(fixture.runtime) + var editing = TeraAddForm(commandType: .createEvent) + editing.media = try await fixture.coordinator().importImages(limit: 1) + form.media = TeraComposerForm(editing: editing).media + return form + } + + @MainActor private func assertRecoveryPages(_ recovery: TeraDraftRecoveryStore, ids: Set<String>) async throws { await TeraScopeFixtures.eventually { !recovery.isLoading } XCTAssertNil(recovery.composerError) diff --git a/TeraTests/TeraOfflineMediaTests.swift b/TeraTests/TeraOfflineMediaTests.swift @@ -178,7 +178,7 @@ final class TeraOfflineMediaTests: XCTestCase { } @MainActor -private struct OfflineMediaFixture { +struct OfflineMediaFixture { let runtime: MediaOwnershipFixture let roots: RadrootsAppleFileRoots let picker: OfflineMediaPicker @@ -186,10 +186,10 @@ private struct OfflineMediaFixture { init() throws { runtime = try MediaOwnershipFixture() - roots = try RadrootsAppleFileRoots(appIdentifier: "test.offline-media", - dataRoot: runtime.root.appendingPathComponent("media/data"), - cacheRoot: runtime.root.appendingPathComponent("media/cache"), - temporaryRoot: runtime.root.appendingPathComponent("media/temporary")) + roots = try TeraDurableMediaRoots.selectingStaging(in: RadrootsAppleFileRoots(appIdentifier: "test.offline-media", + dataRoot: runtime.root.appendingPathComponent("media/data"), + cacheRoot: runtime.root.appendingPathComponent("media/cache"), + temporaryRoot: runtime.root.appendingPathComponent("media/temporary"))) try FileManager.default.createDirectory(at: roots.cacheRoot, withIntermediateDirectories: true) let bytes = UIGraphicsImageRenderer(size: CGSize(width: 2, height: 2)).pngData { context in UIColor.green.setFill() @@ -220,7 +220,7 @@ private struct OfflineMediaFixture { } } -private actor OfflineMediaPicker: RadrootsMediaPicker { +actor OfflineMediaPicker: RadrootsMediaPicker { let byteSize: UInt64 private var denied = false private var available = true diff --git a/project.yml b/project.yml @@ -52,6 +52,7 @@ targets: - path: TeraTests/TeraSubmissionStoreTests.swift - path: TeraTests/TeraForegroundSubmissionTests.swift - path: TeraTests/TeraDurableMediaRootsTests.swift + - path: TeraTests/TeraComposerMediaOwnershipTests.swift - path: TeraTests/TeraPublicationEvidenceTests.swift - path: TeraTests/TeraStoppedUploadTests.swift - path: TeraTests/TeraSubmissionOperationFFITests.swift diff --git a/release/provenance.json b/release/provenance.json @@ -2,11 +2,11 @@ "artifacts": { "app_api_sha256": "020924097c0d7efc33128cb8fd3d3b2026d95f57c44da71880e585aff80f070b", "ffi_api_sha256": "7b3f92a57ebcc7873349403243098d5dcf993b2dbdab9ed84fea4a50c20baab0", - "ffi_provenance_sha256": "a0404373b46709690e4a15bf027a4f0dfc304a3e6ae04858209fe65029a49662", + "ffi_provenance_sha256": "6e587ec3129d2f90dd87d269593c2f200fd13ac29f411c5a2de850be36520698", "info_plist_sha256": "15ef08b1cdd1096cfb9eeaf5be5bf8f814807a7ca9350bbbb47860fa72ec13ef", "privacy_manifest_sha256": "a331d51864743ebe4e00dd22360b4a538b6b3ac26a6b3eb54094e60a36959a12", "sbom_sha256": "b202075f3c34a3a70e8745ca6d6acab6c2cf5e2d6de9a2b2647475d73a8d3f28", - "xcode_project_sha256": "b2a88d8a84f80bf8f677aeb6036c524302f93ca1f4d1bc8c9ac9329c63541053" + "xcode_project_sha256": "d0d27e62cc19969831c559f12e8165ac009cec1aa1804e19ff133e6f59fb5a49" }, "disposition": "unsigned", "platforms": [ @@ -22,7 +22,7 @@ "lib_revision": "963dd8dc99fc5f16ef7d7eea11f8d964770a696e", "source_date_epoch": 1787871027, "swift_package_lock_sha256": "d5c9240384b50f006ee6e9eff886a3775546280e90a05cbe781eaa296632da26", - "tera_ffi_source_tree": "ed0cfde826cbbf8e8cc31c6a7e31f32abbf03405", + "tera_ffi_source_tree": "abea66d625420814e307654d6777e86796424e24", "xcode_package_lock_sha256": "b6806f710734f8e628d7a7d7b97826d89eea78fba32fa9b464f513bcadd411e2" }, "version": "0.1.0-alpha" diff --git a/test-fixtures/legacy-identifiers.v1.json b/test-fixtures/legacy-identifiers.v1.json @@ -191,6 +191,10 @@ "count": 2 }, { + "path": "Tera/Runtime/TeraComposerMediaOwnership.swift", + "count": 1 + }, + { "path": "Tera/Runtime/TeraDurableMediaRoots.swift", "count": 2 }, @@ -259,6 +263,10 @@ "count": 4 }, { + "path": "Tera/Runtime/TeraComposerMediaOwnership.swift", + "count": 1 + }, + { "path": "Tera/Runtime/TeraDurableMediaRoots.swift", "count": 5 }, @@ -279,6 +287,10 @@ "count": 2 }, { + "path": "TeraTests/TeraComposerMediaOwnershipTests.swift", + "count": 1 + }, + { "path": "TeraTests/TeraDurableMediaRootsTests.swift", "count": 2 }, @@ -1121,6 +1133,10 @@ "count": 1 }, { + "path": "Tera/Runtime/TeraComposerMediaOwnership.swift", + "count": 1 + }, + { "path": "Tera/Runtime/TeraDurableMediaRoots.swift", "count": 1 }, @@ -1173,6 +1189,10 @@ "count": 1 }, { + "path": "TeraTests/TeraComposerMediaOwnershipTests.swift", + "count": 1 + }, + { "path": "TeraTests/TeraDurableMediaRootsTests.swift", "count": 1 }, @@ -1545,6 +1565,10 @@ "count": 2 }, { + "path": "Tera/Runtime/TeraComposerMediaOwnership.swift", + "count": 2 + }, + { "path": "Tera/Runtime/TeraDurableMediaRoots.swift", "count": 1 },