field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | README | LICENSE

commit f4cb59b4e65d6f10dcffe3e3d65f77008e52780d
parent 2ea1aadf09bd6f1e355e82323be763c3214672d3
Author: triesap <tyson@radroots.org>
Date:   Tue, 22 Sep 2026 02:46:32 +0000

storage: qualify interrupted migration recovery

- Validate retained media before accepting a migrated destination
- Preserve source and conflicting files across repeated restart
- Qualify every existing database rebuild boundary
- Verify exact native artifacts and unchanged public contracts

Diffstat:
MREADME.md | 8++++++++
MTera/Runtime/TeraDurableMediaRoots.swift | 22++++++++++++++++------
MTeraFFI/provenance.json | 14+++++++-------
MTeraFFI/source.lock | 4++--
MTeraFFI/source/aarch64-apple-darwin.json | 14+++++++-------
MTeraFFI/source/aarch64-apple-ios-sim.json | 14+++++++-------
MTeraFFI/source/aarch64-apple-ios.json | 14+++++++-------
MTeraTests/TeraDurableMediaRootsTests.swift | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcore/crates/tera_core/src/runtime/product_surface/today_calendar_migration_tests.rs | 70++++++++++++++++++++++++++++++++++++++++++++--------------------------
Mrelease/provenance.json | 4++--
Mtest-fixtures/legacy-identifiers.v1.json | 16++++++++++++++--
11 files changed, 179 insertions(+), 66 deletions(-)

