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 6fc190f6e7bcbeb5b58a86fed6b31dd0f18225a8
parent e25819267b51f659e5dfdf7b318239a8969bf45c
Author: triesap <tyson@radroots.org>
Date:   Tue,  8 Sep 2026 17:25:22 +0000

tera: pin migration compatibility fixtures

- Freeze installed bundle and custody namespaces against parsed configuration.
- Preserve a synthetic queued operation and card identity through the real FFI reader.
- Verify signed operation recovery with an ephemeral signer and unchanged locked versions.
- Pass two Rust persistence tests, 44 package tests and 89 native tests without skips.

Diffstat:
MCargo.lock | 5+++++
Mcrates/source_lock/Cargo.toml | 7+++++++
Acrates/source_lock/tests/compatibility.rs | 201+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mscripts/package_contract.py | 24++++++++++++++++++++++++
Mscripts/test_package_contract.py | 19+++++++++++++++++++
Atest-fixtures/tera-compatibility.v1.json | 27+++++++++++++++++++++++++++
6 files changed, 283 insertions(+), 0 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1721,7 +1721,12 @@ dependencies = [ name = "radroots_ios_source_lock" version = "0.1.0-alpha" dependencies = [ + "async-trait", "radroots_mobile_ffi", + "secp256k1", + "serde_json", + "tempfile", + "tokio", ] [[package]] diff --git a/crates/source_lock/Cargo.toml b/crates/source_lock/Cargo.toml @@ -12,5 +12,12 @@ publish = false [dependencies] radroots_mobile_ffi = { workspace = true } +[dev-dependencies] +async-trait = "=0.1.91" +secp256k1 = { version = "=0.29.1", features = ["rand-std"] } +serde_json = "=1.0.151" +tempfile = "=3.27.0" +tokio = { version = "=1.53.1", features = ["macros", "rt-multi-thread"] } + [lints] workspace = true diff --git a/crates/source_lock/tests/compatibility.rs b/crates/source_lock/tests/compatibility.rs @@ -0,0 +1,201 @@ +//! Pre-transfer compatibility at the consumed FFI boundary. All stores are +//! isolated fixtures; signer keys are generated in memory and never serialized. + +use radroots_mobile_ffi::{ + FfiAddCommandType, FfiAddDraftInput, FfiCancellationPolicy, FfiDraftStatusRecord, + FfiQueuePolicyRecord, FfiRelaySatisfaction, HostSigningOutcome, HostSigningRequest, + HostSigningResult, MOBILE_FFI_SCHEMA_VERSION, ProtectedDataAvailability, RadrootsHostSigner, + RadrootsRuntime, SignerAvailabilityRecord, SignerStatusRecord, +}; +use secp256k1::{Keypair, Message, Secp256k1}; +use serde_json::Value; +use std::path::Path; + +fn fixture() -> Value { + serde_json::from_str(include_str!( + "../../../test-fixtures/tera-compatibility.v1.json" + )) + .expect("checked synthetic fixture") +} + +fn field<'a>(fixture: &'a Value, key: &str) -> &'a str { + fixture[key].as_str().expect("fixture string") +} + +fn input(fixture: &Value) -> FfiAddDraftInput { + FfiAddDraftInput { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + command_type: FfiAddCommandType::CreateUpdate, + content: field(fixture, "content").to_owned(), + identifier: None, + title: None, + summary: None, + location: None, + event_timing: None, + event_start_date: None, + event_end_date: None, + event_start_unix_s: None, + event_end_unix_s: None, + event_timezone: None, + price_amount: None, + currency: None, + unit: None, + quantity: None, + food_published_at_unix_s: None, + food_status: None, + media: Vec::new(), + } +} + +async fn runtime(root: &Path, public_key: &str, signer: Option<Keypair>) -> RadrootsRuntime { + std::fs::create_dir_all(root.join("radroots/users").join(public_key)) + .expect("isolated application owner directory"); + let fixture = fixture(); + let generation = field(&fixture["queued_update"], "source_generation").to_owned(); + if let Some(keypair) = signer { + RadrootsRuntime::with_host_signer( + root.to_string_lossy().into_owned(), + public_key.to_owned(), + generation, + 1_800_000_000_000, + ProtectedDataAvailability::Available, + Box::new(EphemeralSigner(keypair)), + ) + .await + .expect("runtime with ephemeral fixture signer") + } else { + RadrootsRuntime::new( + root.to_string_lossy().into_owned(), + public_key.to_owned(), + generation, + 1_800_000_000_000, + ProtectedDataAvailability::Available, + ) + .await + .expect("runtime using current persistent reader") + } +} + +async fn queue(runtime: &RadrootsRuntime, fixture: &Value) -> FfiDraftStatusRecord { + let id = field(fixture, "draft_id"); + let persisted = fixture["persisted_at_unix_ms"].as_u64().unwrap(); + let saved = runtime + .phase1_save_draft( + id.to_owned(), + input(fixture), + fixture["authored_at_unix_s"].as_u64().unwrap(), + None, + persisted, + ) + .await + .expect("save synthetic draft through real FFI"); + let policy = FfiQueuePolicyRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + relay_urls: fixture["relay_urls"] + .as_array() + .unwrap() + .iter() + .map(|value| value.as_str().unwrap().to_owned()) + .collect(), + satisfaction: FfiRelaySatisfaction::AllAccepted, + delivery_deadline_unix_ms: fixture["delivery_deadline_unix_ms"].as_u64().unwrap(), + cancellation: FfiCancellationPolicy::LocalCooperative, + }; + runtime + .phase1_queue_draft(id.to_owned(), saved.revision, policy, persisted + 1) + .await + .expect("queue locally without starting relay delivery") +} + +#[tokio::test] +async fn queued_update_reopens_with_frozen_operation_and_card_identity() { + let fixture = fixture()["queued_update"].clone(); + let root = tempfile::tempdir().unwrap(); + let public_key = field(&fixture, "public_key"); + let first = runtime(root.path(), public_key, None).await; + let queued = queue(&first, &fixture).await; + assert_eq!( + queued.operation_id.as_deref(), + Some(field(&fixture, "expected_operation_id")) + ); + assert_eq!(queued.card_id, field(&fixture, "expected_card_id")); + first.shutdown().await.unwrap(); + drop(first); + let reopened = runtime(root.path(), public_key, None).await; + let restored = reopened + .phase1_draft_status(field(&fixture, "draft_id").to_owned()) + .await + .expect("existing persisted draft reader"); + assert_eq!(restored, queued); + let recovered = reopened + .phase1_recover_draft_queue(field(&fixture, "draft_id").to_owned(), 1_900_000_000_002) + .await + .expect("existing queued operation reader"); + assert_eq!(recovered, queued); + reopened.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn signed_operation_reopens_without_replacing_its_author_or_identity() { + let fixture = fixture()["queued_update"].clone(); + let root = tempfile::tempdir().unwrap(); + let keypair = Keypair::new(&Secp256k1::new(), &mut secp256k1::rand::thread_rng()); + let public_key = keypair.x_only_public_key().0.to_string(); + let first = runtime(root.path(), &public_key, Some(keypair)).await; + let queued = queue(&first, &fixture).await; + let signed = first + .phase1_sign_queued_draft(queued.draft_id.clone(), queued.revision) + .await + .expect("sign and durably admit the fixed operation"); + assert_eq!(signed.operation_id, queued.operation_id); + assert_eq!(signed.card_id, queued.card_id); + assert_eq!(signed.author_public_key, public_key); + assert_eq!(signed.settlement.unwrap().signed, 1); + first.shutdown().await.unwrap(); + drop(first); + // Reopening has no signer. Reading must preserve the already signed facts. + let reopened = runtime(root.path(), &public_key, None).await; + let restored = reopened + .phase1_draft_status(signed.draft_id.clone()) + .await + .unwrap(); + assert_eq!(restored, signed); + reopened.shutdown().await.unwrap(); +} + +struct EphemeralSigner(Keypair); + +#[async_trait::async_trait] +impl RadrootsHostSigner for EphemeralSigner { + async fn signer_status(&self) -> SignerStatusRecord { + SignerStatusRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + availability: SignerAvailabilityRecord::Ready, + } + } + + async fn sign(&self, request: HostSigningRequest) -> HostSigningResult { + assert_eq!(request.public_key, self.0.x_only_public_key().0.to_string()); + let digest: [u8; 32] = request + .event_id_digest + .try_into() + .expect("exact event digest"); + let signature = + Secp256k1::new().sign_schnorr_no_aux_rand(&Message::from_digest(digest), &self.0); + HostSigningResult { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + outcome: HostSigningOutcome::Signed, + operation_id: request.operation_id, + signer_request_id: request.signer_request_id, + public_key: request.public_key, + purpose: request.purpose, + signature_hex: Some(signature.to_string()), + completed_at_unix_ms: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() + .try_into() + .unwrap(), + } + } +} diff --git a/scripts/package_contract.py b/scripts/package_contract.py @@ -527,6 +527,30 @@ def _verify_apple_configuration(root: Path) -> None: "http:$(SLASH)$(SLASH)127.0.0.1:21100", "debug Blossom origin", ) + _verify_installation_compatibility(root, base, debug) + + +def _verify_installation_compatibility( + root: Path, base: dict[str, str], debug: dict[str, str] +) -> None: + baseline = _read_json(root / "test-fixtures/tera-compatibility.v1.json") + _exact( + baseline.get("schema"), "tera.compatibility-baseline.v1", "compatibility schema" + ) + production = parse_xcconfig_assignments( + _read_text(root / "Radroots/radroots.xcconfig") + ) + actual = { + "production_bundle_identifier": production.get("PRODUCT_BUNDLE_IDENTIFIER"), + "debug_bundle_identifier": debug.get("PRODUCT_BUNDLE_IDENTIFIER"), + "production_keychain_service_prefix": base.get( + "RADROOTS_FIELD_IOS_KEYCHAIN_SERVICE_PREFIX" + ), + "debug_keychain_service_prefix": debug.get( + "RADROOTS_FIELD_IOS_KEYCHAIN_SERVICE_PREFIX" + ), + } + _exact(actual, baseline.get("installation"), "installed identity compatibility") def _verify_package_locks(root: Path, apple_revision: str) -> None: diff --git a/scripts/test_package_contract.py b/scripts/test_package_contract.py @@ -120,6 +120,25 @@ class PackageContractTests(unittest.TestCase): self.assertEqual(version, "0.1.0-alpha") self.assertRegex(revision, r"^[0-9a-f]{40}$") + def test_compatibility_baseline_rejects_bundle_or_custody_rename(self) -> None: + root = SCRIPTS.parent + base = contract.parse_xcconfig_assignments( + (root / "Radroots/Config/Base.xcconfig").read_text() + ) + debug = contract.parse_xcconfig_assignments( + (root / "Radroots/Config/Debug.xcconfig").read_text() + ) + for key in ( + "PRODUCT_BUNDLE_IDENTIFIER", + "RADROOTS_FIELD_IOS_KEYCHAIN_SERVICE_PREFIX", + ): + with self.subTest(key=key): + changed = {**debug, key: "org.tera.accidental-rename"} + with self.assertRaisesRegex( + contract.PackageContractError, "identity compatibility" + ): + contract._verify_installation_compatibility(root, base, changed) + def test_comment_token_does_not_define_xcconfig_field(self) -> None: values = contract.parse_xcconfig_assignments( "// RADROOTS_FIELD_IOS_RUNTIME_MODE = production\n" diff --git a/test-fixtures/tera-compatibility.v1.json b/test-fixtures/tera-compatibility.v1.json @@ -0,0 +1,27 @@ +{ + "schema": "tera.compatibility-baseline.v1", + "provenance": { + "tera_commit": "e25819267b51f659e5dfdf7b318239a8969bf45c", + "consumed_lib_commit": "ad17b7d3455a7147cfa303d976fc5c70c3a4c0cb", + "apple_kit_commit": "35aedb6b54ff645b663fecff26082b3e91fcb232", + "purpose": "Synthetic compatibility inputs and exact pre-transfer source observations; no credentials or captured user state. Source revisions are provenance, not a prohibition on later approved producer adoption." + }, + "installation": { + "production_bundle_identifier": "org.radroots.field-ios", + "debug_bundle_identifier": "dev.local.radroots", + "production_keychain_service_prefix": "org.radroots.field_ios", + "debug_keychain_service_prefix": "org.radroots.field_ios.local" + }, + "queued_update": { + "public_key": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "source_generation": "0404040404040404040404040404040404040404040404040404040404040404", + "draft_id": "07070707070707070707070707070707", + "content": "Synthetic Tera compatibility update", + "authored_at_unix_s": 1900000000, + "persisted_at_unix_ms": 1900000000000, + "delivery_deadline_unix_ms": 2000000000000, + "relay_urls": ["wss://relay-one.example", "wss://relay-two.example"], + "expected_operation_id": "9a1de0df9497259052ae7c68a7aee1b7", + "expected_card_id": "371d75785c77f9f8c313aa1e293c24f7d87abf1e6ca9b35d84b605be1f3dfdb6" + } +}