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:
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
},