diff --git a/README.md b/README.md @@ -344,3 +344,11 @@ generation before writable setup; unsupported versions or incompatible state remain typed failures with original evidence retained. Protected-data absence also fails before opening storage. Startup does not rename directories, import arbitrary paths, repair permissions or silently replace identity custody. + +Interrupted calendar rebuilds reconcile through the existing storage owner's +durable records, retaining legacy bytes and publication identities. Lazy +prepared-photo recovery validates an existing destination before accepting it; +conflicts require recovery without overwriting either copy. A verified legacy +copy is installed only after confirming destination absence, and the old copy +remains available across repeated restarts. Unpublished partial staging is not +treated as a completed photo or discarded by migration. diff --git a/Tera/Runtime/TeraDurableMediaRoots.swift b/Tera/Runtime/TeraDurableMediaRoots.swift @@ -14,21 +14,31 @@ enum TeraDurableMediaRoots { } static func restoreLegacyBlob(_ blob: RadrootsStagedBlobReference, roots: RadrootsAppleFileRoots) throws { - let destination = try roots.stagedBlobURL(for: blob) - guard !FileManager.default.fileExists(atPath: destination.path) else { return } + let access = RadrootsAppleFileAccess(roots: roots) + do { + try validate(access.readStagedBlob(blob), reference: blob) + return + } catch RadrootsAppleFileError.notFound { + // Only definitive absence permits the legacy copy to be installed. + // Conflicting, protected or unsafe destinations require recovery. + } let legacy = try RadrootsAppleFileRoots( appIdentifier: roots.appIdentifier, dataRoot: roots.dataRoot, cacheRoot: roots.cacheRoot, temporaryRoot: roots.temporaryRoot, logsRoot: roots.logsRoot ) - guard legacy.stagedBlobsRoot != roots.stagedBlobsRoot else { return } + guard legacy.stagedBlobsRoot != roots.stagedBlobsRoot else { throw RadrootsAppleFileError.notFound } let bytes = try RadrootsAppleFileAccess(roots: legacy).readStagedBlob(blob) + try validate(bytes, reference: blob) + // Preserve the legacy copy and reconcile a completed install by its exact + // existing reference on restart, without introducing a second journal. + try access.installStagedBlob(bytes, reference: blob) + } + + private static func validate(_ bytes: Data, reference blob: RadrootsStagedBlobReference) throws { let digest = SHA256.hash(data: bytes).map { String(format: "%02x", $0) }.joined() guard digest == blob.blobID else { throw TeraRuntimeFailure.local(operation: "add.media.migrate", code: "ios.add.media_corrupt", safeMessage: "A prepared photo could not be verified on this device.") } - // The generic producer supports opaque IDs, so verify the application's - // content identity before atomic installation. Preserve the legacy copy. - try RadrootsAppleFileAccess(roots: roots).installStagedBlob(bytes, reference: blob) } } diff --git a/TeraFFI/provenance.json b/TeraFFI/provenance.json @@ -94,17 +94,17 @@ { "bytes": 125174, "path": "source/aarch64-apple-darwin.json", - "sha256": "f0204686d75280e3fab1716e81b2b546c39d764f70109ab2387518f0e949eeb5" + "sha256": "d9adb4c7cb2103290a5270a5d335e7c41a308cb375b2491e33d44458db22734e" }, { "bytes": 125018, "path": "source/aarch64-apple-ios-sim.json", - "sha256": "e75c9e4fcf1ec5ea62688ce6cc95d4c2a6631e7fdf21f488ea7b8a6d14e64cc1" + "sha256": "20e0ff74f44f2c9b6728b17ab73fae39e28ff9188172baf6c738fff979e86d41" }, { "bytes": 125014, "path": "source/aarch64-apple-ios.json", - "sha256": "c94e067a9120c82a47bc815d992242db68a38c339fa6ffec881c2690a478f5e8" + "sha256": "af700f0a27fc0698d5ea265138f8d4d305da1a791e21e02e65916be7d9c32558" } ], "language": "swift", @@ -112,7 +112,7 @@ "schema": "radroots.artifact-manifest.v2", "source": { "repository": "https://github.com/radrootslabs/tera", - "tree": "610c64f026927441de580af4b1ea13c8e1430c84" + "tree": "6f0421a49bba8a64068795151c80baaeecedffa5" }, "source_records": { "aarch64-apple-darwin": "source/aarch64-apple-darwin.json", @@ -176,17 +176,17 @@ { "bytes": 125174, "path": "TeraFFI/source/aarch64-apple-darwin.json", - "sha256": "f0204686d75280e3fab1716e81b2b546c39d764f70109ab2387518f0e949eeb5" + "sha256": "d9adb4c7cb2103290a5270a5d335e7c41a308cb375b2491e33d44458db22734e" }, { "bytes": 125018, "path": "TeraFFI/source/aarch64-apple-ios-sim.json", - "sha256": "e75c9e4fcf1ec5ea62688ce6cc95d4c2a6631e7fdf21f488ea7b8a6d14e64cc1" + "sha256": "20e0ff74f44f2c9b6728b17ab73fae39e28ff9188172baf6c738fff979e86d41" }, { "bytes": 125014, "path": "TeraFFI/source/aarch64-apple-ios.json", - "sha256": "c94e067a9120c82a47bc815d992242db68a38c339fa6ffec881c2690a478f5e8" + "sha256": "af700f0a27fc0698d5ea265138f8d4d305da1a791e21e02e65916be7d9c32558" } ], "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 = "610c64f026927441de580af4b1ea13c8e1430c84" -manifest_sha256 = "93dfe03bac3c324eedf94abbe2ca8242ef5c12c2d69db30ebe6a772c508da448" +source_tree = "6f0421a49bba8a64068795151c80baaeecedffa5" +manifest_sha256 = "7b462ae3ccd0d0ea7966b72f2d4125410ced74ccae623755232f12bdf3dc135a" source_date_epoch = 1787871027 [foundation] diff --git a/TeraFFI/source/aarch64-apple-darwin.json b/TeraFFI/source/aarch64-apple-darwin.json @@ -1569,10 +1569,10 @@ "sha256": "fa01f28b24461f35effe7a943a44aba8381c547b8949dd59d3b82f211124afee" }, "core/crates/tera_core/src/runtime/product_surface/today_calendar_migration_tests.rs": { - "bytes": 22286, - "git_blob": "bb66620255076230165b59f507331704aa41cba7", + "bytes": 22836, + "git_blob": "cbbb1520e5b8bbc5b7a99f87eca534eb72a426bf", "mode": "100644", - "sha256": "4af6ed960986c6d606d07c0589958b52b131c44a334e6a0a762aa5a7e502d2c6" + "sha256": "9cf1f4aba644abe1d1b382a86bcacd3dabfd8cb3d05dd08c935eb227745f0b63" }, "core/crates/tera_core/src/runtime/product_surface/today_calendar_tests.rs": { "bytes": 3065, @@ -2649,13 +2649,13 @@ "sha256": "b052a73a824e8f8b26af2646a4758f13655e04de9551a8271890d3cf2b63209d" }, "test-fixtures/legacy-identifiers.v1.json": { - "bytes": 169590, - "git_blob": "511d07184624c8f91d31f98e4ce68921eb44c9af", + "bytes": 169906, + "git_blob": "cec1388ac8f40f88a5a0bdf1400538c44962587c", "mode": "100644", - "sha256": "6cbb20cabd02f5b55f39967dd1b856b6faea778037235fd7614340781092f094" + "sha256": "6379a500399810ca8097d1d5fcb5223836d978254e2d05d4fb9602d4e29cfd58" } }, "policy": "staged_inputs", - "tree": "610c64f026927441de580af4b1ea13c8e1430c84" + "tree": "6f0421a49bba8a64068795151c80baaeecedffa5" } } diff --git a/TeraFFI/source/aarch64-apple-ios-sim.json b/TeraFFI/source/aarch64-apple-ios-sim.json @@ -1565,10 +1565,10 @@ "sha256": "fa01f28b24461f35effe7a943a44aba8381c547b8949dd59d3b82f211124afee" }, "core/crates/tera_core/src/runtime/product_surface/today_calendar_migration_tests.rs": { - "bytes": 22286, - "git_blob": "bb66620255076230165b59f507331704aa41cba7", + "bytes": 22836, + "git_blob": "cbbb1520e5b8bbc5b7a99f87eca534eb72a426bf", "mode": "100644", - "sha256": "4af6ed960986c6d606d07c0589958b52b131c44a334e6a0a762aa5a7e502d2c6" + "sha256": "9cf1f4aba644abe1d1b382a86bcacd3dabfd8cb3d05dd08c935eb227745f0b63" }, "core/crates/tera_core/src/runtime/product_surface/today_calendar_tests.rs": { "bytes": 3065, @@ -2645,13 +2645,13 @@ "sha256": "b052a73a824e8f8b26af2646a4758f13655e04de9551a8271890d3cf2b63209d" }, "test-fixtures/legacy-identifiers.v1.json": { - "bytes": 169590, - "git_blob": "511d07184624c8f91d31f98e4ce68921eb44c9af", + "bytes": 169906, + "git_blob": "cec1388ac8f40f88a5a0bdf1400538c44962587c", "mode": "100644", - "sha256": "6cbb20cabd02f5b55f39967dd1b856b6faea778037235fd7614340781092f094" + "sha256": "6379a500399810ca8097d1d5fcb5223836d978254e2d05d4fb9602d4e29cfd58" } }, "policy": "staged_inputs", - "tree": "610c64f026927441de580af4b1ea13c8e1430c84" + "tree": "6f0421a49bba8a64068795151c80baaeecedffa5" } } diff --git a/TeraFFI/source/aarch64-apple-ios.json b/TeraFFI/source/aarch64-apple-ios.json @@ -1565,10 +1565,10 @@ "sha256": "fa01f28b24461f35effe7a943a44aba8381c547b8949dd59d3b82f211124afee" }, "core/crates/tera_core/src/runtime/product_surface/today_calendar_migration_tests.rs": { - "bytes": 22286, - "git_blob": "bb66620255076230165b59f507331704aa41cba7", + "bytes": 22836, + "git_blob": "cbbb1520e5b8bbc5b7a99f87eca534eb72a426bf", "mode": "100644", - "sha256": "4af6ed960986c6d606d07c0589958b52b131c44a334e6a0a762aa5a7e502d2c6" + "sha256": "9cf1f4aba644abe1d1b382a86bcacd3dabfd8cb3d05dd08c935eb227745f0b63" }, "core/crates/tera_core/src/runtime/product_surface/today_calendar_tests.rs": { "bytes": 3065, @@ -2645,13 +2645,13 @@ "sha256": "b052a73a824e8f8b26af2646a4758f13655e04de9551a8271890d3cf2b63209d" }, "test-fixtures/legacy-identifiers.v1.json": { - "bytes": 169590, - "git_blob": "511d07184624c8f91d31f98e4ce68921eb44c9af", + "bytes": 169906, + "git_blob": "cec1388ac8f40f88a5a0bdf1400538c44962587c", "mode": "100644", - "sha256": "6cbb20cabd02f5b55f39967dd1b856b6faea778037235fd7614340781092f094" + "sha256": "6379a500399810ca8097d1d5fcb5223836d978254e2d05d4fb9602d4e29cfd58" } }, "policy": "staged_inputs", - "tree": "610c64f026927441de580af4b1ea13c8e1430c84" + "tree": "6f0421a49bba8a64068795151c80baaeecedffa5" } } diff --git a/TeraTests/TeraDurableMediaRootsTests.swift b/TeraTests/TeraDurableMediaRootsTests.swift @@ -57,6 +57,71 @@ final class TeraDurableMediaRootsTests: XCTestCase { let count = await transfer.enqueueCount XCTAssertEqual(count, 0) } + + func testConflictingDestinationIsRefusedAcrossRestartsWithoutReplacingEitherCopy() throws { + for symlink in [false, true] { + let fixture = try DurableMediaFixture() + defer { fixture.remove() } + let roots = try TeraDurableMediaRoots.selectingStaging(in: fixture.legacy) + let destination = try roots.stagedBlobURL(for: fixture.blob) + try FileManager.default.createDirectory(at: roots.stagedBlobsRoot, withIntermediateDirectories: true) + let conflict = Data(repeating: 0, count: fixture.bytes.count) + let outside = fixture.root.appendingPathComponent("unrelated") + if symlink { + try conflict.write(to: outside) + try FileManager.default.createSymbolicLink(at: destination, withDestinationURL: outside) + } else { + try conflict.write(to: destination) + } + for _ in 0 ..< 2 { + let reopened = try TeraDurableMediaRoots.selectingStaging(in: fixture.legacy) + XCTAssertThrowsError(try TeraDurableMediaRoots.restoreLegacyBlob(fixture.blob, roots: reopened)) { error in + if symlink { + XCTAssertEqual(error as? RadrootsAppleFileError, .permanentFailure) + } else { + XCTAssertEqual((error as? TeraRuntimeFailure)?.code, "ios.add.media_corrupt") + } + } + XCTAssertEqual(try Data(contentsOf: destination), conflict) + XCTAssertEqual(try Data(contentsOf: fixture.legacyURL), fixture.bytes) + if symlink { + XCTAssertEqual(try FileManager.default.destinationOfSymbolicLink(atPath: destination.path), outside.path) + } + } + } + } + + func testPartialLegacySourceRemainsRecoverableWithoutPublishingAnEmptyReplacement() throws { + let fixture = try DurableMediaFixture() + defer { fixture.remove() } + let partial = Data(fixture.bytes.prefix(4)) + try partial.write(to: fixture.legacyURL) + for _ in 0 ..< 2 { + let roots = try TeraDurableMediaRoots.selectingStaging(in: fixture.legacy) + XCTAssertThrowsError(try TeraDurableMediaRoots.restoreLegacyBlob(fixture.blob, roots: roots)) + XCTAssertFalse(try FileManager.default.fileExists(atPath: roots.stagedBlobURL(for: fixture.blob).path)) + XCTAssertEqual(try Data(contentsOf: fixture.legacyURL), partial) + } + } + + func testUnpublishedPartialFileIsRetainedAndPublishedCopyReconcilesOnRepeatedRestart() throws { + let fixture = try DurableMediaFixture() + defer { fixture.remove() } + let roots = try TeraDurableMediaRoots.selectingStaging(in: fixture.legacy) + try FileManager.default.createDirectory(at: roots.stagedBlobsRoot, withIntermediateDirectories: true) + let pending = roots.stagedBlobsRoot.appendingPathComponent(".radroots_pending_12345678-1234-1234-1234-123456789abc") + let partial = Data("unpublished partial bytes".utf8) + try partial.write(to: pending) + for _ in 0 ..< 3 { + let reopened = try TeraDurableMediaRoots.selectingStaging(in: fixture.legacy) + try TeraDurableMediaRoots.restoreLegacyBlob(fixture.blob, roots: reopened) + XCTAssertEqual(try RadrootsAppleFileAccess(roots: reopened).readStagedBlob(fixture.blob), fixture.bytes) + XCTAssertEqual(try Data(contentsOf: fixture.legacyURL), fixture.bytes) + XCTAssertEqual(try Data(contentsOf: pending), partial) + XCTAssertEqual(try Set(FileManager.default.contentsOfDirectory(atPath: roots.stagedBlobsRoot.path)), + [fixture.blob.blobID, pending.lastPathComponent]) + } + } } private struct DurableMediaFixture { diff --git a/core/crates/tera_core/src/runtime/product_surface/today_calendar_migration_tests.rs b/core/crates/tera_core/src/runtime/product_surface/today_calendar_migration_tests.rs @@ -227,7 +227,14 @@ async fn old_sqlite_calendar_migrates_on_all_readers_without_changing_pending_id #[tokio::test] async fn every_durable_rebuild_boundary_resumes_after_sqlite_reopen() { - for boundary in ["invalidated", "requested", "running", "staged"] { + for boundary in [ + "before", + "invalidated", + "requested", + "running", + "staged", + "completed", + ] { let root = tempfile::tempdir().unwrap(); let runtime = open(root.path()).await; seed_legacy(&runtime).await; @@ -241,11 +248,13 @@ async fn every_durable_rebuild_boundary_resumes_after_sqlite_reopen() { NOW * 1000, ) .unwrap(); - ProjectionStore::invalidate(storage, invalidation.clone()) - .await - .unwrap(); + if boundary != "before" { + ProjectionStore::invalidate(storage, invalidation.clone()) + .await + .unwrap(); + } let id = RebuildTicketId::new([7; 16]).unwrap(); - if boundary != "invalidated" { + if !matches!(boundary, "before" | "invalidated") { ProjectionStore::request_rebuild( storage, RebuildTicket::requested( @@ -260,7 +269,7 @@ async fn every_durable_rebuild_boundary_resumes_after_sqlite_reopen() { .await .unwrap(); } - if matches!(boundary, "running" | "staged") { + if matches!(boundary, "running" | "staged" | "completed") { calendar_migration::begin( storage, NOW * 1000, @@ -285,35 +294,44 @@ async fn every_durable_rebuild_boundary_resumes_after_sqlite_reopen() { .await .unwrap(); } - assert!( + if boundary == "completed" { + runtime + .phase1_today_page(&context(None, 1), TodayPageRequest::first(20, NOW, "UTC")) + .await + .unwrap(); + } + assert_eq!( load_state(storage, &context(None, 1), projection_generation().unwrap()) .await .unwrap() - .is_none() + .is_some(), + boundary == "completed" ); let original = old_bytes(&runtime).await; let signed_before = raw(&runtime).await; runtime.shutdown().await.unwrap(); drop(runtime); - let runtime = open(root.path()).await; - runtime - .phase1_today_page(&context(None, 1), TodayPageRequest::first(20, NOW, "UTC")) - .await - .unwrap(); - assert_current(&runtime).await; - assert_eq!(old_bytes(&runtime).await, original, "{boundary}"); - assert_eq!(raw(&runtime).await, signed_before, "{boundary}"); - if boundary != "invalidated" { - assert_eq!( - ProjectionStore::rebuild(runtime.client.storage().unwrap(), id) - .await - .unwrap() - .unwrap() - .stage(), - RebuildStage::Completed - ); + for _ in 0..2 { + let runtime = open(root.path()).await; + runtime + .phase1_today_page(&context(None, 1), TodayPageRequest::first(20, NOW, "UTC")) + .await + .unwrap(); + assert_current(&runtime).await; + assert_eq!(old_bytes(&runtime).await, original, "{boundary}"); + assert_eq!(raw(&runtime).await, signed_before, "{boundary}"); + if !matches!(boundary, "before" | "invalidated") { + assert_eq!( + ProjectionStore::rebuild(runtime.client.storage().unwrap(), id) + .await + .unwrap() + .unwrap() + .stage(), + RebuildStage::Completed + ); + } + runtime.shutdown().await.unwrap(); } - runtime.shutdown().await.unwrap(); } } diff --git a/release/provenance.json b/release/provenance.json @@ -2,7 +2,7 @@ "artifacts": { "app_api_sha256": "020924097c0d7efc33128cb8fd3d3b2026d95f57c44da71880e585aff80f070b", "ffi_api_sha256": "81a943ef98405676b4f65932a4a8d4bdb4011923f65fa2e3496fcb58e1d68be8", - "ffi_provenance_sha256": "93dfe03bac3c324eedf94abbe2ca8242ef5c12c2d69db30ebe6a772c508da448", + "ffi_provenance_sha256": "7b462ae3ccd0d0ea7966b72f2d4125410ced74ccae623755232f12bdf3dc135a", "info_plist_sha256": "15ef08b1cdd1096cfb9eeaf5be5bf8f814807a7ca9350bbbb47860fa72ec13ef", "privacy_manifest_sha256": "a331d51864743ebe4e00dd22360b4a538b6b3ac26a6b3eb54094e60a36959a12", "sbom_sha256": "a55b66226b2a9114210077a0da0611ac68b74d4016ca499df2f80d3f915be2ec", @@ -22,7 +22,7 @@ "lib_revision": "1cc197c2d48cd537de50e5f2637e00bf2c10fd36", "source_date_epoch": 1787871027, "swift_package_lock_sha256": "a7e31d77d31ecbc1e0e5a78f2681e3fb0c6f367af0edf8b4ceef0af973a78681", - "tera_ffi_source_tree": "610c64f026927441de580af4b1ea13c8e1430c84", + "tera_ffi_source_tree": "6f0421a49bba8a64068795151c80baaeecedffa5", "xcode_package_lock_sha256": "d98a614a38dce7b55c2925dee51c2d4bf8dcfc4e31779d0c5fe8561c45d6e3ba" }, "version": "0.1.0-alpha" diff --git a/test-fixtures/legacy-identifiers.v1.json b/test-fixtures/legacy-identifiers.v1.json @@ -290,7 +290,7 @@ }, { "path": "TeraTests/TeraDurableMediaRootsTests.swift", - "count": 1 + "count": 2 }, { "path": "TeraTests/TeraMediaCleanupTests.swift", @@ -309,6 +309,10 @@ { "path": "Tera/Runtime/TeraUserMessageClassifier.swift", "count": 2 + }, + { + "path": "TeraTests/TeraDurableMediaRootsTests.swift", + "count": 1 } ] }, @@ -331,6 +335,10 @@ "category": "shared_apple_api", "occurrences": [ { + "path": "Tera/Runtime/TeraDurableMediaRoots.swift", + "count": 2 + }, + { "path": "Tera/State/TeraConfigurationStore.swift", "count": 1 }, @@ -2044,7 +2052,7 @@ }, { "path": "Tera/Runtime/TeraDurableMediaRoots.swift", - "count": 1 + "count": 2 }, { "path": "TeraTests/TeraBackgroundUploadFixtures.swift", @@ -5389,6 +5397,10 @@ "category": "verification_compatibility", "occurrences": [ { + "path": "TeraTests/TeraDurableMediaRootsTests.swift", + "count": 1 + }, + { "path": "TeraTests/TeraMediaCleanupTests.swift", "count": 1 },