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 1138c7fb317a2365ac56453d356ae21c7e4e78b9
parent ead4c3f7b0a2069a49f9eaaa5fbbddbd7aefb686
Author: triesap <tyson@radroots.org>
Date:   Tue,  8 Sep 2026 19:00:13 +0000

tera: own the application ffi producer

- preserve the verified ffi history and license provenance by forward merge
- bind the local producer to tera_core and matched Tera Swift module names
- retain conformance vectors and frozen queued and signed identity tests
- verify Rust behavior and scratch Swift bindings before native cutover

Diffstat:
MCargo.lock | 145+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
MCargo.toml | 11++++++++++-
Acore/crates/tera_ffi/Cargo.toml | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/tera_ffi/src/dto.rs | 2999+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/tera_ffi/src/error.rs | 481+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/tera_ffi/src/lib.rs | 46++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/tera_ffi/src/logging.rs | 284+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/tera_ffi/src/logging/writer.rs | 436+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/tera_ffi/src/operations.rs | 1005+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/tera_ffi/src/runtime.rs | 1186+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/tera_ffi/src/signer.rs | 404+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/tera_ffi/src/subscription.rs | 321+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/tera_ffi/tests/compatibility.rs | 201+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/tera_ffi/tests/local_mvp_real_io.rs | 964+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/tera_ffi/tests/logging_error.rs | 13+++++++++++++
Acore/crates/tera_ffi/tests/runtime_delegation.rs | 850+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/tera_ffi/tests/runtime_lifecycle.rs | 44++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/tera_ffi/tests/subscription_contract.rs | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/tera_ffi/tests/support/mod.rs | 24++++++++++++++++++++++++
Acore/crates/tera_ffi/tests/uniffi_contract.rs | 258+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/tera_ffi/uniffi.toml | 3+++
Acore/provenance/tera_ffi/LICENSE-APACHE | 201+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/provenance/tera_ffi/LICENSE-GPL-3.0-only | 674+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/provenance/tera_ffi/LICENSE-GPL-3.0-or-later | 674+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/provenance/tera_ffi/LICENSE-MIT | 21+++++++++++++++++++++
Acore/test-fixtures/tera_ffi/authored_operations.v1.json | 765+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/test-fixtures/tera_ffi/evidence_attestation_decision.v1.json | 153+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/source_lock/Cargo.toml | 7-------
Dcrates/source_lock/tests/compatibility.rs | 201-------------------------------------------------------------------------------
29 files changed, 12266 insertions(+), 226 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1343,6 +1343,29 @@ dependencies = [ [[package]] name = "nostr" +version = "0.44.1" +source = "git+https://github.com/rust-nostr/nostr.git?rev=5bba5163eb77107f82c4a8262cf29d7f33a73219#5bba5163eb77107f82c4a8262cf29d7f33a73219" +dependencies = [ + "base64", + "bech32", + "bip39", + "bitcoin_hashes", + "cbc", + "chacha20 0.9.1", + "chacha20poly1305", + "getrandom 0.2.17", + "hex", + "instant", + "scrypt", + "secp256k1", + "serde", + "serde_json", + "unicode-normalization", + "url", +] + +[[package]] +name = "nostr" version = "0.44.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ff7b77ef428b40aa2834a6acbae38a0e104c98b306208ca4b87a420d579a4b" @@ -1375,7 +1398,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7462c9d8ae5ef6a28d66a192d399ad2530f1f2130b13186296dbb11bdef5b3d1" dependencies = [ "lru", - "nostr", + "nostr 0.44.8", + "tokio", +] + +[[package]] +name = "nostr-database" +version = "0.44.0" +source = "git+https://github.com/rust-nostr/nostr.git?rev=5bba5163eb77107f82c4a8262cf29d7f33a73219#5bba5163eb77107f82c4a8262cf29d7f33a73219" +dependencies = [ + "lru", + "nostr 0.44.1", "tokio", ] @@ -1385,7 +1418,48 @@ version = "0.44.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ade30de16869618919c6b5efc8258f47b654a98b51541eb77f85e8ec5e3c83a6" dependencies = [ - "nostr", + "nostr 0.44.8", +] + +[[package]] +name = "nostr-gossip" +version = "0.44.0" +source = "git+https://github.com/rust-nostr/nostr.git?rev=5bba5163eb77107f82c4a8262cf29d7f33a73219#5bba5163eb77107f82c4a8262cf29d7f33a73219" +dependencies = [ + "nostr 0.44.1", +] + +[[package]] +name = "nostr-relay-builder" +version = "0.44.0" +source = "git+https://github.com/rust-nostr/nostr.git?rev=5bba5163eb77107f82c4a8262cf29d7f33a73219#5bba5163eb77107f82c4a8262cf29d7f33a73219" +dependencies = [ + "async-utility", + "async-wsocket", + "atomic-destructor", + "hex", + "negentropy", + "nostr 0.44.1", + "nostr-database 0.44.0 (git+https://github.com/rust-nostr/nostr.git?rev=5bba5163eb77107f82c4a8262cf29d7f33a73219)", + "tokio", + "tracing", +] + +[[package]] +name = "nostr-relay-pool" +version = "0.44.0" +source = "git+https://github.com/rust-nostr/nostr.git?rev=5bba5163eb77107f82c4a8262cf29d7f33a73219#5bba5163eb77107f82c4a8262cf29d7f33a73219" +dependencies = [ + "async-utility", + "async-wsocket", + "atomic-destructor", + "hex", + "lru", + "negentropy", + "nostr 0.44.1", + "nostr-database 0.44.0 (git+https://github.com/rust-nostr/nostr.git?rev=5bba5163eb77107f82c4a8262cf29d7f33a73219)", + "tokio", + "tracing", ] [[package]] @@ -1400,8 +1474,22 @@ dependencies = [ "hex", "lru", "negentropy", - "nostr", - "nostr-database", + "nostr 0.44.8", + "nostr-database 0.44.0 (registry+https://github.com/rust-lang/crates.io-index)", + "tokio", + "tracing", +] + +[[package]] +name = "nostr-sdk" +version = "0.44.0" +source = "git+https://github.com/rust-nostr/nostr.git?rev=5bba5163eb77107f82c4a8262cf29d7f33a73219#5bba5163eb77107f82c4a8262cf29d7f33a73219" +dependencies = [ + "async-utility", + "nostr 0.44.1", + "nostr-database 0.44.0 (git+https://github.com/rust-nostr/nostr.git?rev=5bba5163eb77107f82c4a8262cf29d7f33a73219)", + "nostr-gossip 0.44.0 (git+https://github.com/rust-nostr/nostr.git?rev=5bba5163eb77107f82c4a8262cf29d7f33a73219)", + "nostr-relay-pool 0.44.0", "tokio", "tracing", ] @@ -1413,10 +1501,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "471732576710e779b64f04c55e3f8b5292f865fea228436daf19694f0bf70393" dependencies = [ "async-utility", - "nostr", - "nostr-database", - "nostr-gossip", - "nostr-relay-pool", + "nostr 0.44.8", + "nostr-database 0.44.0 (registry+https://github.com/rust-lang/crates.io-index)", + "nostr-gossip 0.44.0 (registry+https://github.com/rust-lang/crates.io-index)", + "nostr-relay-pool 0.44.3", "tokio", "tracing", ] @@ -1722,12 +1810,7 @@ dependencies = [ name = "radroots_ios_source_lock" version = "0.1.0-alpha" dependencies = [ - "async-trait", "radroots_mobile_ffi", - "secp256k1", - "serde_json", - "tempfile", - "tokio", ] [[package]] @@ -1786,7 +1869,7 @@ version = "0.1.0-alpha" source = "git+https://github.com/radrootslabs/lib?rev=ad17b7d3455a7147cfa303d976fc5c70c3a4c0cb#ad17b7d3455a7147cfa303d976fc5c70c3a4c0cb" dependencies = [ "base64", - "nostr", + "nostr 0.44.8", "radroots_blossom", "radroots_event", "radroots_event_codec", @@ -1934,8 +2017,8 @@ source = "git+https://github.com/radrootslabs/lib?rev=ad17b7d3455a7147cfa303d976 dependencies = [ "async-wsocket", "futures", - "nostr-relay-pool", - "nostr-sdk", + "nostr-relay-pool 0.44.3", + "nostr-sdk 0.44.1", "radroots_event_codec", "radroots_nostr", "radroots_protocol", @@ -2588,7 +2671,7 @@ version = "0.1.0-alpha" dependencies = [ "chrono", "hex", - "nostr", + "nostr 0.44.8", "radroots_blossom", "radroots_event", "radroots_event_codec", @@ -2612,6 +2695,34 @@ dependencies = [ ] [[package]] +name = "tera_ffi" +version = "0.1.0-alpha" +dependencies = [ + "async-trait", + "hex", + "libc", + "nostr 0.44.1", + "nostr-relay-builder", + "nostr-sdk 0.44.0", + "radroots_blossom", + "radroots_event", + "radroots_sdk", + "radroots_signing", + "radroots_storage", + "rustix", + "secp256k1", + "serde_json", + "tempfile", + "tera_core", + "thiserror 1.0.69", + "tokio", + "tracing", + "tracing-appender", + "tracing-subscriber", + "uniffi", +] + +[[package]] name = "textwrap" version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/Cargo.toml b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/source_lock", "core/crates/tera_core"] +members = ["crates/source_lock", "core/crates/tera_core", "core/crates/tera_ffi"] resolver = "3" [workspace.package] @@ -48,3 +48,12 @@ thiserror = { version = "1" } tokio = { version = "1" } url = { version = "2" } uuid = { version = "1.22.0", features = ["v4", "v7"] } +async-trait = { version = "0.1.89" } +libc = { version = "0.2" } +rustix = { version = "1", features = ["fs", "process", "std"] } +secp256k1 = { version = "0.29.1", default-features = false, features = ["alloc"] } +tera_core = { path = "core/crates/tera_core", version = "=0.1.0-alpha", default-features = false } +tracing = { version = "0.1", default-features = false } +tracing-appender = { version = "0.2" } +tracing-subscriber = { version = "0.3" } +uniffi = { version = "0.29.4" } diff --git a/core/crates/tera_ffi/Cargo.toml b/core/crates/tera_ffi/Cargo.toml @@ -0,0 +1,54 @@ +[package] +name = "tera_ffi" +description = "Native FFI bindings for Radroots mobile apps" +version = "0.1.0-alpha" +edition.workspace = true +authors = ["Radroots Authors"] +rust-version.workspace = true +license = "GPL-3.0-or-later" +repository.workspace = true +homepage.workspace = true +readme.workspace = true +publish = false +include = ["src/**", "uniffi.toml", "Cargo.toml"] + +[lib] +crate-type = ["rlib", "staticlib", "cdylib"] + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } + +[dependencies] +async-trait = { workspace = true } +hex = { workspace = true } +radroots_blossom = { workspace = true, features = ["std"] } +radroots_event = { workspace = true, features = ["serde", "std"] } +tera_core = { workspace = true, features = ["mobile-social"] } +radroots_sdk = { workspace = true, features = ["blossom"] } +radroots_signing = { workspace = true, features = ["serde", "std"] } +radroots_storage = { workspace = true } +serde_json = { workspace = true, features = ["std"] } +tracing = { workspace = true } +tracing-appender = { workspace = true } +tracing-subscriber = { workspace = true } +thiserror = { workspace = true } +uniffi = { workspace = true, features = ["tokio"] } + +[target.'cfg(unix)'.dependencies] +libc = { workspace = true } +rustix = { workspace = true } + +[dev-dependencies] +nostr = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219", package = "nostr" } +nostr-relay-builder = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219", package = "nostr-relay-builder" } +nostr-sdk = { git = "https://github.com/rust-nostr/nostr.git", rev = "5bba5163eb77107f82c4a8262cf29d7f33a73219", package = "nostr-sdk" } +secp256k1 = { workspace = true, features = ["rand-std"] } +tempfile = { workspace = true } +tokio = { workspace = true, features = [ + "io-util", + "macros", + "net", + "rt-multi-thread", + "sync", + "time", +] } diff --git a/core/crates/tera_ffi/src/dto.rs b/core/crates/tera_ffi/src/dto.rs @@ -0,0 +1,2999 @@ +//! Focused, versioned value types owned by the native boundary. + +#[cfg(unix)] +use std::os::fd::{FromRawFd, OwnedFd, RawFd}; +#[cfg(unix)] +use std::os::unix::fs::FileExt; + +use radroots_blossom::{BlobDescriptor, MediaType, Sha256}; +use radroots_event::{ + calendar::{AuthoredCalendarDateEvent, AuthoredCalendarTimeEvent, CalendarDate}, + food::availability::{ + FoodAvailabilityDetails, FoodAvailabilityDetailsParts, FoodAvailabilityImage, + FoodAvailabilityStatus, FoodContent, FoodCurrency, FoodIdentifier, FoodImageDimensions, + FoodPrice, FoodPublishedAt, FoodQuantity, FoodText, FoodUnit, + }, + media::AuthoredImage, + post::{AuthoredPostImage, PostImageDimensions}, +}; +use tera_core::runtime::{ + app_info::AppInfoPlatform, + info::{AppInfo, RuntimeBuildInfo, RuntimeInfo}, + product_surface::{ + AddCommandType, CardLifecycleState, CreateAsk, CreateEvent, CreateFoodAvailability, + CreatePhotoUpdate, CreateUpdate, LocalNetwork, LocalNetworkRelayPolicy, MeSnapshot, + MediaReference, Phase1AddCommand, Phase1CancellationPolicy, Phase1DraftEventTiming, + Phase1DraftFormSnapshot, Phase1DraftKind, Phase1DraftMediaSnapshot, Phase1DraftStatus, + Phase1InboundMediaState, Phase1MediaPrerequisite, Phase1MediaStage, Phase1OutboxState, + Phase1QueuePolicy, Phase1RelaySatisfaction, Phase1UploadIntent, ProfileSummary, + SearchResult, SearchResultType, SupportingProfile, ThreadEntry, TodayCard, TodayCardType, + TodayPage, TodayProjectionUpdate, TodayRefreshReceipt, TodayRelaySyncState, + TodaySyncReceipt, + }, + sdk::{ + SdkBlossomConfigurationRecord, SdkBlossomEvidenceRecord, SdkCapabilityRecord, + SdkRelayAccessRecord, SdkRelayStatusRecord, SdkRelayStatusReportRecord, SdkShutdownRecord, + SdkStorageStatusRecord, + }, +}; + +use crate::RadrootsAppError; + +pub const MOBILE_FFI_SCHEMA_VERSION: u16 = 1; +const MEDIA_FILE_MAX_BYTES: u64 = 10 * 1024 * 1024; +const MEDIA_REFERENCE_MAX_BYTES: usize = 256; + +/// Final four-state trade-evidence coverage vocabulary. +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum FfiTradeEvidenceCoverage { + Missing, + Partial, + ScopeSatisfied, + Unsupported, +} + +/// Final three-state trade-evidence outcome vocabulary. +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum FfiTradeEvidenceOutcome { + Valid, + Invalid, + Indeterminate, +} + +/// Secret-free projection of one canonical evidence manifest. +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiTradeEvidenceManifestRecord { + pub schema_version: u16, + pub contract_id: String, + pub contract_version: u16, + pub trade_id: String, + pub trade_generation: String, + pub observed_at_unix_s: String, + pub coverage: FfiTradeEvidenceCoverage, + pub evidence_policy_digest: String, + pub manifest_digest: String, + pub canonical_bytes_hex: String, +} + +/// Secret-free projection of one canonical RHI evidence report. +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiRhiEvidenceReportRecord { + pub schema_version: u16, + pub contract_id: String, + pub contract_version: u16, + pub issuer_pubkey: String, + pub trade_id: String, + pub claim_mutation_id: String, + pub outcome: FfiTradeEvidenceOutcome, + pub reason_codes: Vec<String>, + pub projection_digest: String, + pub evidence_manifest_digest: String, + pub evidence_policy_digest: String, + pub observed_at_unix_s: String, + pub trade_generation: String, + pub statement_digest: String, + pub supersedes_report_id: Option<String>, + pub supersedes_event_id: Option<String>, + pub canonical_content: String, +} + +/// Unsigned typed event plan ready for host-owned signing. +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiTypedEvidenceEventPlanRecord { + pub schema_version: u16, + pub contract_id: String, + pub kind: u32, + pub author_pubkey: String, + pub created_at_unix_s: String, + pub expected_event_id: String, + pub tags: Vec<Vec<String>>, + pub content: String, +} + +/// Signed NIP-01 event input for verified attestation admission. +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiSignedEvidenceEventRecord { + pub id: String, + pub author_pubkey: String, + pub created_at_unix_s: u64, + pub kind: u32, + pub tags: Vec<Vec<String>>, + pub content: String, + pub signature: String, +} + +/// Secret-free supersession projection from one verified attestation. +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiRhiEvidenceAttestationSupersessionRecord { + pub report_id: String, + pub event_id: String, +} + +/// Verified final RHI evidence attestation. +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiRhiEvidenceAttestationRecord { + pub schema_version: u16, + pub issuer_pubkey: String, + pub trade_id: String, + pub claim_mutation_id: String, + pub outcome: FfiTradeEvidenceOutcome, + pub observed_at_unix_s: String, + pub trade_generation: String, + pub statement_digest: String, + pub supersession: Option<FfiRhiEvidenceAttestationSupersessionRecord>, + pub canonical_content: String, +} + +#[uniffi::export] +pub fn parse_trade_evidence_manifest( + canonical_bytes: Vec<u8>, +) -> Result<FfiTradeEvidenceManifestRecord, RadrootsAppError> { + let manifest = radroots_sdk::trade::parse_evidence_manifest(&canonical_bytes) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_evidence_manifest"))?; + Ok(FfiTradeEvidenceManifestRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + contract_id: manifest.contract_id().to_owned(), + contract_version: manifest.contract_version(), + trade_id: manifest.trade_id().to_string(), + trade_generation: manifest.trade_generation().get().to_string(), + observed_at_unix_s: manifest.observed_at_unix_s().to_string(), + coverage: manifest.coverage().into(), + evidence_policy_digest: manifest.evidence_policy_digest().to_hex(), + manifest_digest: manifest.digest().to_hex(), + canonical_bytes_hex: hex::encode(manifest.canonical_bytes()), + }) +} + +#[uniffi::export] +pub fn parse_rhi_evidence_report( + canonical_content: String, +) -> Result<FfiRhiEvidenceReportRecord, RadrootsAppError> { + let report = radroots_sdk::trade::parse_rhi_evidence_report(canonical_content.as_bytes()) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_evidence_report"))?; + let supersession = report.supersession(); + Ok(FfiRhiEvidenceReportRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + contract_id: report.contract_id().to_owned(), + contract_version: report.contract_version(), + issuer_pubkey: report.issuer_public_key().to_hex(), + trade_id: report.trade_id().to_string(), + claim_mutation_id: report.claim_mutation_id().to_string(), + outcome: report.outcome().into(), + reason_codes: report + .reason_codes() + .iter() + .map(|code| code.as_str().to_owned()) + .collect(), + projection_digest: report.projection_digest().to_hex(), + evidence_manifest_digest: report.evidence_manifest_digest().to_hex(), + evidence_policy_digest: report.evidence_policy_digest().to_hex(), + observed_at_unix_s: report.observed_at_unix_s().to_string(), + trade_generation: report.trade_generation().get().to_string(), + statement_digest: report.statement_digest().to_hex(), + supersedes_report_id: supersession.map(|value| value.report_id().to_hex()), + supersedes_event_id: supersession.map(|value| value.event_id().to_hex()), + canonical_content: report.canonical_content().to_owned(), + }) +} + +#[uniffi::export] +pub fn prepare_rhi_evidence_attestation( + canonical_content: String, + created_at_unix_s: u64, +) -> Result<FfiTypedEvidenceEventPlanRecord, RadrootsAppError> { + let report = radroots_sdk::trade::parse_rhi_evidence_report(canonical_content.as_bytes()) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_evidence_report"))?; + let plan = radroots_sdk::trade::prepare_rhi_evidence_attestation(&report, created_at_unix_s) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_evidence_attestation_plan"))?; + Ok(FfiTypedEvidenceEventPlanRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + contract_id: plan.body().contract().contract_id().as_str().to_owned(), + kind: plan.body().kind(), + author_pubkey: plan.author().to_hex(), + created_at_unix_s: plan.created_at().to_string(), + expected_event_id: plan.expected_event_id().to_hex(), + tags: plan.body().tags().to_vec(), + content: plan.body().content().to_owned(), + }) +} + +#[uniffi::export] +pub fn validate_rhi_evidence_attestation( + event: FfiSignedEvidenceEventRecord, +) -> Result<FfiRhiEvidenceAttestationRecord, RadrootsAppError> { + let event = radroots_event::envelope::EventEnvelope::new( + radroots_event::envelope::EventEnvelopeParts { + id: event.id, + author: event.author_pubkey, + created_at: event.created_at_unix_s, + kind: event.kind, + tags: event.tags, + content: event.content, + sig: event.signature, + }, + ) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_signed_event"))?; + let attestation = radroots_sdk::trade::validate_rhi_evidence_attestation(event).map_err( + |error| match error { + radroots_sdk::trade::EvidenceAttestationValidationError::Signature => { + RadrootsAppError::invalid_argument("invalid_event_signature") + } + radroots_sdk::trade::EvidenceAttestationValidationError::Contract => { + RadrootsAppError::invalid_argument("invalid_evidence_attestation") + } + }, + )?; + Ok(FfiRhiEvidenceAttestationRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + issuer_pubkey: attestation.issuer().to_hex(), + trade_id: attestation.trade_id().to_string(), + claim_mutation_id: attestation.claim_mutation_id().to_string(), + outcome: match attestation.outcome() { + radroots_sdk::trade::RadrootsRhiEvidenceAttestationOutcomeV1::Valid => { + FfiTradeEvidenceOutcome::Valid + } + radroots_sdk::trade::RadrootsRhiEvidenceAttestationOutcomeV1::Invalid => { + FfiTradeEvidenceOutcome::Invalid + } + radroots_sdk::trade::RadrootsRhiEvidenceAttestationOutcomeV1::Indeterminate => { + FfiTradeEvidenceOutcome::Indeterminate + } + }, + observed_at_unix_s: attestation.observed_at_unix_s().to_string(), + trade_generation: attestation.trade_generation().get().to_string(), + statement_digest: hex::encode(attestation.statement_digest()), + supersession: attestation.supersession().map(|value| { + FfiRhiEvidenceAttestationSupersessionRecord { + report_id: hex::encode(value.report_id()), + event_id: value.event_id().to_hex(), + } + }), + canonical_content: attestation.canonical_content().to_owned(), + }) +} + +impl From<radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1> for FfiTradeEvidenceCoverage { + fn from(value: radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1) -> Self { + match value { + radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1::Missing => Self::Missing, + radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1::Partial => Self::Partial, + radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1::ScopeSatisfied => { + Self::ScopeSatisfied + } + radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1::Unsupported => Self::Unsupported, + } + } +} + +impl From<radroots_sdk::trade::RadrootsTradeEvidenceOutcomeV1> for FfiTradeEvidenceOutcome { + fn from(value: radroots_sdk::trade::RadrootsTradeEvidenceOutcomeV1) -> Self { + match value { + radroots_sdk::trade::RadrootsTradeEvidenceOutcomeV1::Valid => Self::Valid, + radroots_sdk::trade::RadrootsTradeEvidenceOutcomeV1::Invalid => Self::Invalid, + radroots_sdk::trade::RadrootsTradeEvidenceOutcomeV1::Indeterminate => { + Self::Indeterminate + } + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiBuildInfoRecord { + pub schema_version: u16, + pub crate_name: String, + pub crate_version: String, + pub rustc: Option<String>, + pub profile: Option<String>, + pub lib_revision: Option<String>, + pub consumer_revision: Option<String>, + pub build_time_unix: Option<u64>, +} + +// These exhaustive field-for-field adapters are verified by the generated API +// snapshot and Swift compilation. Excluding the mechanical projection glue +// keeps the coverage gate focused on validation and behavior. +#[cfg_attr(coverage_nightly, coverage(off))] +impl From<RuntimeBuildInfo> for FfiBuildInfoRecord { + fn from(value: RuntimeBuildInfo) -> Self { + Self { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + crate_name: value.crate_name, + crate_version: value.crate_version, + rustc: value.rustc, + profile: value.profile, + lib_revision: value.lib_revision, + consumer_revision: value.consumer_revision, + build_time_unix: value.build_time_unix, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiAppPlatformRecord { + pub schema_version: u16, + pub platform: Option<String>, + pub bundle_id: Option<String>, + pub version: Option<String>, + pub build_number: Option<String>, + pub build_sha: Option<String>, +} + +#[cfg_attr(coverage_nightly, coverage(off))] +impl From<AppInfoPlatform> for FfiAppPlatformRecord { + fn from(value: AppInfoPlatform) -> Self { + Self { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + platform: value.platform, + bundle_id: value.bundle_id, + version: value.version, + build_number: value.build_number, + build_sha: value.build_sha, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiAppInfoRecord { + pub schema_version: u16, + pub build: FfiBuildInfoRecord, + pub started_unix_ms: i64, + pub uptime_millis: i64, + pub shutting_down: bool, + pub platform: Option<FfiAppPlatformRecord>, +} + +#[cfg_attr(coverage_nightly, coverage(off))] +impl From<AppInfo> for FfiAppInfoRecord { + fn from(value: AppInfo) -> Self { + Self { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + build: value.build.into(), + started_unix_ms: value.started_unix_ms, + uptime_millis: value.uptime_millis, + shutting_down: value.shutting_down, + platform: value.platform.map(Into::into), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiRuntimeInfoRecord { + pub schema_version: u16, + pub app: FfiAppInfoRecord, + pub sdk: FfiBuildInfoRecord, + pub sdk_closed: bool, +} + +#[cfg_attr(coverage_nightly, coverage(off))] +impl From<RuntimeInfo> for FfiRuntimeInfoRecord { + fn from(value: RuntimeInfo) -> Self { + Self { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + app: value.app.into(), + sdk: value.sdk.into(), + sdk_closed: value.sdk_closed, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiIdentityStatusRecord { + pub schema_version: u16, + pub public_key: String, + pub host_signer_configured: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum FfiTodayCardType { + Update, + PhotoUpdate, + Ask, + Event, + FoodAvailability, +} + +#[cfg_attr(coverage_nightly, coverage(off))] +impl From<TodayCardType> for FfiTodayCardType { + fn from(value: TodayCardType) -> Self { + match value { + TodayCardType::Update => Self::Update, + TodayCardType::PhotoUpdate => Self::PhotoUpdate, + TodayCardType::Ask => Self::Ask, + TodayCardType::Event => Self::Event, + TodayCardType::FoodAvailability => Self::FoodAvailability, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum FfiAddCommandType { + CreateUpdate, + CreatePhotoUpdate, + CreateAsk, + CreateEvent, + CreateFoodAvailability, +} + +#[cfg_attr(coverage_nightly, coverage(off))] +impl From<AddCommandType> for FfiAddCommandType { + fn from(value: AddCommandType) -> Self { + match value { + AddCommandType::CreateUpdate => Self::CreateUpdate, + AddCommandType::CreatePhotoUpdate => Self::CreatePhotoUpdate, + AddCommandType::CreateAsk => Self::CreateAsk, + AddCommandType::CreateEvent => Self::CreateEvent, + AddCommandType::CreateFoodAvailability => Self::CreateFoodAvailability, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiCardAddParityRecord { + pub schema_version: u16, + pub card_type: FfiTodayCardType, + pub command_type: FfiAddCommandType, +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiLocalNetworkRecord { + pub schema_version: u16, + pub id: String, + pub label: String, + pub relay_urls: Vec<String>, + pub locality: Option<String>, + pub followed_authors: Vec<String>, + pub generation: u64, +} + +impl TryFrom<FfiLocalNetworkRecord> for LocalNetwork { + type Error = RadrootsAppError; + + fn try_from(value: FfiLocalNetworkRecord) -> Result<Self, Self::Error> { + value.try_into_with_relay_policy(LocalNetworkRelayPolicy::Public) + } +} + +impl FfiLocalNetworkRecord { + pub(crate) fn try_into_with_relay_policy( + self, + relay_policy: LocalNetworkRelayPolicy, + ) -> Result<LocalNetwork, RadrootsAppError> { + require_schema(self.schema_version)?; + LocalNetwork::new_for_relay_policy( + self.id, + self.label, + self.relay_urls, + self.locality, + self.followed_authors, + self.generation, + relay_policy, + ) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_local_network")) + } +} + +#[cfg_attr(coverage_nightly, coverage(off))] +impl From<LocalNetwork> for FfiLocalNetworkRecord { + fn from(value: LocalNetwork) -> Self { + Self { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + id: value.id, + label: value.label, + relay_urls: value.relay_urls, + locality: value.locality, + followed_authors: value.followed_authors, + generation: value.generation, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum FfiMediaVerificationState { + Pending, + Verified, + Failed, + Unavailable, +} + +#[cfg_attr(coverage_nightly, coverage(off))] +impl From<&Phase1InboundMediaState> for FfiMediaVerificationState { + fn from(value: &Phase1InboundMediaState) -> Self { + match value { + Phase1InboundMediaState::Pending(_) => Self::Pending, + Phase1InboundMediaState::Verified(_) => Self::Verified, + Phase1InboundMediaState::Failed(_) => Self::Failed, + Phase1InboundMediaState::Unavailable => Self::Unavailable, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiMediaReferenceRecord { + pub schema_version: u16, + pub reference_fingerprint: String, + pub url: String, + pub sha256: Option<String>, + pub media_type: Option<String>, + pub width: Option<u32>, + pub height: Option<u32>, + pub byte_size: Option<u64>, + pub alt: Option<String>, + pub verification: FfiMediaVerificationState, +} + +#[cfg_attr(coverage_nightly, coverage(off))] +impl From<MediaReference> for FfiMediaReferenceRecord { + fn from(value: MediaReference) -> Self { + let structural = value.structural(); + Self { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + reference_fingerprint: hex::encode(structural.fingerprint()), + url: structural.source_url().to_owned(), + sha256: structural.expected_sha256().map(str::to_owned), + media_type: structural.expected_media_type().map(str::to_owned), + width: structural.expected_width(), + height: structural.expected_height(), + byte_size: structural.expected_byte_size(), + alt: structural.alt().map(str::to_owned), + verification: value.retrieval().into(), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiProfileRecord { + pub schema_version: u16, + pub author_public_key: String, + pub name: Option<String>, + pub display_name: Option<String>, + pub about: Option<String>, + pub picture: Option<FfiMediaReferenceRecord>, + pub banner: Option<FfiMediaReferenceRecord>, + pub nip05: Option<String>, + pub website: Option<String>, + pub lightning_address: Option<String>, +} + +#[cfg_attr(coverage_nightly, coverage(off))] +impl From<ProfileSummary> for FfiProfileRecord { + fn from(value: ProfileSummary) -> Self { + Self { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + author_public_key: value.author_pubkey, + name: value.name, + display_name: value.display_name, + about: value.about, + picture: value.picture.map(Into::into), + banner: value.banner.map(Into::into), + nip05: value.nip05, + website: value.website, + lightning_address: value.lightning_address, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum FfiThreadProfile { + Profile, + Reply, + Comment, + Deletion, +} + +#[cfg_attr(coverage_nightly, coverage(off))] +impl From<SupportingProfile> for FfiThreadProfile { + fn from(value: SupportingProfile) -> Self { + match value { + SupportingProfile::Profile => Self::Profile, + SupportingProfile::Reply => Self::Reply, + SupportingProfile::Comment => Self::Comment, + SupportingProfile::Deletion => Self::Deletion, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiThreadEntryRecord { + pub schema_version: u16, + pub event_id: String, + pub author_public_key: String, + pub content: String, + pub authored_at_unix_s: u64, + pub profile: FfiThreadProfile, + pub root: String, + pub parent_event_id: String, + pub author_profile: Option<FfiProfileRecord>, +} + +#[cfg_attr(coverage_nightly, coverage(off))] +impl From<ThreadEntry> for FfiThreadEntryRecord { + fn from(value: ThreadEntry) -> Self { + Self { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + event_id: value.event_id, + author_public_key: value.author_pubkey, + content: value.content, + authored_at_unix_s: value.authored_at, + profile: value.reference.profile.into(), + root: value.reference.root, + parent_event_id: value.reference.parent_event_id, + author_profile: value.author_profile.map(Into::into), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum FfiCardLifecycleState { + Active, + Sold, + Past, +} + +#[cfg_attr(coverage_nightly, coverage(off))] +impl From<CardLifecycleState> for FfiCardLifecycleState { + fn from(value: CardLifecycleState) -> Self { + match value { + CardLifecycleState::Active => Self::Active, + CardLifecycleState::Sold => Self::Sold, + CardLifecycleState::Past => Self::Past, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiTodayCardRecord { + pub schema_version: u16, + pub card_id: String, + pub card_type: FfiTodayCardType, + pub source_event_id: String, + pub source_address: Option<String>, + pub author_public_key: String, + pub contract_id: String, + pub title: Option<String>, + pub content: String, + pub authored_at_unix_s: u64, + pub effective_at_unix_s: u64, + pub event_start_unix_s: Option<u64>, + pub event_end_unix_s: Option<u64>, + pub location: Option<String>, + pub price_amount: Option<String>, + pub price_currency: Option<String>, + pub price_unit: Option<String>, + pub quantity: Option<String>, + pub food_summary: Option<String>, + pub food_published_at_unix_s: Option<u64>, + pub food_status: Option<String>, + pub context_rank: u8, + pub inclusion_reason: String, + pub media: Vec<FfiMediaReferenceRecord>, + pub lifecycle: FfiCardLifecycleState, + pub rank_digest: Option<String>, + pub author_profile: Option<FfiProfileRecord>, + pub thread: Vec<FfiThreadEntryRecord>, + pub local_operation_id: Option<String>, + pub local_operation_state: Option<String>, +} + +#[cfg_attr(coverage_nightly, coverage(off))] +impl From<TodayCard> for FfiTodayCardRecord { + fn from(value: TodayCard) -> Self { + let card = value.card; + Self { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + card_id: card.card_id.to_hex(), + card_type: card.card_type.into(), + source_event_id: card.source_event_id, + source_address: card.source_address, + author_public_key: card.author_pubkey, + contract_id: card.contract_id, + title: card.title, + content: card.content, + authored_at_unix_s: card.authored_at, + effective_at_unix_s: card.effective_at, + event_start_unix_s: card.event_start, + event_end_unix_s: card.event_end, + location: card.location, + price_amount: card.price_amount, + price_currency: card.price_currency, + price_unit: card.price_unit, + quantity: card.quantity, + food_summary: card.food_summary, + food_published_at_unix_s: card.food_published_at, + food_status: card.food_status, + context_rank: card.context_rank.value(), + inclusion_reason: card.inclusion_reason, + media: card.media.into_iter().map(Into::into).collect(), + lifecycle: card.lifecycle.into(), + rank_digest: card.rank.map(|rank| rank.digest_hex()), + author_profile: value.author_profile.map(Into::into), + thread: value.thread.into_iter().map(Into::into).collect(), + local_operation_id: value + .local_overlay + .as_ref() + .map(|overlay| overlay.operation_id.clone()), + local_operation_state: value.local_overlay.map(|overlay| overlay.state), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiTodayPageRecord { + pub schema_version: u16, + pub as_of_unix_s: u64, + pub items: Vec<FfiTodayCardRecord>, + pub next_cursor: Option<String>, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum FfiTodayProjectionUpdate { + Incremental, + Rebuild, +} + +#[cfg_attr(coverage_nightly, coverage(off))] +impl From<FfiTodayProjectionUpdate> for TodayProjectionUpdate { + fn from(value: FfiTodayProjectionUpdate) -> Self { + match value { + FfiTodayProjectionUpdate::Incremental => Self::Incremental, + FfiTodayProjectionUpdate::Rebuild => Self::Rebuild, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiTodayRefreshRecord { + pub schema_version: u16, + pub update: FfiTodayProjectionUpdate, + pub source_events: u64, + pub visible_cards: u64, + pub profiles: u64, + pub thread_entries: u64, + pub content_generation: u64, + pub changed: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum FfiTodayRelaySyncState { + Complete, + Partial, + Offline, +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiTodaySyncRecord { + pub schema_version: u16, + pub relay_state: FfiTodayRelaySyncState, + pub pages_fetched: u16, + pub events_observed: u64, + pub events_admitted: u64, + pub events_rejected: u64, + pub projection: FfiTodayRefreshRecord, +} + +#[cfg_attr(coverage_nightly, coverage(off))] +impl From<TodaySyncReceipt> for FfiTodaySyncRecord { + fn from(value: TodaySyncReceipt) -> Self { + Self { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + relay_state: match value.relay_state { + TodayRelaySyncState::Complete => FfiTodayRelaySyncState::Complete, + TodayRelaySyncState::Partial => FfiTodayRelaySyncState::Partial, + TodayRelaySyncState::Offline => FfiTodayRelaySyncState::Offline, + }, + pages_fetched: value.pages_fetched, + events_observed: value.events_observed, + events_admitted: value.events_admitted, + events_rejected: value.events_rejected, + projection: value.projection.into(), + } + } +} + +#[cfg_attr(coverage_nightly, coverage(off))] +impl From<TodayRefreshReceipt> for FfiTodayRefreshRecord { + fn from(value: TodayRefreshReceipt) -> Self { + Self { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + update: match value.update { + TodayProjectionUpdate::Incremental => FfiTodayProjectionUpdate::Incremental, + TodayProjectionUpdate::Rebuild => FfiTodayProjectionUpdate::Rebuild, + }, + source_events: value.source_events, + visible_cards: value.visible_cards, + profiles: value.profiles, + thread_entries: value.thread_entries, + content_generation: value.content_generation, + changed: value.changed, + } + } +} + +#[cfg_attr(coverage_nightly, coverage(off))] +impl From<TodayPage> for FfiTodayPageRecord { + fn from(value: TodayPage) -> Self { + Self { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + as_of_unix_s: value.as_of, + items: value.items.into_iter().map(Into::into).collect(), + next_cursor: value.next_cursor, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum FfiSearchResultType { + Card, + Profile, +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiSearchResultRecord { + pub schema_version: u16, + pub result_type: FfiSearchResultType, + pub stable_id: String, + pub card: Option<FfiTodayCardRecord>, + pub profile: Option<FfiProfileRecord>, +} + +#[cfg_attr(coverage_nightly, coverage(off))] +impl From<SearchResult> for FfiSearchResultRecord { + fn from(value: SearchResult) -> Self { + Self { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + result_type: match value.result_type { + SearchResultType::Card => FfiSearchResultType::Card, + SearchResultType::Profile => FfiSearchResultType::Profile, + }, + stable_id: value.stable_id, + card: value.card.map(Into::into), + profile: value.profile.map(Into::into), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiMeRecord { + pub schema_version: u16, + pub public_key: String, + pub profile: Option<FfiProfileRecord>, + pub cards: Vec<FfiTodayCardRecord>, +} + +#[cfg_attr(coverage_nightly, coverage(off))] +impl From<MeSnapshot> for FfiMeRecord { + fn from(value: MeSnapshot) -> Self { + Self { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + public_key: value.public_key, + profile: value.profile.map(Into::into), + cards: value.cards.into_iter().map(Into::into).collect(), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum FfiAddFieldKind { + Text, + MultilineText, + Date, + DateTime, + Decimal, + Choice, + Location, + Media, +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiAddFieldRecord { + pub schema_version: u16, + pub id: String, + pub label: String, + pub kind: FfiAddFieldKind, + pub required: bool, + pub choices: Vec<String>, + pub max_bytes: Option<u64>, + pub max_items: Option<u16>, +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiAddSchemaRecord { + pub schema_version: u16, + pub command_type: FfiAddCommandType, + pub label: String, + pub fields: Vec<FfiAddFieldRecord>, +} + +pub fn add_schemas() -> Vec<FfiAddSchemaRecord> { + use FfiAddCommandType as Command; + use FfiAddFieldKind as Kind; + + let field = + |id: &str, label: &str, kind, required, choices: &[&str], max_bytes| FfiAddFieldRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + id: id.to_owned(), + label: label.to_owned(), + kind, + required, + choices: choices.iter().map(|value| (*value).to_owned()).collect(), + max_bytes, + max_items: None, + }; + let media_field = |label: &str, required: bool, max_items| FfiAddFieldRecord { + max_items: Some(max_items), + ..field( + "media", + label, + Kind::Media, + required, + &[], + Some(MEDIA_FILE_MAX_BYTES), + ) + }; + vec![ + FfiAddSchemaRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + command_type: Command::CreateUpdate, + label: "Update".to_owned(), + fields: vec![field( + "content", + "Update", + Kind::MultilineText, + true, + &[], + Some(65_535), + )], + }, + FfiAddSchemaRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + command_type: Command::CreatePhotoUpdate, + label: "Photo update".to_owned(), + fields: vec![ + field( + "content", + "Update", + Kind::MultilineText, + true, + &[], + Some(65_535), + ), + media_field("Photos", true, 20), + ], + }, + FfiAddSchemaRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + command_type: Command::CreateAsk, + label: "Ask".to_owned(), + fields: vec![ + field( + "content", + "Question", + Kind::MultilineText, + true, + &[], + Some(65_535), + ), + media_field("Photos", false, 20), + ], + }, + FfiAddSchemaRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + command_type: Command::CreateEvent, + label: "Event".to_owned(), + fields: vec![ + field("identifier", "Identifier", Kind::Text, true, &[], Some(256)), + field("title", "Title", Kind::Text, true, &[], Some(256)), + field( + "content", + "Description", + Kind::MultilineText, + false, + &[], + Some(65_535), + ), + field("event_start", "Starts", Kind::DateTime, true, &[], None), + field("event_end", "Ends", Kind::DateTime, false, &[], None), + field( + "location", + "Location", + Kind::Location, + false, + &[], + Some(256), + ), + media_field("Photo", false, 1), + ], + }, + FfiAddSchemaRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + command_type: Command::CreateFoodAvailability, + label: "Food availability".to_owned(), + fields: vec![ + field("identifier", "Identifier", Kind::Text, true, &[], Some(256)), + field("title", "Food", Kind::Text, true, &[], Some(256)), + field("summary", "Summary", Kind::Text, true, &[], Some(256)), + field( + "content", + "Details", + Kind::MultilineText, + true, + &[], + Some(65_535), + ), + field( + "location", + "Pickup location", + Kind::Location, + true, + &[], + Some(256), + ), + field("price_amount", "Price", Kind::Decimal, true, &[], Some(64)), + field("currency", "Currency", Kind::Choice, true, &[], Some(3)), + field( + "unit", + "Unit", + Kind::Choice, + true, + &[ + "g", "kg", "lb", "oz", "each", "dozen", "bunch", "punnet", "bag", "basket", + ], + None, + ), + field( + "quantity", + "Available quantity", + Kind::Decimal, + false, + &[], + Some(64), + ), + media_field("Photos", false, 20), + ], + }, + ] +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum FfiEventTimingKind { + AllDay, + Timed, +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiPreparedMediaInput { + pub schema_version: u16, + pub opaque_reference: String, + pub file_descriptor: u64, + pub sha256: String, + pub media_type: String, + pub byte_size: u64, + pub width: u32, + pub height: u32, + pub alt: String, + pub prepared_at_unix_s: u64, +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiAddDraftInput { + pub schema_version: u16, + pub command_type: FfiAddCommandType, + pub content: String, + pub identifier: Option<String>, + pub title: Option<String>, + pub summary: Option<String>, + pub location: Option<String>, + pub event_timing: Option<FfiEventTimingKind>, + pub event_start_date: Option<String>, + pub event_end_date: Option<String>, + pub event_start_unix_s: Option<u64>, + pub event_end_unix_s: Option<u64>, + pub event_timezone: Option<String>, + pub price_amount: Option<String>, + pub currency: Option<String>, + pub unit: Option<String>, + pub quantity: Option<String>, + pub food_published_at_unix_s: Option<u64>, + pub food_status: Option<String>, + pub media: Vec<FfiPreparedMediaInput>, +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiRetractionDraftInput { + pub schema_version: u16, + pub command_type: FfiAddCommandType, + pub target_card_id: String, + pub target_event_id: String, + pub target_kind: u32, + pub target_address: Option<String>, + pub reason: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiBlossomUploadInput { + pub schema_version: u16, + pub draft_id: String, + pub expected_revision: u64, + pub media: FfiPreparedMediaInput, + pub authorization_content: String, + pub authorization_created_at_unix_s: u64, + pub authorization_lifetime_seconds: u64, + pub operation_id: String, + pub artifact_id: String, + pub signing_deadline_unix_ms: u64, + pub signing_cancellation: FfiCancellationPolicy, + pub verified_at_unix_ms: u64, + pub updated_at_unix_ms: u64, +} + +/// Minimal host input for a Rust-planned exact-byte upload attempt. +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiBlossomUploadIntent { + pub schema_version: u16, + pub draft_id: String, + pub expected_revision: u64, + pub media: FfiPreparedMediaInput, +} + +/// Secret-bearing native job. Hosts may use the authorization header for the +/// immediate OS request but must not persist it in application metadata. +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiNativeUploadJobRecord { + pub schema_version: u16, + pub operation_id: String, + pub draft: FfiDraftStatusRecord, + pub remote_url: String, + pub authorization_header: String, + pub expected_sha256: String, + pub media_type: String, + pub byte_size: u64, +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiNativeUploadCompletionInput { + pub schema_version: u16, + pub draft_id: String, + pub expected_revision: u64, + pub media: FfiPreparedMediaInput, + pub status_code: u16, + pub response_media_type: Option<String>, + pub response_content_encoding: Option<String>, + pub response_body: Vec<u8>, +} + +impl FfiAddDraftInput { + pub(crate) fn command_and_media( + self, + authored_at_unix_s: u64, + blossom: Option<&radroots_sdk::transport::BlossomSlot>, + ) -> Result<(Phase1AddCommand, Vec<Phase1MediaPrerequisite>), RadrootsAppError> { + self.command_media_and_form(authored_at_unix_s, blossom) + .map(|(command, media, _)| (command, media)) + } + + pub(crate) fn command_media_and_form( + self, + authored_at_unix_s: u64, + blossom: Option<&radroots_sdk::transport::BlossomSlot>, + ) -> Result< + ( + Phase1AddCommand, + Vec<Phase1MediaPrerequisite>, + Phase1DraftFormSnapshot, + ), + RadrootsAppError, + > { + require_schema(self.schema_version)?; + if authored_at_unix_s == 0 || self.media.len() > 20 { + return Err(RadrootsAppError::invalid_argument("invalid_add_draft")); + } + let prepared = self + .media + .iter() + .cloned() + .map(PreparedMedia::try_from) + .map(|media| { + media.and_then(|media| { + let blossom = blossom.ok_or_else(|| { + RadrootsAppError::invalid_argument("blossom_not_configured") + })?; + media.bind(blossom) + }) + }) + .collect::<Result<Vec<BoundPreparedMedia>, _>>()?; + let prerequisites = prepared + .iter() + .map(|value| { + Phase1MediaPrerequisite::new( + value.media.opaque_reference.clone(), + &value.descriptor, + ) + }) + .collect::<Result<Vec<_>, _>>() + .map_err(|_| RadrootsAppError::invalid_argument("invalid_media_reference"))?; + let post_images = prepared + .iter() + .map(BoundPreparedMedia::post_image) + .collect::<Result<Vec<_>, _>>()?; + let form = self.form_snapshot(&prepared); + let command = match self.command_type { + FfiAddCommandType::CreateUpdate => { + reject_media(&prepared)?; + Phase1AddCommand::CreateUpdate( + CreateUpdate::new(self.content) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_update"))?, + ) + } + FfiAddCommandType::CreatePhotoUpdate => Phase1AddCommand::CreatePhotoUpdate( + CreatePhotoUpdate::new( + content_with_media_references(self.content, &prepared)?, + post_images, + ) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_photo_update"))?, + ), + FfiAddCommandType::CreateAsk => Phase1AddCommand::CreateAsk( + CreateAsk::new( + content_with_media_references(self.content, &prepared)?, + post_images, + ) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_ask"))?, + ), + FfiAddCommandType::CreateEvent => { + Phase1AddCommand::CreateEvent(event_command(&self, prepared.first())?) + } + FfiAddCommandType::CreateFoodAvailability => Phase1AddCommand::CreateFoodAvailability( + food_command(self, authored_at_unix_s, &prepared)?, + ), + }; + Ok((command, prerequisites, form)) + } + + fn form_snapshot(&self, prepared: &[BoundPreparedMedia]) -> Phase1DraftFormSnapshot { + Phase1DraftFormSnapshot { + command_type: match self.command_type { + FfiAddCommandType::CreateUpdate => AddCommandType::CreateUpdate, + FfiAddCommandType::CreatePhotoUpdate => AddCommandType::CreatePhotoUpdate, + FfiAddCommandType::CreateAsk => AddCommandType::CreateAsk, + FfiAddCommandType::CreateEvent => AddCommandType::CreateEvent, + FfiAddCommandType::CreateFoodAvailability => AddCommandType::CreateFoodAvailability, + }, + content: self.content.clone(), + identifier: self.identifier.clone(), + title: self.title.clone(), + summary: self.summary.clone(), + location: self.location.clone(), + event_timing: self.event_timing.map(|value| match value { + FfiEventTimingKind::AllDay => Phase1DraftEventTiming::AllDay, + FfiEventTimingKind::Timed => Phase1DraftEventTiming::Timed, + }), + event_start_date: self.event_start_date.clone(), + event_end_date: self.event_end_date.clone(), + event_start_unix_s: self.event_start_unix_s, + event_end_unix_s: self.event_end_unix_s, + event_timezone: self.event_timezone.clone(), + price_amount: self.price_amount.clone(), + currency: self.currency.clone(), + unit: self.unit.clone(), + quantity: self.quantity.clone(), + food_published_at_unix_s: self.food_published_at_unix_s, + food_status: self.food_status.clone(), + media: prepared + .iter() + .map(|value| Phase1DraftMediaSnapshot { + opaque_reference: value.media.opaque_reference.clone(), + url: value.descriptor.url().as_str().to_owned(), + sha256: value.media.sha256.to_hex(), + media_type: value.media.media_type.as_str().to_owned(), + byte_size: value.media.byte_size, + width: value.media.width, + height: value.media.height, + alt: value.media.alt.clone(), + prepared_at_unix_s: value.media.prepared_at_unix_s, + }) + .collect(), + } + } +} + +pub(crate) struct PreparedMedia { + opaque_reference: String, + sha256: Sha256, + byte_size: u64, + prepared_at_unix_s: u64, + bytes: std::sync::Arc<[u8]>, + media_type: MediaType, + width: u32, + height: u32, + alt: String, +} + +struct BoundPreparedMedia { + media: PreparedMedia, + descriptor: radroots_blossom::ByteVerifiedDescriptor, +} + +impl TryFrom<FfiPreparedMediaInput> for PreparedMedia { + type Error = RadrootsAppError; + + fn try_from(value: FfiPreparedMediaInput) -> Result<Self, Self::Error> { + require_schema(value.schema_version)?; + if !opaque_media_reference_is_valid(&value.opaque_reference) + || value.byte_size == 0 + || value.byte_size > MEDIA_FILE_MAX_BYTES + || value.width == 0 + || value.height == 0 + || value.prepared_at_unix_s == 0 + || value.alt.trim().is_empty() + || value.alt.len() > 1_024 + { + return Err(RadrootsAppError::invalid_argument( + "invalid_media_reference", + )); + } + let byte_size = usize::try_from(value.byte_size) + .map_err(|_| RadrootsAppError::invalid_argument("media_size_mismatch"))?; + let bytes = read_media_file_descriptor(value.file_descriptor, value.byte_size, byte_size)?; + let media_type = MediaType::parse(&value.media_type) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_media_type"))?; + let sha256 = Sha256::from_hex(&value.sha256) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_media_digest"))?; + if Sha256::digest(&bytes) != sha256 { + return Err(RadrootsAppError::invalid_argument( + "media_verification_failed", + )); + } + let dimensions = + radroots_sdk::transport::BlossomImageDimensions::new(value.width, value.height) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_image_dimensions"))?; + let verified_at_unix_ms = value + .prepared_at_unix_s + .checked_mul(1_000) + .ok_or_else(|| RadrootsAppError::invalid_argument("invalid_media_reference"))?; + radroots_sdk::transport::BlossomUploadRequest::new( + bytes.clone().into(), + media_type.clone(), + dimensions, + verified_at_unix_ms, + ) + .map_err(|_| RadrootsAppError::invalid_argument("media_verification_failed"))?; + Ok(Self { + opaque_reference: value.opaque_reference, + sha256, + byte_size: value.byte_size, + prepared_at_unix_s: value.prepared_at_unix_s, + bytes: bytes.into(), + media_type, + width: value.width, + height: value.height, + alt: value.alt, + }) + } +} + +#[cfg(unix)] +fn read_media_file_descriptor( + file_descriptor: u64, + expected_size: u64, + byte_size: usize, +) -> Result<Vec<u8>, RadrootsAppError> { + let raw_file_descriptor = RawFd::try_from(file_descriptor) + .map_err(|_| RadrootsAppError::invalid_argument("media_handle_unavailable"))?; + // SAFETY: `fcntl(F_DUPFD_CLOEXEC)` accepts any in-range integer descriptor + // and reports EBADF for an unavailable one. No borrowed or owned Rust + // descriptor is constructed until the kernel has duplicated it. + let duplicated = unsafe { libc::fcntl(raw_file_descriptor, libc::F_DUPFD_CLOEXEC, 0) }; + if duplicated < 0 { + return Err(RadrootsAppError::invalid_argument( + "media_handle_unavailable", + )); + } + // SAFETY: a nonnegative F_DUPFD_CLOEXEC result is a new descriptor owned by + // this call. The host's original descriptor remains independently owned. + let owned = unsafe { OwnedFd::from_raw_fd(duplicated) }; + let file = std::fs::File::from(owned); + let metadata = file + .metadata() + .map_err(|_| RadrootsAppError::invalid_argument("media_handle_unavailable"))?; + if !metadata.is_file() || metadata.len() != expected_size { + return Err(RadrootsAppError::invalid_argument("media_size_mismatch")); + } + let mut bytes = vec![0; byte_size]; + file.read_exact_at(&mut bytes, 0) + .map_err(|_| RadrootsAppError::invalid_argument("media_read_failed"))?; + Ok(bytes) +} + +#[cfg(not(unix))] +fn read_media_file_descriptor( + _file_descriptor: u64, + _expected_size: u64, + _byte_size: usize, +) -> Result<Vec<u8>, RadrootsAppError> { + Err(RadrootsAppError::failure( + "media_handle_unsupported", + "capability", + false, + &[], + "Protected media handles are unsupported on this platform.", + )) +} + +impl PreparedMedia { + pub(crate) fn into_authored_image( + self, + blossom: &radroots_sdk::transport::BlossomSlot, + ) -> Result<AuthoredImage, RadrootsAppError> { + self.bind(blossom)?.authored_image() + } + + pub(crate) fn into_upload_intent( + self, + draft_id: [u8; 16], + expected_revision: u64, + ) -> Result<Phase1UploadIntent, RadrootsAppError> { + Phase1UploadIntent::new( + draft_id, + expected_revision, + self.bytes, + self.media_type, + self.width, + self.height, + ) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_blossom_upload")) + } + + pub(crate) fn upload_request( + &self, + verified_at_unix_ms: u64, + ) -> Result<radroots_sdk::transport::BlossomUploadRequest, RadrootsAppError> { + let dimensions = + radroots_sdk::transport::BlossomImageDimensions::new(self.width, self.height) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_image_dimensions"))?; + radroots_sdk::transport::BlossomUploadRequest::new( + std::sync::Arc::clone(&self.bytes), + self.media_type.clone(), + dimensions, + verified_at_unix_ms, + ) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_blossom_upload")) + } + + fn bind( + self, + blossom: &radroots_sdk::transport::BlossomSlot, + ) -> Result<BoundPreparedMedia, RadrootsAppError> { + let verified_at_unix_ms = self + .prepared_at_unix_s + .checked_mul(1_000) + .ok_or_else(|| RadrootsAppError::invalid_argument("invalid_media_reference"))?; + let transaction = blossom + .prepare_upload(self.upload_request(verified_at_unix_ms)?) + .map_err(|error| RadrootsAppError::invalid_argument(error.code()))?; + let descriptor = BlobDescriptor::new( + transaction.expected_url().clone(), + self.sha256, + self.byte_size, + self.media_type.clone(), + self.prepared_at_unix_s, + ) + .and_then(BlobDescriptor::approve_reference) + .and_then(|descriptor| descriptor.verify_bytes(&self.bytes, &self.media_type)) + .map_err(|_| RadrootsAppError::invalid_argument("media_verification_failed"))?; + Ok(BoundPreparedMedia { + media: self, + descriptor, + }) + } +} + +impl BoundPreparedMedia { + fn authored_image(&self) -> Result<AuthoredImage, RadrootsAppError> { + AuthoredImage::try_from_verified_descriptor(self.descriptor.clone()) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_image_media")) + } + + fn post_image(&self) -> Result<AuthoredPostImage, RadrootsAppError> { + AuthoredPostImage::new( + self.authored_image()?, + PostImageDimensions::new(self.media.width, self.media.height) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_image_dimensions"))?, + self.media.alt.clone(), + ) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_image")) + } +} + +fn event_command( + input: &FfiAddDraftInput, + image: Option<&BoundPreparedMedia>, +) -> Result<CreateEvent, RadrootsAppError> { + if input.media.len() > 1 { + return Err(RadrootsAppError::invalid_argument("event_image_limit")); + } + let identifier = required(input.identifier.as_deref(), "event_identifier_required")?; + let title = required(input.title.as_deref(), "event_title_required")?; + let timing = input + .event_timing + .ok_or_else(|| RadrootsAppError::invalid_argument("event_timing_required"))?; + match timing { + FfiEventTimingKind::AllDay => { + let start = CalendarDate::parse(required( + input.event_start_date.as_deref(), + "event_start_date_required", + )?) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_event_start_date"))?; + let mut event = AuthoredCalendarDateEvent::new(identifier, title, start) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_event"))?; + if let Some(end) = input.event_end_date.as_deref() { + event = event + .with_end(CalendarDate::parse(end).map_err(|_| { + RadrootsAppError::invalid_argument("invalid_event_end_date") + })?) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_event_range"))?; + } + if !input.content.is_empty() { + event = event + .with_description(input.content.clone()) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_event_description"))?; + } + if let Some(location) = input.location.clone() { + event = event + .with_locations(vec![location]) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_event_location"))?; + } + if let Some(image) = image { + event = event + .with_image(image.authored_image()?) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_event_image"))?; + } + Ok(CreateEvent::date(event)) + } + FfiEventTimingKind::Timed => { + let start = input + .event_start_unix_s + .filter(|value| *value != 0) + .ok_or_else(|| RadrootsAppError::invalid_argument("event_start_required"))?; + let mut event = AuthoredCalendarTimeEvent::new(identifier, title, start) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_event"))?; + if let Some(end) = input.event_end_unix_s { + event = event + .with_end(end) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_event_range"))?; + } + if let Some(timezone) = input.event_timezone.as_deref() { + event = event + .with_start_tzid(timezone) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_event_timezone"))?; + } + if !input.content.is_empty() { + event = event + .with_description(input.content.clone()) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_event_description"))?; + } + if let Some(location) = input.location.clone() { + event = event + .with_locations(vec![location]) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_event_location"))?; + } + if let Some(image) = image { + event = event + .with_image(image.authored_image()?) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_event_image"))?; + } + Ok(CreateEvent::time(event)) + } + } +} + +fn food_command( + input: FfiAddDraftInput, + authored_at_unix_s: u64, + media: &[BoundPreparedMedia], +) -> Result<CreateFoodAvailability, RadrootsAppError> { + let unit = FoodUnit::parse(required(input.unit.as_deref(), "food_unit_required")?) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_unit"))?; + let images = media + .iter() + .map(|image| { + Ok(FoodAvailabilityImage::new( + image.authored_image()?, + FoodImageDimensions::new(image.media.width, image.media.height) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_image_dimensions"))?, + )) + }) + .collect::<Result<Vec<_>, RadrootsAppError>>()?; + let status = input.food_status.as_deref().unwrap_or("active"); + let details = FoodAvailabilityDetails::new(FoodAvailabilityDetailsParts { + content: FoodContent::new(input.content) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_content"))?, + identifier: FoodIdentifier::parse(required( + input.identifier.as_deref(), + "food_identifier_required", + )?) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_identifier"))?, + title: FoodText::new(required(input.title, "food_title_required")?) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_title"))?, + summary: FoodText::new(required(input.summary, "food_summary_required")?) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_summary"))?, + published_at: FoodPublishedAt::new( + input.food_published_at_unix_s.unwrap_or(authored_at_unix_s), + ) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_published_at"))?, + location: FoodText::new(required(input.location, "food_location_required")?) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_location"))?, + price: FoodPrice::new( + required(input.price_amount, "food_price_required")?, + FoodCurrency::parse(required(input.currency, "food_currency_required")?) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_currency"))?, + unit, + ) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_price"))?, + quantity: input + .quantity + .map(|quantity| FoodQuantity::new(quantity, unit)) + .transpose() + .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_quantity"))?, + status: FoodAvailabilityStatus::parse(status) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_status"))?, + images, + }) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_availability"))?; + Ok(CreateFoodAvailability::new(details)) +} + +fn reject_media(media: &[BoundPreparedMedia]) -> Result<(), RadrootsAppError> { + if media.is_empty() { + Ok(()) + } else { + Err(RadrootsAppError::invalid_argument("media_not_allowed")) + } +} + +fn content_with_media_references( + mut content: String, + media: &[BoundPreparedMedia], +) -> Result<String, RadrootsAppError> { + if content.trim().is_empty() { + return Err(RadrootsAppError::invalid_argument("content_required")); + } + for item in media { + let url = item.descriptor.url().as_str(); + match content.match_indices(url).count() { + 0 => { + if !content.ends_with('\n') { + content.push('\n'); + } + content.push_str(url); + } + 1 => {} + _ => { + return Err(RadrootsAppError::invalid_argument( + "duplicate_media_reference", + )); + } + } + } + Ok(content) +} + +fn required<T>(value: Option<T>, code: &'static str) -> Result<T, RadrootsAppError> { + value.ok_or_else(|| RadrootsAppError::invalid_argument(code)) +} + +fn opaque_media_reference_is_valid(value: &str) -> bool { + value.len() > "media:".len() + && value.len() <= MEDIA_REFERENCE_MAX_BYTES + && value.starts_with("media:") + && value["media:".len()..].bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_') + }) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum FfiMediaStage { + Pending, + Preparing, + Uploading, + Verified, + Failed, + Orphaned, +} + +#[cfg_attr(coverage_nightly, coverage(off))] +impl From<Phase1MediaStage> for FfiMediaStage { + fn from(value: Phase1MediaStage) -> Self { + match value { + Phase1MediaStage::Pending => Self::Pending, + Phase1MediaStage::Preparing => Self::Preparing, + Phase1MediaStage::Uploading => Self::Uploading, + Phase1MediaStage::Verified => Self::Verified, + Phase1MediaStage::Failed => Self::Failed, + Phase1MediaStage::Orphaned => Self::Orphaned, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiDraftMediaRecord { + pub schema_version: u16, + pub url: String, + pub stage: FfiMediaStage, + pub upload_attempts: u8, + pub verified_at_unix_ms: Option<u64>, + pub possible_orphan: bool, + pub orphan_reason_code: Option<String>, + pub orphan_recorded_at_unix_ms: Option<u64>, +} + +#[cfg_attr(coverage_nightly, coverage(off))] +impl From<&Phase1MediaPrerequisite> for FfiDraftMediaRecord { + fn from(value: &Phase1MediaPrerequisite) -> Self { + let orphan = value.orphan(); + Self { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + url: value.url().to_owned(), + stage: value.stage().into(), + upload_attempts: value.upload_attempts(), + verified_at_unix_ms: value.verified_at_unix_ms(), + possible_orphan: orphan.is_some(), + orphan_reason_code: orphan.map(|value| value.reason_code().to_owned()), + orphan_recorded_at_unix_ms: orphan.map(|value| value.recorded_at_unix_ms()), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum FfiOutboxState { + Draft, + MediaPreparing, + MediaUploading, + ReadyToSign, + Signing, + Signed, + Queued, + Delivering, + PartiallyDelivered, + Retryable, + Terminal, + Cancelled, + Complete, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum FfiDraftKind { + Add, + Retraction, +} + +#[cfg_attr(coverage_nightly, coverage(off))] +impl From<Phase1DraftKind> for FfiDraftKind { + fn from(value: Phase1DraftKind) -> Self { + match value { + Phase1DraftKind::Add => Self::Add, + Phase1DraftKind::Retraction => Self::Retraction, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiDraftFormMediaRecord { + pub schema_version: u16, + pub opaque_reference: String, + pub url: String, + pub sha256: String, + pub media_type: String, + pub byte_size: u64, + pub width: u32, + pub height: u32, + pub alt: String, + pub prepared_at_unix_s: u64, +} + +#[cfg_attr(coverage_nightly, coverage(off))] +impl From<&Phase1DraftMediaSnapshot> for FfiDraftFormMediaRecord { + fn from(value: &Phase1DraftMediaSnapshot) -> Self { + Self { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + opaque_reference: value.opaque_reference.clone(), + url: value.url.clone(), + sha256: value.sha256.clone(), + media_type: value.media_type.clone(), + byte_size: value.byte_size, + width: value.width, + height: value.height, + alt: value.alt.clone(), + prepared_at_unix_s: value.prepared_at_unix_s, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiDraftFormRecord { + pub schema_version: u16, + pub command_type: FfiAddCommandType, + pub content: String, + pub identifier: Option<String>, + pub title: Option<String>, + pub summary: Option<String>, + pub location: Option<String>, + pub event_timing: Option<FfiEventTimingKind>, + pub event_start_date: Option<String>, + pub event_end_date: Option<String>, + pub event_start_unix_s: Option<u64>, + pub event_end_unix_s: Option<u64>, + pub event_timezone: Option<String>, + pub price_amount: Option<String>, + pub currency: Option<String>, + pub unit: Option<String>, + pub quantity: Option<String>, + pub food_published_at_unix_s: Option<u64>, + pub food_status: Option<String>, + pub media: Vec<FfiDraftFormMediaRecord>, +} + +#[cfg_attr(coverage_nightly, coverage(off))] +impl From<&Phase1DraftFormSnapshot> for FfiDraftFormRecord { + fn from(value: &Phase1DraftFormSnapshot) -> Self { + Self { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + command_type: value.command_type.into(), + content: value.content.clone(), + identifier: value.identifier.clone(), + title: value.title.clone(), + summary: value.summary.clone(), + location: value.location.clone(), + event_timing: value.event_timing.map(|value| match value { + Phase1DraftEventTiming::AllDay => FfiEventTimingKind::AllDay, + Phase1DraftEventTiming::Timed => FfiEventTimingKind::Timed, + }), + event_start_date: value.event_start_date.clone(), + event_end_date: value.event_end_date.clone(), + event_start_unix_s: value.event_start_unix_s, + event_end_unix_s: value.event_end_unix_s, + event_timezone: value.event_timezone.clone(), + price_amount: value.price_amount.clone(), + currency: value.currency.clone(), + unit: value.unit.clone(), + quantity: value.quantity.clone(), + food_published_at_unix_s: value.food_published_at_unix_s, + food_status: value.food_status.clone(), + media: value.media.iter().map(Into::into).collect(), + } + } +} + +#[cfg_attr(coverage_nightly, coverage(off))] +impl From<Phase1OutboxState> for FfiOutboxState { + fn from(value: Phase1OutboxState) -> Self { + match value { + Phase1OutboxState::Draft => Self::Draft, + Phase1OutboxState::MediaPreparing => Self::MediaPreparing, + Phase1OutboxState::MediaUploading => Self::MediaUploading, + Phase1OutboxState::ReadyToSign => Self::ReadyToSign, + Phase1OutboxState::Signing => Self::Signing, + Phase1OutboxState::Signed => Self::Signed, + Phase1OutboxState::Queued => Self::Queued, + Phase1OutboxState::Delivering => Self::Delivering, + Phase1OutboxState::PartiallyDelivered => Self::PartiallyDelivered, + Phase1OutboxState::Retryable => Self::Retryable, + Phase1OutboxState::Terminal => Self::Terminal, + Phase1OutboxState::Cancelled => Self::Cancelled, + Phase1OutboxState::Complete => Self::Complete, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiDraftStatusRecord { + pub schema_version: u16, + pub draft_id: String, + pub revision: u64, + pub author_public_key: String, + pub kind: FfiDraftKind, + pub command_type: FfiAddCommandType, + pub form: Option<FfiDraftFormRecord>, + pub state: FfiOutboxState, + pub card_id: String, + pub operation_id: Option<String>, + pub created_at_unix_ms: u64, + pub updated_at_unix_ms: u64, + pub media: Vec<FfiDraftMediaRecord>, + pub settlement: Option<FfiOperationSettlementRecord>, + pub is_revision: bool, +} + +#[cfg_attr(coverage_nightly, coverage(off))] +impl From<Phase1DraftStatus> for FfiDraftStatusRecord { + fn from(value: Phase1DraftStatus) -> Self { + let draft = value.draft(); + let settlement = value.push().map(|push| push.settlement()); + Self { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + draft_id: hex::encode(draft.draft_id().as_bytes()), + revision: draft.revision().get(), + author_public_key: hex::encode(draft.author()), + kind: value.kind().into(), + command_type: value.command_type().into(), + form: value.form().map(Into::into), + state: value.state().into(), + card_id: value.card_id().to_hex(), + operation_id: draft.operation_id().map(|id| hex::encode(id.as_bytes())), + created_at_unix_ms: draft.created_at_unix_ms(), + updated_at_unix_ms: draft.updated_at_unix_ms(), + media: value.media().iter().map(Into::into).collect(), + settlement: settlement.map(Into::into), + is_revision: value.revision_policy().is_some(), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiOperationSettlementRecord { + pub schema_version: u16, + pub artifacts: u16, + pub signed: u16, + pub admitted: u16, + pub pending: u16, + pub retryable: u16, + pub indeterminate: u16, + pub failed_terminal: u16, + pub cancelled: u16, + pub delivery_plans: u16, + pub delivery_satisfied: u16, + pub delivery_pending: u16, + pub delivery_retryable: u16, + pub delivery_exhausted: u16, + pub delivery_failed_terminal: u16, + pub delivery_cancelled: u16, +} + +#[cfg_attr(coverage_nightly, coverage(off))] +impl From<radroots_storage::authored::OperationSettlement> for FfiOperationSettlementRecord { + fn from(value: radroots_storage::authored::OperationSettlement) -> Self { + Self { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + artifacts: value.artifacts(), + signed: value.signed(), + admitted: value.admitted(), + pending: value.pending(), + retryable: value.retryable(), + indeterminate: value.indeterminate(), + failed_terminal: value.failed_terminal(), + cancelled: value.cancelled(), + delivery_plans: value.delivery_plans(), + delivery_satisfied: value.delivery_satisfied(), + delivery_pending: value.delivery_pending(), + delivery_retryable: value.delivery_retryable(), + delivery_exhausted: value.delivery_exhausted(), + delivery_failed_terminal: value.delivery_failed_terminal(), + delivery_cancelled: value.delivery_cancelled(), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum FfiRelaySatisfaction { + AnyAccepted, + AllAccepted, + AnyDelivered, + AllDelivered, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum FfiCancellationPolicy { + PreservePublishedRequest, + LocalCooperative, +} + +impl FfiCancellationPolicy { + pub(crate) const fn core(self) -> Phase1CancellationPolicy { + match self { + Self::PreservePublishedRequest => Phase1CancellationPolicy::PreservePublishedRequest, + Self::LocalCooperative => Phase1CancellationPolicy::LocalCooperative, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiQueuePolicyRecord { + pub schema_version: u16, + pub relay_urls: Vec<String>, + pub satisfaction: FfiRelaySatisfaction, + pub delivery_deadline_unix_ms: u64, + pub cancellation: FfiCancellationPolicy, +} + +impl TryFrom<FfiQueuePolicyRecord> for Phase1QueuePolicy { + type Error = RadrootsAppError; + + fn try_from(value: FfiQueuePolicyRecord) -> Result<Self, Self::Error> { + require_schema(value.schema_version)?; + Phase1QueuePolicy::new( + value.relay_urls, + match value.satisfaction { + FfiRelaySatisfaction::AnyAccepted => Phase1RelaySatisfaction::AnyAccepted, + FfiRelaySatisfaction::AllAccepted => Phase1RelaySatisfaction::AllAccepted, + FfiRelaySatisfaction::AnyDelivered => Phase1RelaySatisfaction::AnyDelivered, + FfiRelaySatisfaction::AllDelivered => Phase1RelaySatisfaction::AllDelivered, + }, + value.delivery_deadline_unix_ms, + match value.cancellation { + FfiCancellationPolicy::PreservePublishedRequest => { + Phase1CancellationPolicy::PreservePublishedRequest + } + FfiCancellationPolicy::LocalCooperative => { + Phase1CancellationPolicy::LocalCooperative + } + }, + ) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_queue_policy")) + } +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiCapabilityRecord { + pub schema_version: u16, + pub id: String, + pub compiled: bool, + pub configured: bool, + pub availability: String, + pub maturity: String, +} + +#[cfg_attr(coverage_nightly, coverage(off))] +impl From<SdkCapabilityRecord> for FfiCapabilityRecord { + fn from(value: SdkCapabilityRecord) -> Self { + Self { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + id: value.id, + compiled: value.compiled, + configured: value.configured, + availability: value.availability, + maturity: value.maturity, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiStorageStatusRecord { + pub schema_version: u16, + pub backend: String, + pub open_mode: String, + pub shutdown: String, + pub integrity: String, +} + +#[cfg_attr(coverage_nightly, coverage(off))] +impl From<SdkStorageStatusRecord> for FfiStorageStatusRecord { + fn from(value: SdkStorageStatusRecord) -> Self { + Self { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + backend: value.backend, + open_mode: value.open_mode, + shutdown: value.shutdown, + integrity: value.integrity, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum FfiRelayAccessRecord { + ReadOnly, + ReadWrite, +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiRelayStatusRecord { + pub schema_version: u16, + pub relay_url: String, + pub access: FfiRelayAccessRecord, + pub read_state: String, + pub write_state: String, + pub read_last_attempt_unix_ms: Option<u64>, + pub write_last_attempt_unix_ms: Option<u64>, + pub read_next_attempt_unix_ms: Option<u64>, + pub write_next_attempt_unix_ms: Option<u64>, +} + +#[cfg_attr(coverage_nightly, coverage(off))] +impl From<SdkRelayStatusRecord> for FfiRelayStatusRecord { + fn from(value: SdkRelayStatusRecord) -> Self { + Self { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + relay_url: value.relay_url, + access: match value.access { + SdkRelayAccessRecord::ReadOnly => FfiRelayAccessRecord::ReadOnly, + SdkRelayAccessRecord::ReadWrite => FfiRelayAccessRecord::ReadWrite, + }, + read_state: value.read_state, + write_state: value.write_state, + read_last_attempt_unix_ms: value.read_last_attempt_unix_ms, + write_last_attempt_unix_ms: value.write_last_attempt_unix_ms, + read_next_attempt_unix_ms: value.read_next_attempt_unix_ms, + write_next_attempt_unix_ms: value.write_next_attempt_unix_ms, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiRelayStatusReportRecord { + pub schema_version: u16, + pub profile: String, + pub state: String, + pub read_availability: String, + pub write_availability: String, + pub relays: Vec<FfiRelayStatusRecord>, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum FfiBlossomHostKind { + Native, + Simulator, + PhysicalDevice, +} + +impl From<FfiBlossomHostKind> for radroots_sdk::transport::BlossomHostKind { + fn from(value: FfiBlossomHostKind) -> Self { + match value { + FfiBlossomHostKind::Native => Self::Native, + FfiBlossomHostKind::Simulator => Self::Simulator, + FfiBlossomHostKind::PhysicalDevice => Self::PhysicalDevice, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum FfiBlossomEndpointAuthority { + PublicWebPki, + LoopbackDevelopment, + PrivateNetworkDevelopment, +} + +impl From<FfiBlossomEndpointAuthority> for radroots_sdk::transport::BlossomEndpointAuthority { + fn from(value: FfiBlossomEndpointAuthority) -> Self { + match value { + FfiBlossomEndpointAuthority::PublicWebPki => Self::PublicWebPki, + FfiBlossomEndpointAuthority::LoopbackDevelopment => Self::LoopbackDevelopment, + FfiBlossomEndpointAuthority::PrivateNetworkDevelopment => { + Self::PrivateNetworkDevelopment + } + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiBlossomConfigurationRecord { + pub schema_version: u16, + pub host_kind: String, + pub endpoint_authority: String, + pub primary_origin: String, + pub fallback_origins: Vec<String>, + pub config_fingerprint: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiBlossomEvidenceRecord { + pub schema_version: u16, + pub origin: String, + pub config_fingerprint: String, + pub state: String, + pub last_successful_state: String, + pub transport_security: String, + pub observed_at_unix_ms: Option<u64>, + pub http_status: Option<u16>, + pub error_code: Option<String>, + pub server_error_code: Option<String>, + pub error_phase: Option<String>, + pub retryable: bool, + pub possible_orphan: bool, + pub attempts: u8, +} + +#[cfg_attr(coverage_nightly, coverage(off))] +impl From<SdkBlossomConfigurationRecord> for FfiBlossomConfigurationRecord { + fn from(value: SdkBlossomConfigurationRecord) -> Self { + Self { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + host_kind: value.host_kind, + endpoint_authority: value.endpoint_authority, + primary_origin: value.primary_origin, + fallback_origins: value.fallback_origins, + config_fingerprint: value.config_fingerprint, + } + } +} + +#[cfg_attr(coverage_nightly, coverage(off))] +impl From<SdkBlossomEvidenceRecord> for FfiBlossomEvidenceRecord { + fn from(value: SdkBlossomEvidenceRecord) -> Self { + Self { + schema_version: value.schema_version, + origin: value.origin, + config_fingerprint: value.config_fingerprint, + state: value.state, + last_successful_state: value.last_successful_state, + transport_security: value.transport_security, + observed_at_unix_ms: value.observed_at_unix_ms, + http_status: value.http_status, + error_code: value.error_code, + server_error_code: value.server_error_code, + error_phase: value.error_phase, + retryable: value.retryable, + possible_orphan: value.possible_orphan, + attempts: value.attempts, + } + } +} + +#[cfg_attr(coverage_nightly, coverage(off))] +impl From<SdkRelayStatusReportRecord> for FfiRelayStatusReportRecord { + fn from(value: SdkRelayStatusReportRecord) -> Self { + Self { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + profile: value.profile, + state: value.state, + read_availability: value.read_availability, + write_availability: value.write_availability, + relays: value.relays.into_iter().map(Into::into).collect(), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiShutdownRecord { + pub schema_version: u16, + pub state: String, + pub already_closed: bool, +} + +#[cfg_attr(coverage_nightly, coverage(off))] +impl From<SdkShutdownRecord> for FfiShutdownRecord { + fn from(value: SdkShutdownRecord) -> Self { + Self { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + state: value.state, + already_closed: value.already_closed, + } + } +} + +pub(crate) fn decode_id(value: &str, code: &'static str) -> Result<[u8; 16], RadrootsAppError> { + if value.len() != 32 { + return Err(RadrootsAppError::invalid_argument(code)); + } + let bytes = hex::decode(value).map_err(|_| RadrootsAppError::invalid_argument(code))?; + bytes + .try_into() + .map_err(|_| RadrootsAppError::invalid_argument(code)) +} + +fn require_schema(schema_version: u16) -> Result<(), RadrootsAppError> { + if schema_version == MOBILE_FFI_SCHEMA_VERSION { + Ok(()) + } else { + Err(RadrootsAppError::invalid_argument( + "unsupported_schema_version", + )) + } +} + +#[cfg(test)] +mod tests { + use core::num::NonZeroU64; + use std::io::Write; + use std::os::fd::AsRawFd; + + use radroots_event::id::TradeId; + + use super::*; + + fn rhi_attestation_fixture() -> serde_json::Value { + let fixture: serde_json::Value = serde_json::from_str(include_str!( + "../../../test-fixtures/tera_ffi/authored_operations.v1.json" + )) + .expect("authored corpus"); + fixture["vectors"] + .as_array() + .expect("operations") + .iter() + .find(|entry| entry["id"] == "typed_rhi_evidence_attestation_017") + .expect("RHI operation") + .get("expected") + .expect("expected") + .clone() + } + + #[test] + fn evidence_report_plan_and_verified_event_use_final_mobile_vocabulary() { + let expected = rhi_attestation_fixture(); + let content = expected["content"].as_str().expect("content").to_owned(); + let report = parse_rhi_evidence_report(content.clone()).expect("report"); + assert_eq!(report.outcome, FfiTradeEvidenceOutcome::Indeterminate); + assert_eq!(report.trade_generation, "7"); + assert_eq!(report.observed_at_unix_s, "1800000000"); + + let plan = prepare_rhi_evidence_attestation(content, 1_784_347_200).expect("plan"); + assert_eq!(plan.kind, 3_441); + assert_eq!(plan.created_at_unix_s, "1784347200"); + assert_eq!( + plan.expected_event_id, + expected["event_id"].as_str().expect("event id") + ); + + let raw: serde_json::Value = + serde_json::from_str(expected["raw_json"].as_str().expect("raw event")) + .expect("raw event JSON"); + let signed = FfiSignedEvidenceEventRecord { + id: raw["id"].as_str().expect("id").to_owned(), + author_pubkey: raw["pubkey"].as_str().expect("pubkey").to_owned(), + created_at_unix_s: raw["created_at"].as_u64().expect("created_at"), + kind: u32::try_from(raw["kind"].as_u64().expect("kind")).expect("u32 kind"), + tags: serde_json::from_value(raw["tags"].clone()).expect("tags"), + content: raw["content"].as_str().expect("content").to_owned(), + signature: raw["sig"].as_str().expect("signature").to_owned(), + }; + let attestation = validate_rhi_evidence_attestation(signed).expect("attestation"); + assert_eq!(attestation.outcome, FfiTradeEvidenceOutcome::Indeterminate); + assert_eq!(attestation.trade_generation, "7"); + } + + #[test] + fn evidence_manifest_and_supersession_project_the_complete_vocabulary() { + use radroots_sdk::trade::{ + RadrootsTradeEvidenceManifestSourceResultV1, RadrootsTradeEvidenceManifestV1, + RadrootsTradeEvidencePolicyDigestV1, RadrootsTradeEvidenceScopePrerequisitesV1, + RadrootsTradeEvidenceSourceCompletionV1, RadrootsTradeEvidenceSourceIdV1, + RadrootsTradeEvidenceSourceRequirementV1, RadrootsTradeEvidenceSourceResultDigestV1, + RadrootsTradeEvidenceSourceResultV1, + }; + + assert_eq!( + FfiTradeEvidenceCoverage::from( + radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1::Missing + ), + FfiTradeEvidenceCoverage::Missing + ); + assert_eq!( + FfiTradeEvidenceCoverage::from( + radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1::Partial + ), + FfiTradeEvidenceCoverage::Partial + ); + assert_eq!( + FfiTradeEvidenceCoverage::from( + radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1::ScopeSatisfied + ), + FfiTradeEvidenceCoverage::ScopeSatisfied + ); + assert_eq!( + FfiTradeEvidenceCoverage::from( + radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1::Unsupported + ), + FfiTradeEvidenceCoverage::Unsupported + ); + assert_eq!( + FfiTradeEvidenceOutcome::from( + radroots_sdk::trade::RadrootsTradeEvidenceOutcomeV1::Valid + ), + FfiTradeEvidenceOutcome::Valid + ); + assert_eq!( + FfiTradeEvidenceOutcome::from( + radroots_sdk::trade::RadrootsTradeEvidenceOutcomeV1::Invalid + ), + FfiTradeEvidenceOutcome::Invalid + ); + assert_eq!( + FfiTradeEvidenceOutcome::from( + radroots_sdk::trade::RadrootsTradeEvidenceOutcomeV1::Indeterminate + ), + FfiTradeEvidenceOutcome::Indeterminate + ); + + let source = RadrootsTradeEvidenceManifestSourceResultV1::new( + RadrootsTradeEvidenceSourceIdV1::parse("typed_source").expect("source id"), + RadrootsTradeEvidenceSourceResultV1::new( + RadrootsTradeEvidenceSourceRequirementV1::Required, + RadrootsTradeEvidenceSourceCompletionV1::Complete, + 0, + ) + .expect("source result"), + RadrootsTradeEvidenceSourceResultDigestV1::from_bytes([0x33; 32]), + ); + let manifest = RadrootsTradeEvidenceManifestV1::new( + TradeId::from_bytes([0x11; 16]), + NonZeroU64::new(1).expect("nonzero generation"), + RadrootsTradeEvidencePolicyDigestV1::from_bytes([0x22; 32]), + 1_800_000_000, + RadrootsTradeEvidenceScopePrerequisitesV1::Satisfied, + [source], + [], + ) + .expect("manifest"); + let projected = parse_trade_evidence_manifest(manifest.canonical_bytes().to_vec()) + .expect("manifest projection"); + assert_eq!(projected.coverage, FfiTradeEvidenceCoverage::ScopeSatisfied); + assert_eq!( + projected.canonical_bytes_hex, + hex::encode(manifest.canonical_bytes()) + ); + + let decisions: serde_json::Value = serde_json::from_str(include_str!( + "../../../test-fixtures/tera_ffi/evidence_attestation_decision.v1.json" + )) + .expect("RHI decision corpus"); + let superseding = decisions["vectors"] + .as_array() + .expect("RHI decision vectors") + .iter() + .find(|entry| entry["id"] == "rhi_evidence_attestation_superseding_002") + .expect("superseding vector"); + let report = parse_rhi_evidence_report( + superseding["expected"]["canonical_event_content_utf8"] + .as_str() + .expect("canonical event content") + .to_owned(), + ) + .expect("superseding report"); + assert_eq!(report.outcome, FfiTradeEvidenceOutcome::Valid); + assert_eq!( + report.supersedes_report_id.as_deref(), + Some("7777777777777777777777777777777777777777777777777777777777777777") + ); + assert_eq!( + report.supersedes_event_id.as_deref(), + Some("8888888888888888888888888888888888888888888888888888888888888888") + ); + } + + #[test] + fn transport_policy_enums_map_every_closed_variant() { + assert_eq!( + FfiCancellationPolicy::PreservePublishedRequest.core(), + Phase1CancellationPolicy::PreservePublishedRequest + ); + assert_eq!( + FfiCancellationPolicy::LocalCooperative.core(), + Phase1CancellationPolicy::LocalCooperative + ); + assert_eq!( + radroots_sdk::transport::BlossomHostKind::from(FfiBlossomHostKind::Native), + radroots_sdk::transport::BlossomHostKind::Native + ); + assert_eq!( + radroots_sdk::transport::BlossomHostKind::from(FfiBlossomHostKind::Simulator), + radroots_sdk::transport::BlossomHostKind::Simulator + ); + assert_eq!( + radroots_sdk::transport::BlossomHostKind::from(FfiBlossomHostKind::PhysicalDevice), + radroots_sdk::transport::BlossomHostKind::PhysicalDevice + ); + } + + #[test] + fn evidence_errors_do_not_render_untrusted_content() { + let secret = "mobile-private-evidence"; + let error = parse_rhi_evidence_report(secret.to_owned()).expect_err("malformed report"); + assert!(!error.to_string().contains(secret)); + assert!(!format!("{error:?}").contains(secret)); + } + + fn png(width: u32, height: u32) -> Vec<u8> { + let mut bytes = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec(); + bytes.extend_from_slice(&width.to_be_bytes()); + bytes.extend_from_slice(&height.to_be_bytes()); + bytes + } + + fn blossom_slot() -> radroots_sdk::transport::BlossomSlot { + let profile = radroots_sdk::transport::BlossomProfile::new( + radroots_sdk::transport::BlossomHostKind::Simulator, + radroots_sdk::transport::BlossomEndpointAuthority::LoopbackDevelopment, + "http://127.0.0.1:3000", + std::iter::empty::<&str>(), + ) + .unwrap(); + let slot = radroots_sdk::transport::BlossomSlot::new(); + slot.configure(radroots_sdk::transport::BlossomConfig::from_profile( + profile, + )) + .unwrap(); + slot + } + + fn photo_input(file_descriptor: u64, bytes: &[u8], digest: String) -> FfiAddDraftInput { + FfiAddDraftInput { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + command_type: FfiAddCommandType::CreatePhotoUpdate, + content: "Fresh carrots from this morning.".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![FfiPreparedMediaInput { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + opaque_reference: "media:carrots-01".to_owned(), + file_descriptor, + sha256: digest, + media_type: "image/png".to_owned(), + byte_size: bytes.len() as u64, + width: 2, + height: 2, + alt: "A basket of carrots".to_owned(), + prepared_at_unix_s: 1_800_000_000, + }], + } + } + + fn text_input(command_type: FfiAddCommandType) -> FfiAddDraftInput { + FfiAddDraftInput { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + command_type, + content: "Fresh from the farm".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(), + } + } + + #[test] + fn exact_five_add_inputs_build_their_typed_core_commands() { + let (update, media) = text_input(FfiAddCommandType::CreateUpdate) + .command_and_media(1_800_000_000, None) + .expect("update"); + assert!(matches!(update, Phase1AddCommand::CreateUpdate(_))); + assert!(media.is_empty()); + + let (ask, media) = text_input(FfiAddCommandType::CreateAsk) + .command_and_media(1_800_000_000, None) + .expect("ask"); + assert!(matches!(ask, Phase1AddCommand::CreateAsk(_))); + assert!(media.is_empty()); + + let mut all_day = text_input(FfiAddCommandType::CreateEvent); + all_day.identifier = Some("market-day".to_owned()); + all_day.title = Some("Farmers market".to_owned()); + all_day.location = Some("Town square".to_owned()); + all_day.event_timing = Some(FfiEventTimingKind::AllDay); + all_day.event_start_date = Some("2026-08-08".to_owned()); + all_day.event_end_date = Some("2026-08-09".to_owned()); + let (event, media) = all_day + .command_and_media(1_800_000_000, None) + .expect("all-day event"); + assert!(matches!(event, Phase1AddCommand::CreateEvent(_))); + assert!(media.is_empty()); + + let mut timed = text_input(FfiAddCommandType::CreateEvent); + timed.identifier = Some("harvest-tour".to_owned()); + timed.title = Some("Harvest tour".to_owned()); + timed.event_timing = Some(FfiEventTimingKind::Timed); + timed.event_start_unix_s = Some(1_800_000_000); + timed.event_end_unix_s = Some(1_800_003_600); + timed.event_timezone = Some("America/Vancouver".to_owned()); + let (event, media) = timed + .command_and_media(1_800_000_000, None) + .expect("timed event"); + assert!(matches!(event, Phase1AddCommand::CreateEvent(_))); + assert!(media.is_empty()); + + let bytes = png(2, 2); + let mut file = tempfile::NamedTempFile::new().expect("media file"); + file.write_all(&bytes).expect("write media"); + file.flush().expect("flush media"); + let digest = Sha256::digest(&bytes).to_hex(); + let mut food = photo_input(file.as_file().as_raw_fd() as u64, &bytes, digest); + food.command_type = FfiAddCommandType::CreateFoodAvailability; + food.identifier = Some("carrots-2026-08".to_owned()); + food.title = Some("Carrots".to_owned()); + food.summary = Some("Fresh bunches".to_owned()); + food.location = Some("Victoria".to_owned()); + food.price_amount = Some("4.5".to_owned()); + food.currency = Some("CAD".to_owned()); + food.unit = Some("bunch".to_owned()); + food.quantity = Some("12".to_owned()); + food.food_status = Some("active".to_owned()); + let blossom = blossom_slot(); + let (food, media) = food + .command_and_media(1_800_000_000, Some(&blossom)) + .expect("food availability"); + assert!(matches!(food, Phase1AddCommand::CreateFoodAvailability(_))); + assert_eq!(media.len(), 1); + } + + #[test] + fn add_validation_reports_schema_shape_media_and_required_field_failures() { + let mut wrong_schema = text_input(FfiAddCommandType::CreateUpdate); + wrong_schema.schema_version = MOBILE_FFI_SCHEMA_VERSION + 1; + assert_eq!( + wrong_schema + .command_and_media(1_800_000_000, None) + .expect_err("schema") + .report() + .code, + "unsupported_schema_version" + ); + assert_eq!( + text_input(FfiAddCommandType::CreateUpdate) + .command_and_media(0, None) + .expect_err("authored time") + .report() + .code, + "invalid_add_draft" + ); + assert_eq!( + text_input(FfiAddCommandType::CreateEvent) + .command_and_media(1_800_000_000, None) + .expect_err("event identity") + .report() + .code, + "event_identifier_required" + ); + let mut food = text_input(FfiAddCommandType::CreateFoodAvailability); + food.unit = Some("crate".to_owned()); + assert_eq!( + food.command_and_media(1_800_000_000, None) + .expect_err("food unit") + .report() + .code, + "invalid_food_unit" + ); + } + + #[test] + fn prepared_media_accepts_only_the_exact_bounded_file_descriptor_bytes() { + let bytes = png(2, 2); + let mut file = tempfile::NamedTempFile::new().expect("media file"); + file.write_all(&bytes).expect("write media"); + file.flush().expect("flush media"); + let digest = Sha256::digest(&bytes).to_hex(); + let input = photo_input(file.as_file().as_raw_fd() as u64, &bytes, digest.clone()); + let blossom = blossom_slot(); + + let (command, media) = input + .command_and_media(1_800_000_000, Some(&blossom)) + .expect("verified media input"); + assert!(matches!(command, Phase1AddCommand::CreatePhotoUpdate(_))); + assert_eq!(media.len(), 1); + assert_eq!( + media[0].url(), + format!("http://127.0.0.1:3000/{digest}.png") + ); + assert_eq!( + file.as_file().metadata().expect("caller-owned media").len(), + bytes.len() as u64 + ); + } + + #[test] + fn prepared_media_rejects_file_descriptors_outside_the_platform_range() { + let bytes = png(2, 2); + let blossom = blossom_slot(); + let input = photo_input(u64::MAX, &bytes, Sha256::digest(&bytes).to_hex()); + + assert_eq!( + input + .command_and_media(1_800_000_000, Some(&blossom)) + .expect_err("out-of-range descriptor") + .report() + .code, + "media_handle_unavailable" + ); + } + + #[cfg(unix)] + #[test] + fn prepared_media_rejects_unavailable_in_range_file_descriptors() { + let bytes = png(2, 2); + let blossom = blossom_slot(); + let input = photo_input(i32::MAX as u64, &bytes, Sha256::digest(&bytes).to_hex()); + + assert_eq!( + input + .command_and_media(1_800_000_000, Some(&blossom)) + .expect_err("unavailable in-range descriptor") + .report() + .code, + "media_handle_unavailable" + ); + } + + #[test] + fn prepared_media_rejects_digest_tamper_and_path_like_references() { + let bytes = png(2, 2); + let mut file = tempfile::NamedTempFile::new().expect("media file"); + file.write_all(&bytes).expect("write media"); + file.flush().expect("flush media"); + + let tampered = photo_input( + file.as_file().as_raw_fd() as u64, + &bytes, + Sha256::digest(b"other").to_hex(), + ); + let blossom = blossom_slot(); + assert_eq!( + tampered + .command_and_media(1_800_000_000, Some(&blossom)) + .expect_err("digest mismatch") + .report() + .code, + "media_verification_failed" + ); + + let mut path_like = photo_input( + file.as_file().as_raw_fd() as u64, + &bytes, + Sha256::digest(&bytes).to_hex(), + ); + path_like.media[0].opaque_reference = "file:/private/media.jpg".to_owned(); + assert_eq!( + path_like + .command_and_media(1_800_000_000, Some(&blossom)) + .expect_err("path-like reference") + .report() + .code, + "invalid_media_reference" + ); + } + + #[test] + fn prepared_media_constructs_a_bounded_verified_upload_request() { + let bytes = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x02\0\0\0\x02"; + let mut file = tempfile::NamedTempFile::new().expect("media file"); + file.write_all(bytes).expect("write media"); + file.flush().expect("flush media"); + let digest = Sha256::digest(bytes).to_hex(); + let mut input = photo_input(file.as_file().as_raw_fd() as u64, bytes, digest.clone()); + input.media[0].media_type = "image/png".to_owned(); + + let prepared = PreparedMedia::try_from(input.media.remove(0)).expect("prepared media"); + let request = prepared + .upload_request(1_800_000_000_000) + .expect("bounded upload request"); + assert_eq!(request.sha256().to_hex(), digest); + assert_eq!(request.byte_size(), bytes.len() as u64); + assert_eq!(request.media_type().as_str(), "image/png"); + assert_eq!(request.dimensions().width(), 2); + assert_eq!(request.dimensions().height(), 2); + } + + #[test] + fn media_validation_executes_every_bounded_shape_guard() { + let bytes = png(2, 2); + let mut file = tempfile::NamedTempFile::new().expect("media file"); + file.write_all(&bytes).expect("write media"); + file.flush().expect("flush media"); + let digest = Sha256::digest(&bytes).to_hex(); + let valid = photo_input(file.as_file().as_raw_fd() as u64, &bytes, digest).media[0].clone(); + + let mut invalid_values = Vec::new(); + let mut value = valid.clone(); + value.byte_size = 0; + invalid_values.push(value); + let mut value = valid.clone(); + value.byte_size = MEDIA_FILE_MAX_BYTES + 1; + invalid_values.push(value); + let mut value = valid.clone(); + value.width = 0; + invalid_values.push(value); + let mut value = valid.clone(); + value.height = 0; + invalid_values.push(value); + let mut value = valid.clone(); + value.prepared_at_unix_s = 0; + invalid_values.push(value); + let mut value = valid.clone(); + value.alt = " ".to_owned(); + invalid_values.push(value); + let mut value = valid.clone(); + value.alt = "a".repeat(1_025); + invalid_values.push(value); + + for value in invalid_values { + assert_eq!( + PreparedMedia::try_from(value) + .err() + .expect("invalid bounded media") + .report() + .code, + "invalid_media_reference" + ); + } + + let mut wrong_size = valid.clone(); + wrong_size.byte_size += 1; + assert_eq!( + PreparedMedia::try_from(wrong_size) + .err() + .expect("descriptor size") + .report() + .code, + "media_size_mismatch" + ); + for reference in ["media:", "Media:item", "media:BAD", "media:item/path"] { + assert!(!opaque_media_reference_is_valid(reference)); + } + assert!(opaque_media_reference_is_valid("media:item_01-a")); + assert!(!opaque_media_reference_is_valid(&format!( + "media:{}", + "a".repeat(MEDIA_REFERENCE_MAX_BYTES) + ))); + } + + #[test] + fn event_and_post_optional_branches_remain_strict_and_complete() { + let mut minimal_date = text_input(FfiAddCommandType::CreateEvent); + minimal_date.content.clear(); + minimal_date.identifier = Some("minimal-date".to_owned()); + minimal_date.title = Some("Minimal date".to_owned()); + minimal_date.event_timing = Some(FfiEventTimingKind::AllDay); + minimal_date.event_start_date = Some("2026-08-08".to_owned()); + assert!(minimal_date.command_and_media(1_800_000_000, None).is_ok()); + + let mut minimal_time = text_input(FfiAddCommandType::CreateEvent); + minimal_time.content.clear(); + minimal_time.identifier = Some("minimal-time".to_owned()); + minimal_time.title = Some("Minimal time".to_owned()); + minimal_time.event_timing = Some(FfiEventTimingKind::Timed); + minimal_time.event_start_unix_s = Some(1_800_000_000); + assert!(minimal_time.command_and_media(1_800_000_000, None).is_ok()); + + let bytes = png(2, 2); + let mut file = tempfile::NamedTempFile::new().expect("media file"); + file.write_all(&bytes).expect("write media"); + file.flush().expect("flush media"); + let digest = Sha256::digest(&bytes).to_hex(); + let mut event = photo_input(file.as_file().as_raw_fd() as u64, &bytes, digest); + event.command_type = FfiAddCommandType::CreateEvent; + event.identifier = Some("event-image".to_owned()); + event.title = Some("Event image".to_owned()); + event.event_timing = Some(FfiEventTimingKind::Timed); + event.event_start_unix_s = Some(1_800_000_000); + let blossom = blossom_slot(); + assert!( + event + .clone() + .command_and_media(1_800_000_000, Some(&blossom)) + .is_ok() + ); + + let mut second = event.media[0].clone(); + second.opaque_reference = "media:event-image-two".to_owned(); + event.media.push(second); + assert_eq!( + event + .command_and_media(1_800_000_000, Some(&blossom)) + .expect_err("event media limit") + .report() + .code, + "event_image_limit" + ); + } + + #[test] + fn content_references_and_identifier_decoding_cover_all_outcomes() { + let bytes = png(2, 2); + let mut file = tempfile::NamedTempFile::new().expect("media file"); + file.write_all(&bytes).expect("write media"); + file.flush().expect("flush media"); + let digest = Sha256::digest(&bytes).to_hex(); + let input = photo_input(file.as_file().as_raw_fd() as u64, &bytes, digest); + let blossom = blossom_slot(); + let prepared = PreparedMedia::try_from(input.media[0].clone()) + .expect("prepared media") + .bind(&blossom) + .expect("bound media"); + let url = prepared.descriptor.url().as_str().to_owned(); + + assert_eq!( + content_with_media_references(" ".to_owned(), std::slice::from_ref(&prepared)) + .expect_err("blank content") + .report() + .code, + "content_required" + ); + assert_eq!( + content_with_media_references( + format!("caption\n{url}"), + std::slice::from_ref(&prepared) + ) + .expect("one existing reference") + .match_indices(&url) + .count(), + 1 + ); + assert!( + content_with_media_references("caption\n".to_owned(), std::slice::from_ref(&prepared)) + .expect("newline append") + .ends_with(&url) + ); + assert_eq!( + content_with_media_references( + format!("{url}\n{url}"), + std::slice::from_ref(&prepared), + ) + .expect_err("duplicate reference") + .report() + .code, + "duplicate_media_reference" + ); + + let mut update = text_input(FfiAddCommandType::CreateUpdate); + update.media.push(input.media[0].clone()); + assert_eq!( + update + .command_and_media(1_800_000_000, Some(&blossom)) + .expect_err("update media") + .report() + .code, + "media_not_allowed" + ); + let mut over_limit = text_input(FfiAddCommandType::CreateUpdate); + over_limit.media = vec![input.media[0].clone(); 21]; + assert_eq!( + over_limit + .command_and_media(1_800_000_000, None) + .expect_err("media count") + .report() + .code, + "invalid_add_draft" + ); + + assert_eq!(decode_id(&"01".repeat(16), "bad").expect("id"), [1; 16]); + assert_eq!( + decode_id("01", "bad").expect_err("short id").report().code, + "bad" + ); + assert_eq!( + decode_id(&"gg".repeat(16), "bad") + .expect_err("non-hex id") + .report() + .code, + "bad" + ); + } +} diff --git a/core/crates/tera_ffi/src/error.rs b/core/crates/tera_ffi/src/error.rs @@ -0,0 +1,481 @@ +use tera_core::runtime::product_surface::{ + Phase1DraftError, ProfileMetadataError, SettingsError, TodayError, +}; +use thiserror::Error; + +use crate::MOBILE_FFI_SCHEMA_VERSION; + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct RadrootsErrorRecord { + pub schema_version: u16, + pub code: String, + pub category: String, + pub retryable: bool, + pub recovery_actions: Vec<String>, + pub operation_id: Option<String>, + pub capability_id: Option<String>, + pub safe_message: String, +} + +/// The only error envelope exported across the native language boundary. +#[derive(Debug, Error, uniffi::Error)] +pub enum RadrootsAppError { + #[error("radroots operation failed: {report:?}")] + Failure { report: RadrootsErrorRecord }, +} + +impl RadrootsAppError { + pub(crate) fn initialization(_message: impl Into<String>) -> Self { + Self::failure( + "initialization_failed", + "initialization", + true, + &["retry"], + "The Radroots runtime could not be initialized.", + ) + } + + pub(crate) fn invalid_argument(code: impl Into<String>) -> Self { + Self::Failure { + report: RadrootsErrorRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + code: code.into(), + category: "validation".to_owned(), + retryable: false, + recovery_actions: vec!["correct_input".to_owned()], + operation_id: None, + capability_id: None, + safe_message: "The request is invalid.".to_owned(), + }, + } + } + + pub(crate) fn failure( + code: &str, + category: &str, + retryable: bool, + recovery_actions: &[&str], + safe_message: &str, + ) -> Self { + Self::Failure { + report: RadrootsErrorRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + code: code.to_owned(), + category: category.to_owned(), + retryable, + recovery_actions: recovery_actions + .iter() + .map(|action| (*action).to_owned()) + .collect(), + operation_id: None, + capability_id: None, + safe_message: safe_message.to_owned(), + }, + } + } + + pub fn report(&self) -> &RadrootsErrorRecord { + match self { + Self::Failure { report } => report, + } + } + + pub(crate) fn with_operation_id(mut self, operation_id: String) -> Self { + match &mut self { + Self::Failure { report } => report.operation_id = Some(operation_id), + } + self + } +} + +impl From<tera_core::RadrootsAppError> for RadrootsAppError { + fn from(error: tera_core::RadrootsAppError) -> Self { + match error { + tera_core::RadrootsAppError::Sdk { report } => Self::Failure { + report: RadrootsErrorRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + code: report.code, + category: report.class, + retryable: report.retryable, + recovery_actions: report.recovery_actions, + operation_id: report.operation_id, + capability_id: report.capability_id, + safe_message: report.message, + }, + }, + tera_core::RadrootsAppError::Store { report } => Self::Failure { + report: RadrootsErrorRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + code: report.code, + category: report.class, + retryable: report.retryable, + recovery_actions: report.recovery_actions, + operation_id: None, + capability_id: None, + safe_message: report.message, + }, + }, + tera_core::RadrootsAppError::Initialization(_) => Self::initialization("redacted"), + tera_core::RadrootsAppError::Runtime(_) => Self::failure( + "runtime_failed", + "runtime", + true, + &["retry"], + "The runtime operation failed.", + ), + tera_core::RadrootsAppError::Unsupported(_) => Self::failure( + "unsupported", + "capability", + false, + &[], + "The requested capability is unsupported.", + ), + tera_core::RadrootsAppError::Internal(_) => Self::failure( + "internal_failure", + "internal", + false, + &["restart"], + "An internal Radroots error occurred.", + ), + } + } +} + +impl From<TodayError> for RadrootsAppError { + fn from(error: TodayError) -> Self { + let (code, retryable, actions) = match error { + TodayError::InvalidRequest | TodayError::EventNotVisible => { + ("today_invalid_request", false, &["correct_input"][..]) + } + TodayError::ProjectionMissing | TodayError::SnapshotMissing => { + ("today_refresh_required", true, &["refresh"][..]) + } + TodayError::CursorPositionMissing | TodayError::Cursor(_) => { + ("today_cursor_invalid", true, &["restart_pagination"][..]) + } + TodayError::RuntimeUnavailable => ("today_runtime_unavailable", true, &["retry"][..]), + TodayError::InboundMedia(_) => ("today_media_invalid", false, &["retry_media"][..]), + TodayError::InboundRetrieval(error) => { + if error.retryable() { + ("today_media_retrieval_failed", true, &["retry_media"][..]) + } else { + ("today_media_retrieval_failed", false, &["review_media"][..]) + } + } + TodayError::CorruptProjection | TodayError::Serialization | TodayError::Storage(_) => { + ("today_state_failed", true, &["rebuild", "retry"][..]) + } + }; + Self::failure( + code, + "today", + retryable, + actions, + "The Today operation could not be completed.", + ) + } +} + +impl From<Phase1DraftError> for RadrootsAppError { + fn from(error: Phase1DraftError) -> Self { + let (code, retryable, actions) = match error { + Phase1DraftError::IdentityUnavailable => ( + "identity_unavailable", + true, + &["unlock_identity", "retry"][..], + ), + Phase1DraftError::InvalidDraft => ("draft_invalid", false, &["correct_input"][..]), + Phase1DraftError::InvalidMedia => { + ("draft_media_invalid", false, &["replace_media"][..]) + } + Phase1DraftError::InvalidQueuePolicy => { + ("draft_queue_policy_invalid", false, &["correct_input"][..]) + } + Phase1DraftError::RevisionConflict => { + ("draft_revision_conflict", true, &["refresh"][..]) + } + Phase1DraftError::NotFound => ("draft_not_found", false, &[][..]), + Phase1DraftError::Terminal => ("draft_terminal", false, &[][..]), + Phase1DraftError::MediaNotReady => { + ("draft_media_not_ready", true, &["retry_media"][..]) + } + Phase1DraftError::OperationUnavailable => { + ("authoring_unavailable", true, &["retry"][..]) + } + Phase1DraftError::Operation | Phase1DraftError::Storage | Phase1DraftError::Overlay => { + ("authoring_failed", true, &["retry", "inspect_outbox"][..]) + } + Phase1DraftError::Corrupt => ("draft_corrupt", false, &["recover_draft"][..]), + Phase1DraftError::ClockUnavailable => { + ("operation_clock_unavailable", true, &["retry"][..]) + } + Phase1DraftError::DeadlineOverflow => ("operation_deadline_overflow", false, &[][..]), + Phase1DraftError::NoWritableRelay => ( + "writable_relay_unavailable", + true, + &["configure_relay", "retry"][..], + ), + Phase1DraftError::InvalidRevision => { + ("revision_invalid", false, &["review_revision"][..]) + } + }; + Self::failure( + code, + "authoring", + retryable, + actions, + "The authored operation could not be completed.", + ) + } +} + +impl From<SettingsError> for RadrootsAppError { + fn from(error: SettingsError) -> Self { + let retryable = matches!( + error, + SettingsError::RevisionConflict + | SettingsError::RevisionExhausted + | SettingsError::Storage + ); + let actions = if matches!(error, SettingsError::RevisionConflict) { + &["refresh"] as &[&str] + } else if retryable { + &["retry"] as &[&str] + } else { + &["correct_input"] as &[&str] + }; + Self::failure( + error.code(), + "settings", + retryable, + actions, + "The settings operation could not be completed.", + ) + } +} + +impl From<ProfileMetadataError> for RadrootsAppError { + fn from(error: ProfileMetadataError) -> Self { + Self::failure( + error.code(), + "profile", + false, + &["correct_input"], + "The profile metadata is invalid.", + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn internal_core_messages_are_not_copied_to_the_ffi_record() { + let error = RadrootsAppError::from(tera_core::RadrootsAppError::internal( + "private/path/secret-value", + )); + assert_eq!(error.report().code, "internal_failure"); + assert!(!format!("{error:?}").contains("secret-value")); + assert!(!error.report().safe_message.contains("secret-value")); + } + + #[test] + fn every_core_error_class_maps_to_a_versioned_redacted_record() { + let sdk = tera_core::RadrootsAppError::Sdk { + report: tera_core::SdkErrorRecord { + schema_version: 1, + code: "relay_unavailable".to_owned(), + class: "transport".to_owned(), + retryable: true, + recovery_actions: vec!["retry".to_owned()], + operation_id: Some("operation".to_owned()), + capability_id: Some("relay".to_owned()), + message: "Safe relay failure".to_owned(), + }, + }; + let sdk = RadrootsAppError::from(sdk); + assert_eq!(sdk.report().code, "relay_unavailable"); + assert_eq!(sdk.report().operation_id.as_deref(), Some("operation")); + + let store = tera_core::RadrootsAppError::Store { + report: tera_core::StoreErrorRecord { + schema_version: 1, + code: "store_locked".to_owned(), + class: "storage".to_owned(), + retryable: true, + recovery_actions: vec!["unlock".to_owned()], + message: "Safe store failure".to_owned(), + }, + }; + let store = RadrootsAppError::from(store); + assert_eq!(store.report().code, "store_locked"); + assert!(store.report().operation_id.is_none()); + + for (core, code, category) in [ + ( + tera_core::RadrootsAppError::initialization("secret"), + "initialization_failed", + "initialization", + ), + ( + tera_core::RadrootsAppError::runtime("secret"), + "runtime_failed", + "runtime", + ), + ( + tera_core::RadrootsAppError::unsupported("secret"), + "unsupported", + "capability", + ), + ( + tera_core::RadrootsAppError::internal("secret"), + "internal_failure", + "internal", + ), + ] { + let ffi = RadrootsAppError::from(core); + assert_eq!(ffi.report().code, code); + assert_eq!(ffi.report().category, category); + assert!(!ffi.report().safe_message.contains("secret")); + } + } + + #[test] + fn today_and_draft_failures_have_stable_recovery_classes() { + for error in [ + TodayError::InvalidRequest, + TodayError::EventNotVisible, + TodayError::ProjectionMissing, + TodayError::SnapshotMissing, + TodayError::CursorPositionMissing, + TodayError::RuntimeUnavailable, + TodayError::CorruptProjection, + TodayError::Serialization, + ] { + let ffi = RadrootsAppError::from(error); + assert_eq!(ffi.report().category, "today"); + assert!(!ffi.report().safe_message.is_empty()); + } + + for error in [ + Phase1DraftError::IdentityUnavailable, + Phase1DraftError::InvalidDraft, + Phase1DraftError::InvalidMedia, + Phase1DraftError::InvalidQueuePolicy, + Phase1DraftError::RevisionConflict, + Phase1DraftError::NotFound, + Phase1DraftError::Terminal, + Phase1DraftError::MediaNotReady, + Phase1DraftError::OperationUnavailable, + Phase1DraftError::Operation, + Phase1DraftError::Storage, + Phase1DraftError::Overlay, + Phase1DraftError::Corrupt, + Phase1DraftError::ClockUnavailable, + Phase1DraftError::DeadlineOverflow, + Phase1DraftError::NoWritableRelay, + Phase1DraftError::InvalidRevision, + ] { + let ffi = RadrootsAppError::from(error); + assert_eq!(ffi.report().category, "authoring"); + assert!(!ffi.report().safe_message.is_empty()); + } + } + + #[test] + fn cursor_media_settings_and_profile_failures_cover_every_stable_class() { + use tera_core::runtime::product_surface::{ + CursorError, IdentitySettingsError, Phase1InboundMediaError, + }; + + for cursor in [ + CursorError::InvalidContext, + CursorError::Malformed, + CursorError::Integrity, + CursorError::Version, + CursorError::ContextMismatch, + CursorError::SnapshotMismatch, + CursorError::Stale, + CursorError::InvalidPosition, + ] { + let error = RadrootsAppError::from(TodayError::Cursor(cursor)); + assert_eq!(error.report().code, "today_cursor_invalid"); + } + for media in [ + Phase1InboundMediaError::InvalidReference, + Phase1InboundMediaError::InvalidDigest, + Phase1InboundMediaError::MissingDigest, + Phase1InboundMediaError::InvalidMediaType, + Phase1InboundMediaError::InvalidDimensions, + Phase1InboundMediaError::InvalidByteSize, + Phase1InboundMediaError::InvalidAlt, + Phase1InboundMediaError::MetadataMismatch, + Phase1InboundMediaError::InvalidOperation, + Phase1InboundMediaError::OperationMismatch, + Phase1InboundMediaError::InvalidFailure, + Phase1InboundMediaError::InvalidConfiguration, + Phase1InboundMediaError::ConfigurationMismatch, + Phase1InboundMediaError::InvalidVerificationTime, + Phase1InboundMediaError::InvalidCachePolicy, + Phase1InboundMediaError::InvalidCacheObservation, + Phase1InboundMediaError::CacheQuotaExceeded, + Phase1InboundMediaError::ArtifactCollision, + Phase1InboundMediaError::CorruptReceipt, + Phase1InboundMediaError::CorruptState, + Phase1InboundMediaError::UnsupportedSchema, + Phase1InboundMediaError::CacheUnavailable, + Phase1InboundMediaError::CacheIo, + Phase1InboundMediaError::CorruptArtifact, + ] { + let error = RadrootsAppError::from(TodayError::InboundMedia(media)); + assert_eq!(error.report().code, "today_media_invalid"); + } + for settings in [ + SettingsError::UnknownRelayAccess, + SettingsError::InvalidRelayEndpoint, + SettingsError::InvalidRelayEndpointCount, + SettingsError::DuplicateRelayEndpoint, + SettingsError::InvalidBlossomEndpoint, + SettingsError::InvalidBlossomEndpointCount, + SettingsError::NetworkEnvironmentMismatch, + SettingsError::InvalidMediaCacheBytes, + SettingsError::InvalidMediaCacheArtifacts, + SettingsError::RevisionConflict, + SettingsError::RevisionExhausted, + SettingsError::UnsupportedSchema, + SettingsError::CorruptDocument, + SettingsError::Storage, + SettingsError::Identity(IdentitySettingsError::InvalidIdentityId), + ] { + let expected_retryable = matches!( + settings, + SettingsError::RevisionConflict + | SettingsError::RevisionExhausted + | SettingsError::Storage + ); + let error = RadrootsAppError::from(settings); + assert_eq!(error.report().retryable, expected_retryable); + } + for profile in [ + ProfileMetadataError::InvalidName, + ProfileMetadataError::InvalidDisplayName, + ProfileMetadataError::InvalidAbout, + ProfileMetadataError::InvalidNip05, + ] { + assert_eq!(RadrootsAppError::from(profile).report().category, "profile"); + } + assert_eq!( + RadrootsAppError::initialization("private").report().code, + "initialization_failed" + ); + assert_eq!( + RadrootsAppError::invalid_argument("bad") + .with_operation_id("operation".to_owned()) + .report() + .operation_id + .as_deref(), + Some("operation") + ); + } +} diff --git a/core/crates/tera_ffi/src/lib.rs b/core/crates/tera_ffi/src/lib.rs @@ -0,0 +1,46 @@ +// UniFFI serializes the complete versioned error record across the language +// boundary; keeping it by value preserves the generated wire contract. +#![allow(clippy::result_large_err)] +#![cfg_attr(coverage_nightly, feature(coverage_attribute))] + +uniffi::setup_scaffolding!("tera_core"); + +mod dto; +pub mod logging; +mod operations; +mod runtime; +mod signer; +mod subscription; + +pub use dto::*; +pub use error::{RadrootsAppError, RadrootsErrorRecord}; +pub use operations::*; +pub use runtime::{ProtectedDataAvailability, RadrootsRuntime}; +pub use signer::{ + HostSigningOutcome, HostSigningPurpose, HostSigningRequest, HostSigningResult, + RadrootsHostSigner, SignerAvailabilityRecord, SignerStatusRecord, +}; +pub use subscription::{ + FfiRuntimeChangeKind, FfiRuntimeChangeRecord, FfiSubscriptionHandle, RadrootsRuntimeObserver, +}; + +mod error; + +#[allow( + clippy::if_same_then_else, + reason = "coverage probe intentionally exercises both paths with a stable value" +)] +pub fn coverage_branch_probe(input: bool) -> &'static str { + if input { "ffi" } else { "ffi" } +} + +#[cfg(test)] +mod tests { + use super::coverage_branch_probe; + + #[test] + fn coverage_branch_probe_hits_both_paths() { + assert_eq!(coverage_branch_probe(true), "ffi"); + assert_eq!(coverage_branch_probe(false), "ffi"); + } +} diff --git a/core/crates/tera_ffi/src/logging.rs b/core/crates/tera_ffi/src/logging.rs @@ -0,0 +1,284 @@ +mod writer; + +use std::fs; +use std::path::{Component, Path, PathBuf}; +use std::sync::Mutex; + +use tracing_appender::non_blocking::WorkerGuard; +use tracing_subscriber::prelude::*; + +use self::writer::{LogRotation, SizeRotatingWriter}; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct LoggingOptions { + dir: Option<PathBuf>, + file_name: String, + stdout: bool, + rotation: LogRotation, +} + +impl Default for LoggingOptions { + fn default() -> Self { + Self { + dir: None, + file_name: "radroots.log".to_owned(), + stdout: true, + rotation: LogRotation::default(), + } + } +} + +struct ActiveLogging { + options: LoggingOptions, + _file_guard: Option<WorkerGuard>, +} + +static LOGGING: Mutex<Option<ActiveLogging>> = Mutex::new(None); + +#[cfg_attr(not(coverage_nightly), uniffi::export)] +pub fn init_logging( + dir: Option<String>, + file_name: Option<String>, + is_stdout: Option<bool>, +) -> Result<(), crate::RadrootsAppError> { + let opts = logging_options(dir, file_name, is_stdout); + initialize(opts).map_err(crate::RadrootsAppError::initialization) +} + +fn logging_options( + dir: Option<String>, + file_name: Option<String>, + is_stdout: Option<bool>, +) -> LoggingOptions { + LoggingOptions { + dir: dir.map(PathBuf::from), + file_name: file_name.unwrap_or_else(|| "radroots.log".to_string()), + stdout: is_stdout.unwrap_or(true), + ..LoggingOptions::default() + } +} + +fn initialize(options: LoggingOptions) -> Result<(), String> { + validate_options(&options)?; + let mut active = LOGGING + .lock() + .map_err(|_| "logging initialization state is poisoned".to_owned())?; + if let Some(existing) = active.as_ref() { + return if existing.options == options { + Ok(()) + } else { + Err("logging is already initialized with a different configuration".to_owned()) + }; + } + + let (file_writer, file_guard) = build_file_writer(&options)?; + let file_layer = file_writer.as_ref().map(|writer| { + tracing_subscriber::fmt::layer() + .with_writer(writer.clone()) + .with_ansi(false) + .with_target(false) + }); + let stdout_layer = options.stdout.then(|| { + tracing_subscriber::fmt::layer() + .with_writer(std::io::stdout) + .with_target(false) + }); + tracing_subscriber::registry() + .with(file_layer) + .with(stdout_layer) + .try_init() + .map_err(|error| error.to_string())?; + *active = Some(ActiveLogging { + options, + _file_guard: file_guard, + }); + Ok(()) +} + +fn validate_options(options: &LoggingOptions) -> Result<(), String> { + if options.dir.is_none() && !options.stdout { + return Err("logging requires at least one configured output".to_owned()); + } + if options.file_name.is_empty() + || Path::new(&options.file_name).components().count() != 1 + || !matches!( + Path::new(&options.file_name).components().next(), + Some(Component::Normal(_)) + ) + { + return Err("log file_name must be a safe basename".to_owned()); + } + Ok(()) +} + +type FileWriter = tracing_appender::non_blocking::NonBlocking; + +fn build_file_writer( + options: &LoggingOptions, +) -> Result<(Option<FileWriter>, Option<WorkerGuard>), String> { + #[cfg(windows)] + if options.dir.is_some() { + return Err( + "secure file logging is unavailable until Windows ACL and reparse-point enforcement is active" + .to_owned(), + ); + } + let Some(dir) = options.dir.as_ref() else { + return Ok((None, None)); + }; + fs::create_dir_all(dir).map_err(|error| error.to_string())?; + let metadata = fs::symlink_metadata(dir).map_err(|error| error.to_string())?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err("log directory is not a safe directory".to_owned()); + } + let writer = SizeRotatingWriter::new(dir.join(&options.file_name), options.rotation) + .map_err(|error| error.to_string())?; + let (writer, guard) = tracing_appender::non_blocking::NonBlockingBuilder::default() + .buffered_lines_limit(8192) + .lossy(false) + .thread_name("radroots-app-log-writer") + .finish(writer); + Ok((Some(writer), Some(guard))) +} + +#[cfg_attr(not(coverage_nightly), uniffi::export)] +pub fn init_logging_stdout() -> Result<(), crate::RadrootsAppError> { + initialize(LoggingOptions::default()).map_err(crate::RadrootsAppError::initialization) +} + +#[cfg_attr(not(coverage_nightly), uniffi::export)] +pub fn log_info(msg: String) -> Result<(), crate::RadrootsAppError> { + tracing::info!("{msg}"); + Ok(()) +} + +#[cfg_attr(not(coverage_nightly), uniffi::export)] +pub fn log_error(msg: String) -> Result<(), crate::RadrootsAppError> { + tracing::error!("{msg}"); + Ok(()) +} + +#[cfg_attr(not(coverage_nightly), uniffi::export)] +pub fn log_debug(msg: String) -> Result<(), crate::RadrootsAppError> { + tracing::debug!("{msg}"); + Ok(()) +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use super::{ + LogRotation, LoggingOptions, build_file_writer, initialize, log_debug, log_error, log_info, + logging_options, validate_options, + }; + use std::path::PathBuf; + + #[test] + fn logging_options_adopt_bounded_library_defaults() { + let options = logging_options( + Some("logs".to_owned()), + Some("mobile.log".to_owned()), + Some(false), + ); + + assert_eq!(options.dir, Some(PathBuf::from("logs"))); + assert_eq!(options.file_name, "mobile.log"); + assert!(!options.stdout); + assert_eq!(options.rotation, LogRotation::default()); + } + + #[test] + fn logging_options_preserve_public_api_defaults() { + let options = logging_options(None, None, None); + + assert_eq!(options.file_name, "radroots.log"); + assert!(options.stdout); + assert_eq!(options, LoggingOptions::default()); + } + + #[test] + fn validation_rejects_missing_outputs_and_unsafe_names() { + let mut options = LoggingOptions { + stdout: false, + ..LoggingOptions::default() + }; + assert!(validate_options(&options).is_err()); + + options.stdout = true; + for name in ["", "../radroots.log", "nested/radroots.log", "."] { + options.file_name = name.to_owned(); + assert!(validate_options(&options).is_err(), "accepted {name:?}"); + } + + options.dir = Some(PathBuf::from("logs")); + options.file_name = "radroots.log".to_owned(); + options.stdout = false; + validate_options(&options).expect("file output is sufficient"); + } + + #[test] + fn file_writer_is_optional_and_rejects_non_directories() { + let options = LoggingOptions::default(); + let (writer, guard) = build_file_writer(&options).expect("stdout only"); + assert!(writer.is_none()); + assert!(guard.is_none()); + + let directory = tempfile::tempdir().expect("temporary directory"); + let file = directory.path().join("not-a-directory"); + std::fs::write(&file, b"not a directory").expect("fixture"); + let options = LoggingOptions { + dir: Some(file), + ..LoggingOptions::default() + }; + assert!(build_file_writer(&options).is_err()); + + #[cfg(unix)] + { + let target = directory.path().join("real-directory"); + std::fs::create_dir(&target).expect("real directory"); + let link = directory.path().join("linked-directory"); + std::os::unix::fs::symlink(&target, &link).expect("directory symlink"); + let options = LoggingOptions { + dir: Some(link), + ..LoggingOptions::default() + }; + assert!(build_file_writer(&options).is_err()); + } + } + + #[test] + fn logging_entry_points_accept_secret_safe_messages() { + log_info("info".to_owned()).expect("info"); + log_error("error".to_owned()).expect("error"); + log_debug("debug".to_owned()).expect("debug"); + } + + #[cfg(windows)] + #[test] + fn windows_file_logging_fails_before_filesystem_mutation() { + let directory = tempfile::tempdir().expect("temporary directory"); + let requested = directory.path().join("must-not-exist"); + let options = LoggingOptions { + dir: Some(requested.clone()), + stdout: false, + ..LoggingOptions::default() + }; + let error = build_file_writer(&options).expect_err("ACL capability must fail closed"); + assert!(error.contains("ACL")); + assert!(error.contains("reparse-point")); + assert!(!requested.exists()); + } + + #[test] + fn initialization_is_idempotent_but_rejects_reconfiguration() { + let directory = tempfile::tempdir().expect("temporary directory"); + let options = LoggingOptions { + dir: Some(directory.path().to_owned()), + stdout: false, + ..LoggingOptions::default() + }; + initialize(options.clone()).expect("first initialization"); + initialize(options).expect("idempotent initialization"); + assert!(initialize(LoggingOptions::default()).is_err()); + } +} diff --git a/core/crates/tera_ffi/src/logging/writer.rs b/core/crates/tera_ffi/src/logging/writer.rs @@ -0,0 +1,436 @@ +use std::ffi::{OsStr, OsString}; +#[cfg(any(test, not(unix)))] +use std::fs; +use std::fs::File; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; + +const DEFAULT_MAX_FILE_BYTES: u64 = 10 * 1024 * 1024; +const DEFAULT_RETAINED_FILES: usize = 5; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct LogRotation { + max_file_bytes: u64, + retained_files: usize, +} + +impl Default for LogRotation { + fn default() -> Self { + Self { + max_file_bytes: DEFAULT_MAX_FILE_BYTES, + retained_files: DEFAULT_RETAINED_FILES, + } + } +} + +pub(super) struct SizeRotatingWriter { + #[cfg(not(unix))] + path: PathBuf, + #[cfg(unix)] + directory: File, + file_name: OsString, + file: Option<File>, + bytes_written: u64, + policy: LogRotation, +} + +impl SizeRotatingWriter { + pub(super) fn new(path: PathBuf, policy: LogRotation) -> io::Result<Self> { + let file_name = path + .file_name() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "log target has no name"))? + .to_owned(); + #[cfg(unix)] + let directory = open_secure_directory(path.parent().ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "log target has no parent") + })?)?; + #[cfg(unix)] + let file = open_append_at(&directory, &file_name)?; + #[cfg(not(unix))] + let file = open_append(&path)?; + let bytes_written = file.metadata()?.len(); + let mut writer = Self { + #[cfg(not(unix))] + path, + #[cfg(unix)] + directory, + file_name, + file: Some(file), + bytes_written, + policy, + }; + if writer.bytes_written >= writer.policy.max_file_bytes { + writer.rotate()?; + } + Ok(writer) + } + + fn rotate(&mut self) -> io::Result<()> { + if let Some(mut file) = self.file.take() { + file.flush()?; + file.sync_data()?; + } + if self.policy.retained_files == 1 { + self.remove_if_present(&self.file_name)?; + } else { + self.remove_if_present(&rotated_name( + &self.file_name, + self.policy.retained_files - 1, + ))?; + for index in (2..self.policy.retained_files).rev() { + self.rename_if_present( + &rotated_name(&self.file_name, index - 1), + &rotated_name(&self.file_name, index), + )?; + } + self.rename_if_present(&self.file_name, &rotated_name(&self.file_name, 1))?; + } + #[cfg(unix)] + let file = open_append_at(&self.directory, &self.file_name)?; + #[cfg(not(unix))] + let file = open_append(&self.path)?; + self.file = Some(file); + self.bytes_written = 0; + Ok(()) + } + + fn remove_if_present(&self, name: &OsStr) -> io::Result<()> { + #[cfg(unix)] + { + use rustix::fs::{AtFlags, unlinkat}; + match unlinkat(&self.directory, name, AtFlags::empty()) { + Ok(()) => Ok(()), + Err(error) if error == rustix::io::Errno::NOENT => Ok(()), + Err(error) => Err(io::Error::from_raw_os_error(error.raw_os_error())), + } + } + #[cfg(not(unix))] + remove_if_present(&self.path.with_file_name(name)) + } + + fn rename_if_present(&self, source: &OsStr, target: &OsStr) -> io::Result<()> { + #[cfg(unix)] + { + use rustix::fs::renameat; + match renameat(&self.directory, source, &self.directory, target) { + Ok(()) => Ok(()), + Err(error) if error == rustix::io::Errno::NOENT => Ok(()), + Err(error) => Err(io::Error::from_raw_os_error(error.raw_os_error())), + } + } + #[cfg(not(unix))] + rename_if_present( + &self.path.with_file_name(source), + &self.path.with_file_name(target), + ) + } + + fn file_mut(&mut self) -> io::Result<&mut File> { + self.file + .as_mut() + .ok_or_else(|| io::Error::other("log file is unavailable")) + } +} + +impl Write for SizeRotatingWriter { + fn write(&mut self, buffer: &[u8]) -> io::Result<usize> { + let incoming = u64::try_from(buffer.len()) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "log event is too large"))?; + if incoming > self.policy.max_file_bytes { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "log event exceeds the configured file limit", + )); + } + if self.bytes_written > 0 + && self.bytes_written.saturating_add(incoming) > self.policy.max_file_bytes + { + self.rotate()?; + } + let written = self.file_mut()?.write(buffer)?; + self.bytes_written = self + .bytes_written + .saturating_add(u64::try_from(written).unwrap_or(u64::MAX)); + Ok(written) + } + + fn flush(&mut self) -> io::Result<()> { + self.file_mut()?.flush() + } +} + +#[cfg(test)] +fn reject_unsafe_target(path: &Path) -> io::Result<()> { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => Err( + io::Error::new(io::ErrorKind::InvalidInput, "log target is not a safe file"), + ), + Ok(_) => Ok(()), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error), + } +} + +#[cfg(unix)] +fn open_secure_directory(path: &Path) -> io::Result<File> { + use rustix::fs::{FileType, Mode, OFlags, fstat, open}; + use rustix::process::geteuid; + + let directory = open( + path, + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + ) + .map_err(errno_to_io)?; + let status = fstat(&directory).map_err(errno_to_io)?; + if FileType::from_raw_mode(status.st_mode) != FileType::Directory + || status.st_uid != geteuid().as_raw() + || status.st_mode & 0o022 != 0 + { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "log parent must be an owner-controlled non-writable directory", + )); + } + Ok(File::from(directory)) +} + +#[cfg(unix)] +fn open_append_at(directory: &File, name: &OsStr) -> io::Result<File> { + use rustix::fs::{FileType, Mode, OFlags, fchmod, fstat, openat}; + + let descriptor = openat( + directory, + name, + OFlags::WRONLY | OFlags::APPEND | OFlags::CREATE | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::RUSR | Mode::WUSR, + ) + .map_err(errno_to_io)?; + let status = fstat(&descriptor).map_err(errno_to_io)?; + if FileType::from_raw_mode(status.st_mode) != FileType::RegularFile || status.st_nlink != 1 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "log target must be one regular file link", + )); + } + fchmod(&descriptor, Mode::RUSR | Mode::WUSR).map_err(errno_to_io)?; + Ok(File::from(descriptor)) +} + +#[cfg(unix)] +fn errno_to_io(error: rustix::io::Errno) -> io::Error { + io::Error::from_raw_os_error(error.raw_os_error()) +} + +#[cfg(not(unix))] +fn open_append(_path: &Path) -> io::Result<File> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "secure file logging is unavailable without a no-follow ACL implementation", + )) +} + +#[cfg(test)] +fn rotated_path(path: &Path, index: usize) -> PathBuf { + let mut value = path.as_os_str().to_owned(); + value.push(format!(".{index}")); + PathBuf::from(value) +} + +fn rotated_name(name: &OsStr, index: usize) -> OsString { + let mut value = name.to_owned(); + value.push(format!(".{index}")); + value +} + +#[cfg(any(test, not(unix)))] +fn remove_if_present(path: &Path) -> io::Result<()> { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error), + } +} + +#[cfg(any(test, not(unix)))] +fn rename_if_present(source: &Path, target: &Path) -> io::Result<()> { + match fs::rename(source, target) { + Ok(()) => Ok(()), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error), + } +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use super::{ + LogRotation, SizeRotatingWriter, reject_unsafe_target, remove_if_present, + rename_if_present, rotated_path, + }; + use std::ffi::OsStr; + use std::io::Write; + + #[test] + fn rotation_is_size_bounded_and_retention_is_finite() { + let directory = tempfile::tempdir().expect("temporary log directory"); + let path = directory.path().join("radroots.log"); + let mut writer = SizeRotatingWriter::new( + path.clone(), + LogRotation { + max_file_bytes: 5, + retained_files: 3, + }, + ) + .expect("writer"); + for value in [b"1111", b"2222", b"3333", b"4444"] { + writer.write_all(value).expect("log entry"); + } + writer.flush().expect("flush"); + assert_eq!(std::fs::read(&path).expect("current"), b"4444"); + assert_eq!( + std::fs::read(rotated_path(&path, 1)).expect("first retained"), + b"3333" + ); + assert_eq!( + std::fs::read(rotated_path(&path, 2)).expect("second retained"), + b"2222" + ); + assert!(!rotated_path(&path, 3).exists()); + } + + #[test] + fn single_file_rotation_removes_the_previous_file() { + let directory = tempfile::tempdir().expect("temporary log directory"); + let path = directory.path().join("radroots.log"); + std::fs::write(&path, b"full!").expect("fixture"); + let mut writer = SizeRotatingWriter::new( + path.clone(), + LogRotation { + max_file_bytes: 5, + retained_files: 1, + }, + ) + .expect("writer"); + writer.write_all(b"next").expect("write"); + writer.flush().expect("flush"); + assert_eq!(std::fs::read(path).expect("current"), b"next"); + } + + #[test] + fn oversized_events_and_unavailable_files_fail_closed() { + let directory = tempfile::tempdir().expect("temporary log directory"); + let path = directory.path().join("radroots.log"); + let mut writer = SizeRotatingWriter::new( + path, + LogRotation { + max_file_bytes: 3, + retained_files: 2, + }, + ) + .expect("writer"); + assert_eq!( + writer.write(b"four").expect_err("oversized").kind(), + std::io::ErrorKind::InvalidData + ); + writer.write_all(b"a").expect("first short write"); + writer.write_all(b"b").expect("second short write"); + writer.file = None; + assert_eq!( + writer.flush().expect_err("missing file").kind(), + std::io::ErrorKind::Other + ); + } + + #[test] + fn unsafe_targets_and_absent_rotation_files_are_handled() { + let directory = tempfile::tempdir().expect("temporary directory"); + assert!(reject_unsafe_target(directory.path()).is_err()); + let regular = directory.path().join("regular"); + std::fs::write(&regular, b"regular").expect("regular file"); + assert!(reject_unsafe_target(&regular).is_ok()); + let missing = directory.path().join("missing"); + assert!(reject_unsafe_target(&missing).is_ok()); + assert!(remove_if_present(&missing).is_ok()); + assert!(rename_if_present(&missing, &directory.path().join("target")).is_ok()); + assert!(remove_if_present(directory.path()).is_err()); + + let source = directory.path().join("source"); + std::fs::write(&source, b"source").expect("source"); + assert!(rename_if_present(&source, directory.path()).is_err()); + + #[cfg(unix)] + { + std::os::unix::fs::symlink(directory.path(), &missing).expect("symlink"); + assert!(reject_unsafe_target(&missing).is_err()); + } + } + + #[cfg(unix)] + #[test] + fn descriptor_relative_open_rejects_symlinks_and_multiple_links() { + let directory = tempfile::tempdir().expect("temporary directory"); + let outside = tempfile::NamedTempFile::new().expect("outside file"); + let symlink = directory.path().join("radroots.log"); + std::os::unix::fs::symlink(outside.path(), &symlink).expect("symlink"); + assert!(SizeRotatingWriter::new(symlink.clone(), LogRotation::default()).is_err()); + + std::fs::remove_file(&symlink).expect("remove symlink"); + std::fs::hard_link(outside.path(), &symlink).expect("hard link"); + assert!(SizeRotatingWriter::new(symlink, LogRotation::default()).is_err()); + + let link_holder = tempfile::tempdir().expect("link holder"); + let parent_link = link_holder.path().join("parent-link"); + std::os::unix::fs::symlink(directory.path(), &parent_link).expect("parent symlink"); + assert!( + SizeRotatingWriter::new(parent_link.join("other.log"), LogRotation::default()).is_err() + ); + + use std::os::unix::fs::PermissionsExt; + let insecure = tempfile::tempdir().expect("insecure parent"); + std::fs::set_permissions(insecure.path(), std::fs::Permissions::from_mode(0o777)) + .expect("insecure permissions"); + assert!( + SizeRotatingWriter::new(insecure.path().join("radroots.log"), LogRotation::default()) + .is_err() + ); + } + + #[cfg(unix)] + #[test] + fn descriptor_relative_rotation_propagates_non_missing_errors() { + let directory = tempfile::tempdir().expect("temporary directory"); + let mut writer = SizeRotatingWriter::new( + directory.path().join("radroots.log"), + LogRotation::default(), + ) + .expect("writer"); + writer.flush().expect("flush"); + + std::fs::create_dir(directory.path().join("blocked")).expect("blocked directory"); + assert!(writer.remove_if_present(OsStr::new("blocked")).is_err()); + std::fs::write(directory.path().join("source"), b"source").expect("source"); + assert!( + writer + .rename_if_present(OsStr::new("source"), OsStr::new("blocked")) + .is_err() + ); + } + + #[test] + fn rotation_without_an_open_file_recreates_the_target() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("radroots.log"); + let mut writer = SizeRotatingWriter::new( + path, + LogRotation { + max_file_bytes: 3, + retained_files: 2, + }, + ) + .expect("writer"); + writer.file = None; + writer.rotate().expect("rotation"); + writer.write_all(b"ok").expect("write after rotation"); + } +} diff --git a/core/crates/tera_ffi/src/operations.rs b/core/crates/tera_ffi/src/operations.rs @@ -0,0 +1,1005 @@ +//! Focused secret-safe operation records for settings, profile, revision, and inbound media. + +use tera_core::runtime::product_surface::{ + AddCommandType, BlossomEndpointAuthorityPreference, BlossomPreferences, IdentityCommand, + IdentityLockState, IdentityRecord, IdentityState, LocalStoragePolicy, MediaNetworkPolicy, + MobileNetworkEnvironment, MobileSettings, Phase1LocalMediaArtifact, Phase1MediaCacheStatus, + Phase1ProfileStatus, Phase1RevisionPhase, Phase1RevisionPolicy, Phase1RevisionStatus, + Phase1RevisionTarget, ProfileMetadataCommand, RelayAccessPreference, RelayEndpointPreference, + RelayPreferences, SettingsTransition, phase1_new_operation_id, +}; + +use crate::dto::PreparedMedia; +use crate::{ + FfiAddDraftInput, FfiDraftStatusRecord, FfiOperationSettlementRecord, FfiOutboxState, + FfiPreparedMediaInput, MOBILE_FFI_SCHEMA_VERSION, RadrootsAppError, +}; + +impl From<crate::FfiAddCommandType> for AddCommandType { + fn from(value: crate::FfiAddCommandType) -> Self { + match value { + crate::FfiAddCommandType::CreateUpdate => Self::CreateUpdate, + crate::FfiAddCommandType::CreatePhotoUpdate => Self::CreatePhotoUpdate, + crate::FfiAddCommandType::CreateAsk => Self::CreateAsk, + crate::FfiAddCommandType::CreateEvent => Self::CreateEvent, + crate::FfiAddCommandType::CreateFoodAvailability => Self::CreateFoodAvailability, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum FfiIdentityLockState { + Locked, + Unlocked, +} + +impl From<IdentityLockState> for FfiIdentityLockState { + fn from(value: IdentityLockState) -> Self { + match value { + IdentityLockState::Locked => Self::Locked, + IdentityLockState::Unlocked => Self::Unlocked, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiSettingsIdentityRecord { + pub schema_version: u16, + pub id: String, + pub public_key: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiIdentityStateRecord { + pub schema_version: u16, + pub identities: Vec<FfiSettingsIdentityRecord>, + pub active_identity_id: Option<String>, + pub lock_state: FfiIdentityLockState, + pub pending_import_operation_id: Option<String>, +} + +impl From<&IdentityState> for FfiIdentityStateRecord { + fn from(value: &IdentityState) -> Self { + Self { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + identities: value + .identities() + .iter() + .map(|identity| FfiSettingsIdentityRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + id: identity.id().to_owned(), + public_key: identity.public_key_hex().to_owned(), + }) + .collect(), + active_identity_id: value.active_identity_id().map(str::to_owned), + lock_state: value.lock_state().into(), + pending_import_operation_id: value.pending_import_operation_id().map(str::to_owned), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum FfiIdentityCommandKind { + BeginImport, + CompleteImport, + CancelImport, + Select, + Lock, + Unlock, + Recover, +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiIdentityCommandRecord { + pub schema_version: u16, + pub kind: FfiIdentityCommandKind, + pub operation_id: Option<String>, + pub identity_id: Option<String>, + pub public_key: Option<String>, +} + +impl TryFrom<FfiIdentityCommandRecord> for IdentityCommand { + type Error = RadrootsAppError; + + fn try_from(value: FfiIdentityCommandRecord) -> Result<Self, Self::Error> { + require_schema(value.schema_version)?; + let no_operation = value.operation_id.is_none(); + let no_identity = value.identity_id.is_none() && value.public_key.is_none(); + match value.kind { + FfiIdentityCommandKind::BeginImport if no_identity => Ok(Self::BeginImport { + operation_id: required(value.operation_id, "identity_operation_id_required")?, + }), + FfiIdentityCommandKind::CompleteImport => { + let operation_id = required(value.operation_id, "identity_operation_id_required")?; + let identity_id = required(value.identity_id, "identity_id_required")?; + let public_key = required(value.public_key, "identity_public_key_required")?; + Ok(Self::CompleteImport { + operation_id, + identity: IdentityRecord::new(identity_id, &public_key) + .map_err(|error| RadrootsAppError::invalid_argument(error.code()))?, + }) + } + FfiIdentityCommandKind::CancelImport if no_identity => Ok(Self::CancelImport { + operation_id: required(value.operation_id, "identity_operation_id_required")?, + }), + FfiIdentityCommandKind::Select if no_operation && value.public_key.is_none() => { + Ok(Self::Select { + identity_id: required(value.identity_id, "identity_id_required")?, + }) + } + FfiIdentityCommandKind::Lock if no_operation && no_identity => Ok(Self::Lock), + FfiIdentityCommandKind::Unlock if no_operation && no_identity => Ok(Self::Unlock), + FfiIdentityCommandKind::Recover if no_operation && no_identity => Ok(Self::Recover), + _ => Err(RadrootsAppError::invalid_argument( + "invalid_identity_command", + )), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum FfiMobileNetworkEnvironment { + Public, + Simulator, + PhysicalDevice, +} + +impl From<FfiMobileNetworkEnvironment> for MobileNetworkEnvironment { + fn from(value: FfiMobileNetworkEnvironment) -> Self { + match value { + FfiMobileNetworkEnvironment::Public => Self::Public, + FfiMobileNetworkEnvironment::Simulator => Self::Simulator, + FfiMobileNetworkEnvironment::PhysicalDevice => Self::PhysicalDevice, + } + } +} + +impl From<MobileNetworkEnvironment> for FfiMobileNetworkEnvironment { + fn from(value: MobileNetworkEnvironment) -> Self { + match value { + MobileNetworkEnvironment::Public => Self::Public, + MobileNetworkEnvironment::Simulator => Self::Simulator, + MobileNetworkEnvironment::PhysicalDevice => Self::PhysicalDevice, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum FfiRelayAccessPreference { + ReadOnly, + ReadWrite, +} + +impl From<FfiRelayAccessPreference> for RelayAccessPreference { + fn from(value: FfiRelayAccessPreference) -> Self { + match value { + FfiRelayAccessPreference::ReadOnly => Self::ReadOnly, + FfiRelayAccessPreference::ReadWrite => Self::ReadWrite, + } + } +} + +impl From<RelayAccessPreference> for FfiRelayAccessPreference { + fn from(value: RelayAccessPreference) -> Self { + match value { + RelayAccessPreference::ReadOnly => Self::ReadOnly, + RelayAccessPreference::ReadWrite => Self::ReadWrite, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiRelayPreferenceRecord { + pub schema_version: u16, + pub url: String, + pub access: FfiRelayAccessPreference, +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiRelayPreferencesRecord { + pub schema_version: u16, + pub environment: FfiMobileNetworkEnvironment, + pub endpoints: Vec<FfiRelayPreferenceRecord>, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum FfiBlossomAuthorityPreference { + PublicWebPki, + LoopbackDevelopment, + PrivateNetworkDevelopment, +} + +impl From<FfiBlossomAuthorityPreference> for BlossomEndpointAuthorityPreference { + fn from(value: FfiBlossomAuthorityPreference) -> Self { + match value { + FfiBlossomAuthorityPreference::PublicWebPki => Self::PublicWebPki, + FfiBlossomAuthorityPreference::LoopbackDevelopment => Self::LoopbackDevelopment, + FfiBlossomAuthorityPreference::PrivateNetworkDevelopment => { + Self::PrivateNetworkDevelopment + } + } + } +} + +impl From<BlossomEndpointAuthorityPreference> for FfiBlossomAuthorityPreference { + fn from(value: BlossomEndpointAuthorityPreference) -> Self { + match value { + BlossomEndpointAuthorityPreference::PublicWebPki => Self::PublicWebPki, + BlossomEndpointAuthorityPreference::LoopbackDevelopment => Self::LoopbackDevelopment, + BlossomEndpointAuthorityPreference::PrivateNetworkDevelopment => { + Self::PrivateNetworkDevelopment + } + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiBlossomPreferencesRecord { + pub schema_version: u16, + pub environment: FfiMobileNetworkEnvironment, + pub authority: FfiBlossomAuthorityPreference, + pub primary_origin: String, + pub fallback_origins: Vec<String>, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiMediaNetworkPolicyRecord { + pub schema_version: u16, + pub allow_cellular_downloads: bool, + pub allow_cellular_uploads: bool, + pub allow_background_transfers: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiLocalStoragePolicyRecord { + pub schema_version: u16, + pub media_cache_bytes: u64, + pub media_cache_artifacts: u32, +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiMobileSettingsRecord { + pub schema_version: u16, + pub revision: u64, + pub identity: FfiIdentityStateRecord, + pub relays: FfiRelayPreferencesRecord, + pub blossom: FfiBlossomPreferencesRecord, + pub media_network: FfiMediaNetworkPolicyRecord, + pub local_storage: FfiLocalStoragePolicyRecord, +} + +impl From<&MobileSettings> for FfiMobileSettingsRecord { + fn from(value: &MobileSettings) -> Self { + Self { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + revision: value.revision(), + identity: value.identity().into(), + relays: FfiRelayPreferencesRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + environment: value.relays().environment().into(), + endpoints: value + .relays() + .endpoints() + .iter() + .map(|endpoint| FfiRelayPreferenceRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + url: endpoint.url().to_owned(), + access: endpoint.access().into(), + }) + .collect(), + }, + blossom: FfiBlossomPreferencesRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + environment: value.blossom().environment().into(), + authority: value.blossom().authority().into(), + primary_origin: value.blossom().primary_origin().to_owned(), + fallback_origins: value.blossom().fallback_origins().to_vec(), + }, + media_network: FfiMediaNetworkPolicyRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + allow_cellular_downloads: value.media_network().allow_cellular_downloads(), + allow_cellular_uploads: value.media_network().allow_cellular_uploads(), + allow_background_transfers: value.media_network().allow_background_transfers(), + }, + local_storage: FfiLocalStoragePolicyRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + media_cache_bytes: value.local_storage().media_cache_bytes(), + media_cache_artifacts: value.local_storage().media_cache_artifacts(), + }, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiReplaceSettingsRecord { + pub schema_version: u16, + pub expected_revision: u64, + pub relays: FfiRelayPreferencesRecord, + pub blossom: FfiBlossomPreferencesRecord, + pub media_network: FfiMediaNetworkPolicyRecord, + pub local_storage: FfiLocalStoragePolicyRecord, +} + +impl FfiReplaceSettingsRecord { + pub(crate) fn apply(self, current: MobileSettings) -> Result<MobileSettings, RadrootsAppError> { + require_schema(self.schema_version)?; + if current.revision() != self.expected_revision { + return Err(RadrootsAppError::invalid_argument( + "settings_revision_conflict", + )); + } + require_schema(self.relays.schema_version)?; + let relay_environment: MobileNetworkEnvironment = self.relays.environment.into(); + let relay_endpoints = self + .relays + .endpoints + .into_iter() + .map(|endpoint| { + require_schema(endpoint.schema_version)?; + RelayEndpointPreference::new( + relay_environment, + endpoint.url, + endpoint.access.into(), + ) + .map_err(Into::into) + }) + .collect::<Result<Vec<_>, RadrootsAppError>>()?; + let relays = RelayPreferences::new(relay_environment, relay_endpoints)?; + + require_schema(self.blossom.schema_version)?; + let blossom = BlossomPreferences::new( + self.blossom.environment.into(), + self.blossom.authority.into(), + self.blossom.primary_origin, + self.blossom.fallback_origins, + )?; + require_schema(self.media_network.schema_version)?; + let media_network = MediaNetworkPolicy::new( + self.media_network.allow_cellular_downloads, + self.media_network.allow_cellular_uploads, + self.media_network.allow_background_transfers, + ); + require_schema(self.local_storage.schema_version)?; + let local_storage = LocalStoragePolicy::new( + self.local_storage.media_cache_bytes, + self.local_storage.media_cache_artifacts, + )?; + Ok(current + .with_relays(relays) + .with_blossom(blossom) + .with_media_network(media_network) + .with_local_storage(local_storage)) + } +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiSettingsTransitionRecord { + pub schema_version: u16, + pub settings: FfiMobileSettingsRecord, + pub runtime_restart_required: bool, + pub outbox_requeue_required: bool, + pub media_cache_invalidation_required: bool, +} + +impl From<SettingsTransition> for FfiSettingsTransitionRecord { + fn from(value: SettingsTransition) -> Self { + Self { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + settings: (&value.settings).into(), + runtime_restart_required: value.runtime_restart_required, + outbox_requeue_required: value.outbox_requeue_required, + media_cache_invalidation_required: value.media_cache_invalidation_required, + } + } +} + +#[derive(Clone, Debug, uniffi::Record)] +pub struct FfiProfileMetadataInputRecord { + pub schema_version: u16, + pub name: String, + pub display_name: Option<String>, + pub about: Option<String>, + pub picture: Option<FfiPreparedMediaInput>, + pub banner: Option<FfiPreparedMediaInput>, + pub nip05: Option<String>, + pub bot: Option<bool>, +} + +impl FfiProfileMetadataInputRecord { + pub(crate) fn command( + self, + blossom: Option<&radroots_sdk::transport::BlossomSlot>, + ) -> Result<ProfileMetadataCommand, RadrootsAppError> { + require_schema(self.schema_version)?; + let picture = self + .picture + .map(PreparedMedia::try_from) + .transpose()? + .map(|media| { + let blossom = blossom.ok_or_else(|| { + RadrootsAppError::failure( + "blossom_unconfigured", + "profile", + true, + &["configure_blossom"], + "Profile media configuration is unavailable.", + ) + })?; + media.into_authored_image(blossom) + }) + .transpose()?; + let banner = self + .banner + .map(PreparedMedia::try_from) + .transpose()? + .map(|media| { + let blossom = blossom.ok_or_else(|| { + RadrootsAppError::failure( + "blossom_unconfigured", + "profile", + true, + &["configure_blossom"], + "Profile media configuration is unavailable.", + ) + })?; + media.into_authored_image(blossom) + }) + .transpose()?; + ProfileMetadataCommand::new( + self.name, + self.display_name, + self.about, + picture, + banner, + self.nip05, + self.bot, + ) + .map_err(Into::into) + } +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiProfileStatusRecord { + pub schema_version: u16, + pub operation_id: String, + pub revision: u64, + pub author_public_key: String, + pub state: FfiOutboxState, + pub delivery_id: Option<String>, + pub created_at_unix_ms: u64, + pub updated_at_unix_ms: u64, + pub settlement: Option<FfiOperationSettlementRecord>, +} + +impl From<Phase1ProfileStatus> for FfiProfileStatusRecord { + fn from(value: Phase1ProfileStatus) -> Self { + let draft = value.draft(); + Self { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + operation_id: hex::encode(draft.draft_id().as_bytes()), + revision: draft.revision().get(), + author_public_key: hex::encode(draft.author()), + state: value.state().into(), + delivery_id: draft.operation_id().map(|id| hex::encode(id.as_bytes())), + created_at_unix_ms: draft.created_at_unix_ms(), + updated_at_unix_ms: draft.updated_at_unix_ms(), + settlement: value.push().map(|push| push.settlement().into()), + } + } +} + +#[derive(Clone, Debug, uniffi::Record)] +pub struct FfiRevisionInputRecord { + pub schema_version: u16, + pub card_id: String, + pub source_event_id: String, + pub source_address: Option<String>, + pub author_public_key: String, + pub replacement: FfiAddDraftInput, +} + +impl FfiRevisionInputRecord { + pub(crate) fn target(&self) -> Result<Phase1RevisionTarget, RadrootsAppError> { + require_schema(self.schema_version)?; + Phase1RevisionTarget::from_source( + self.replacement.command_type.into(), + tera_core::runtime::product_surface::CardId::parse(&self.card_id) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_card_id"))?, + self.source_event_id.clone(), + self.source_address.clone(), + self.author_public_key.clone(), + ) + .map_err(Into::into) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum FfiRevisionPolicy { + ReplaceThenRetract, + AddressableReplacement, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum FfiRevisionPhase { + ReplacementPending, + ReplacementFailed, + RetractionPending, + Complete, + PartialEffect, + Cancelled, +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiRevisionStatusRecord { + pub schema_version: u16, + pub operation_id: String, + pub replacement: FfiDraftStatusRecord, + pub retraction: Option<FfiDraftStatusRecord>, + pub policy: FfiRevisionPolicy, + pub phase: FfiRevisionPhase, +} + +impl From<Phase1RevisionStatus> for FfiRevisionStatusRecord { + fn from(value: Phase1RevisionStatus) -> Self { + let operation_id = hex::encode(value.replacement().draft().draft_id().as_bytes()); + let retraction = value.retraction().cloned().map(Into::into); + let replacement = value.replacement().clone().into(); + Self { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + operation_id, + replacement, + retraction, + policy: match value.policy() { + Phase1RevisionPolicy::ReplaceThenRetract => FfiRevisionPolicy::ReplaceThenRetract, + Phase1RevisionPolicy::AddressableReplacement => { + FfiRevisionPolicy::AddressableReplacement + } + }, + phase: match value.phase() { + Phase1RevisionPhase::ReplacementPending => FfiRevisionPhase::ReplacementPending, + Phase1RevisionPhase::ReplacementFailed => FfiRevisionPhase::ReplacementFailed, + Phase1RevisionPhase::RetractionPending => FfiRevisionPhase::RetractionPending, + Phase1RevisionPhase::Complete => FfiRevisionPhase::Complete, + Phase1RevisionPhase::PartialEffect => FfiRevisionPhase::PartialEffect, + Phase1RevisionPhase::Cancelled => FfiRevisionPhase::Cancelled, + }, + } + } +} + +#[derive(uniffi::Object)] +pub struct FfiMediaOperation { + operation_id: [u8; 16], + cancellation: radroots_sdk::transport::BlossomCancellation, + claimed: std::sync::atomic::AtomicBool, +} + +#[uniffi::export] +impl FfiMediaOperation { + #[uniffi::constructor] + pub fn new() -> Result<Self, RadrootsAppError> { + Ok(Self { + operation_id: phase1_new_operation_id().map_err(RadrootsAppError::from)?, + cancellation: radroots_sdk::transport::BlossomCancellation::default(), + claimed: std::sync::atomic::AtomicBool::new(false), + }) + } + + pub fn operation_id(&self) -> String { + hex::encode(self.operation_id) + } + + pub fn cancel(&self) { + self.cancellation.cancel(); + } + + pub fn is_cancelled(&self) -> bool { + self.cancellation.is_cancelled() + } +} + +impl FfiMediaOperation { + pub(crate) fn claim(&self) -> Result<(), RadrootsAppError> { + self.claimed + .compare_exchange( + false, + true, + std::sync::atomic::Ordering::AcqRel, + std::sync::atomic::Ordering::Acquire, + ) + .map(|_| ()) + .map_err(|_| RadrootsAppError::invalid_argument("media_operation_already_used")) + } + + pub(crate) const fn id(&self) -> [u8; 16] { + self.operation_id + } + + pub(crate) fn cancellation(&self) -> radroots_sdk::transport::BlossomCancellation { + self.cancellation.clone() + } +} + +#[derive(Clone, Eq, PartialEq, uniffi::Record)] +pub struct FfiVerifiedMediaArtifactRecord { + pub schema_version: u16, + pub operation_id: Option<String>, + pub artifact_id: String, + pub bytes: Vec<u8>, + pub byte_size: u64, + pub media_type: String, + pub width: u32, + pub height: u32, +} + +impl std::fmt::Debug for FfiVerifiedMediaArtifactRecord { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("FfiVerifiedMediaArtifactRecord") + .field("schema_version", &self.schema_version) + .field("operation_id", &self.operation_id) + .field("artifact_id", &self.artifact_id) + .field("bytes", &"<redacted>") + .field("byte_size", &self.byte_size) + .field("media_type", &self.media_type) + .field("width", &self.width) + .field("height", &self.height) + .finish() + } +} + +impl FfiVerifiedMediaArtifactRecord { + pub(crate) fn from_artifact( + value: Phase1LocalMediaArtifact, + operation_id: Option<String>, + ) -> Self { + Self { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + operation_id, + artifact_id: value.artifact_id().to_hex(), + bytes: value.bytes().to_vec(), + byte_size: value.byte_size(), + media_type: value.media_type().to_owned(), + width: value.width(), + height: value.height(), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiMediaCacheStatusRecord { + pub schema_version: u16, + pub artifact_count: u32, + pub total_bytes: u64, + pub configuration_fingerprint: Option<String>, +} + +impl From<Phase1MediaCacheStatus> for FfiMediaCacheStatusRecord { + fn from(value: Phase1MediaCacheStatus) -> Self { + Self { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + artifact_count: value.artifacts, + total_bytes: value.bytes, + configuration_fingerprint: value.configuration.map(|value| value.to_hex()), + } + } +} + +pub(crate) fn require_schema(schema_version: u16) -> Result<(), RadrootsAppError> { + if schema_version == MOBILE_FFI_SCHEMA_VERSION { + Ok(()) + } else { + Err(RadrootsAppError::invalid_argument( + "unsupported_schema_version", + )) + } +} + +fn required(value: Option<String>, code: &'static str) -> Result<String, RadrootsAppError> { + value.ok_or_else(|| RadrootsAppError::invalid_argument(code)) +} + +pub(crate) fn decode_artifact_id( + value: &str, +) -> Result<tera_core::runtime::product_surface::Phase1MediaArtifactId, RadrootsAppError> { + tera_core::runtime::product_surface::Phase1MediaArtifactId::parse(value) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_media_artifact_id")) +} + +pub(crate) fn decode_configuration( + value: &str, +) -> Result< + tera_core::runtime::product_surface::Phase1MediaConfigurationFingerprint, + RadrootsAppError, +> { + tera_core::runtime::product_surface::Phase1MediaConfigurationFingerprint::parse(value) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_media_configuration")) +} + +pub(crate) fn decode_reference_fingerprint(value: &str) -> Result<[u8; 32], RadrootsAppError> { + let bytes = hex::decode(value) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_media_reference_fingerprint"))?; + bytes + .try_into() + .map_err(|_| RadrootsAppError::invalid_argument("invalid_media_reference_fingerprint")) +} + +#[cfg(test)] +mod tests { + use super::*; + use tera_core::runtime::product_surface::Phase1MediaConfigurationFingerprint; + + const PUBLIC_KEY: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + + #[test] + fn media_operation_can_be_claimed_exactly_once() { + let operation = FfiMediaOperation::new().unwrap(); + operation.claim().unwrap(); + let error = operation.claim().unwrap_err(); + assert_eq!(error.report().code, "media_operation_already_used"); + assert_eq!(operation.operation_id().len(), 32); + } + + #[test] + fn verified_media_artifact_debug_never_exposes_renderable_bytes() { + let artifact = FfiVerifiedMediaArtifactRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + operation_id: None, + artifact_id: "11".repeat(32), + bytes: b"private farm image".to_vec(), + byte_size: 18, + media_type: "image/png".to_owned(), + width: 1, + height: 1, + }; + let debug = format!("{artifact:?}"); + assert!(debug.contains("<redacted>")); + assert!(!debug.contains("private farm image")); + } + + #[test] + fn identity_commands_reject_fields_outside_the_selected_variant() { + let error = IdentityCommand::try_from(FfiIdentityCommandRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + kind: FfiIdentityCommandKind::Lock, + operation_id: Some("unexpected".to_owned()), + identity_id: None, + public_key: None, + }) + .unwrap_err(); + assert_eq!(error.report().code, "invalid_identity_command"); + } + + #[test] + fn boundary_enums_and_identity_commands_cover_the_closed_vocabularies() { + for (ffi, core) in [ + ( + crate::FfiAddCommandType::CreateUpdate, + AddCommandType::CreateUpdate, + ), + ( + crate::FfiAddCommandType::CreatePhotoUpdate, + AddCommandType::CreatePhotoUpdate, + ), + ( + crate::FfiAddCommandType::CreateAsk, + AddCommandType::CreateAsk, + ), + ( + crate::FfiAddCommandType::CreateEvent, + AddCommandType::CreateEvent, + ), + ( + crate::FfiAddCommandType::CreateFoodAvailability, + AddCommandType::CreateFoodAvailability, + ), + ] { + assert_eq!(AddCommandType::from(ffi), core); + } + for environment in [ + FfiMobileNetworkEnvironment::Public, + FfiMobileNetworkEnvironment::Simulator, + FfiMobileNetworkEnvironment::PhysicalDevice, + ] { + assert_eq!( + FfiMobileNetworkEnvironment::from(MobileNetworkEnvironment::from(environment)), + environment + ); + } + for access in [ + FfiRelayAccessPreference::ReadOnly, + FfiRelayAccessPreference::ReadWrite, + ] { + assert_eq!( + FfiRelayAccessPreference::from(RelayAccessPreference::from(access)), + access + ); + } + for authority in [ + FfiBlossomAuthorityPreference::PublicWebPki, + FfiBlossomAuthorityPreference::LoopbackDevelopment, + FfiBlossomAuthorityPreference::PrivateNetworkDevelopment, + ] { + assert_eq!( + FfiBlossomAuthorityPreference::from(BlossomEndpointAuthorityPreference::from( + authority + )), + authority + ); + } + + let commands = [ + FfiIdentityCommandRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + kind: FfiIdentityCommandKind::BeginImport, + operation_id: Some("operation".to_owned()), + identity_id: None, + public_key: None, + }, + FfiIdentityCommandRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + kind: FfiIdentityCommandKind::CompleteImport, + operation_id: Some("operation".to_owned()), + identity_id: Some("primary".to_owned()), + public_key: Some(PUBLIC_KEY.to_owned()), + }, + FfiIdentityCommandRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + kind: FfiIdentityCommandKind::CancelImport, + operation_id: Some("operation".to_owned()), + identity_id: None, + public_key: None, + }, + FfiIdentityCommandRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + kind: FfiIdentityCommandKind::Select, + operation_id: None, + identity_id: Some("primary".to_owned()), + public_key: None, + }, + FfiIdentityCommandRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + kind: FfiIdentityCommandKind::Lock, + operation_id: None, + identity_id: None, + public_key: None, + }, + FfiIdentityCommandRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + kind: FfiIdentityCommandKind::Unlock, + operation_id: None, + identity_id: None, + public_key: None, + }, + FfiIdentityCommandRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + kind: FfiIdentityCommandKind::Recover, + operation_id: None, + identity_id: None, + public_key: None, + }, + ]; + for command in commands { + assert!(IdentityCommand::try_from(command).is_ok()); + } + for command in [ + FfiIdentityCommandRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + kind: FfiIdentityCommandKind::BeginImport, + operation_id: Some("operation".to_owned()), + identity_id: Some("unexpected".to_owned()), + public_key: None, + }, + FfiIdentityCommandRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + kind: FfiIdentityCommandKind::CancelImport, + operation_id: Some("operation".to_owned()), + identity_id: None, + public_key: Some(PUBLIC_KEY.to_owned()), + }, + FfiIdentityCommandRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + kind: FfiIdentityCommandKind::Select, + operation_id: Some("unexpected".to_owned()), + identity_id: Some("primary".to_owned()), + public_key: None, + }, + FfiIdentityCommandRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + kind: FfiIdentityCommandKind::Select, + operation_id: None, + identity_id: Some("primary".to_owned()), + public_key: Some(PUBLIC_KEY.to_owned()), + }, + FfiIdentityCommandRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + kind: FfiIdentityCommandKind::Lock, + operation_id: Some("unexpected".to_owned()), + identity_id: None, + public_key: None, + }, + FfiIdentityCommandRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + kind: FfiIdentityCommandKind::Unlock, + operation_id: None, + identity_id: Some("unexpected".to_owned()), + public_key: None, + }, + FfiIdentityCommandRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + kind: FfiIdentityCommandKind::Recover, + operation_id: None, + identity_id: None, + public_key: Some(PUBLIC_KEY.to_owned()), + }, + ] { + assert!(IdentityCommand::try_from(command).is_err()); + } + assert!( + IdentityCommand::try_from(FfiIdentityCommandRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION + 1, + kind: FfiIdentityCommandKind::Lock, + operation_id: None, + identity_id: None, + public_key: None, + }) + .is_err() + ); + + let identity = IdentityRecord::new("primary", PUBLIC_KEY).unwrap(); + for lock_state in [IdentityLockState::Locked, IdentityLockState::Unlocked] { + let state = IdentityState::new( + vec![identity.clone()], + Some("primary".to_owned()), + lock_state, + None, + ) + .unwrap(); + let record = FfiIdentityStateRecord::from(&state); + assert_eq!(record.identities.len(), 1); + assert_eq!(record.lock_state, lock_state.into()); + } + } + + #[test] + fn media_decoders_status_and_private_operation_accessors_are_exhaustive() { + assert!(require_schema(MOBILE_FFI_SCHEMA_VERSION).is_ok()); + assert!(require_schema(MOBILE_FFI_SCHEMA_VERSION + 1).is_err()); + for invalid in ["", "not-hex", "00"] { + assert!(decode_artifact_id(invalid).is_err()); + assert!(decode_configuration(invalid).is_err()); + assert!(decode_reference_fingerprint(invalid).is_err()); + } + let digest = "11".repeat(32); + assert!(decode_artifact_id(&digest).is_ok()); + assert!(decode_configuration(&digest).is_ok()); + assert_eq!(decode_reference_fingerprint(&digest).unwrap(), [0x11; 32]); + + let configuration = Phase1MediaConfigurationFingerprint::new([7; 32]).unwrap(); + let status = FfiMediaCacheStatusRecord::from(Phase1MediaCacheStatus { + artifacts: 2, + bytes: 9, + configuration: Some(configuration), + }); + assert_eq!(status.artifact_count, 2); + assert_eq!(status.total_bytes, 9); + assert_eq!(status.configuration_fingerprint, Some("07".repeat(32))); + assert!( + FfiMediaCacheStatusRecord::from(Phase1MediaCacheStatus { + artifacts: 0, + bytes: 0, + configuration: None, + }) + .configuration_fingerprint + .is_none() + ); + + let operation = FfiMediaOperation::new().unwrap(); + assert_eq!(operation.id(), operation.operation_id); + assert!(!operation.cancellation().is_cancelled()); + assert!(!operation.is_cancelled()); + operation.cancel(); + assert!(operation.is_cancelled()); + assert!(operation.cancellation().is_cancelled()); + } +} diff --git a/core/crates/tera_ffi/src/runtime.rs b/core/crates/tera_ffi/src/runtime.rs @@ -0,0 +1,1186 @@ +use std::sync::Arc; + +use tera_core::runtime::product_surface::{ + LocalNetworkRelayPolicy, Phase1AddIntent, Phase1ExistingDraft, Phase1MediaCachePolicy, + Phase1QueueIntent, Phase1ReviseIntent, ReplaceMobileSettings, TodayPageRequest, + phase1_new_addressable_identifier, phase1_operation_now_unix_ms, +}; + +use crate::dto::PreparedMedia; +use crate::operations::{ + FfiIdentityCommandRecord, FfiMediaCacheStatusRecord, FfiMediaOperation, + FfiMobileSettingsRecord, FfiProfileMetadataInputRecord, FfiProfileStatusRecord, + FfiReplaceSettingsRecord, FfiRevisionInputRecord, FfiRevisionStatusRecord, + FfiSettingsTransitionRecord, FfiVerifiedMediaArtifactRecord, decode_artifact_id, + decode_configuration, decode_reference_fingerprint, +}; +use crate::signer::HostSignerAdapter; +use crate::subscription::SubscriptionHub; +use crate::{ + FfiAddDraftInput, FfiAddSchemaRecord, FfiBlossomConfigurationRecord, + FfiBlossomEndpointAuthority, FfiBlossomEvidenceRecord, FfiBlossomHostKind, + FfiBlossomUploadInput, FfiBlossomUploadIntent, FfiCapabilityRecord, FfiCardAddParityRecord, + FfiDraftStatusRecord, FfiIdentityStatusRecord, FfiLocalNetworkRecord, FfiMeRecord, + FfiQueuePolicyRecord, FfiRelayStatusReportRecord, FfiRetractionDraftInput, + FfiRuntimeChangeKind, FfiRuntimeInfoRecord, FfiSearchResultRecord, FfiShutdownRecord, + FfiStorageStatusRecord, FfiSubscriptionHandle, FfiTodayPageRecord, FfiTodayProjectionUpdate, + FfiTodayRefreshRecord, FfiTodaySyncRecord, RadrootsAppError, RadrootsHostSigner, + RadrootsRuntimeObserver, add_schemas, decode_id, +}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum ProtectedDataAvailability { + Available, + Unavailable, +} + +impl From<ProtectedDataAvailability> for tera_core::runtime::store::ProtectedDataAvailability { + fn from(value: ProtectedDataAvailability) -> Self { + match value { + ProtectedDataAvailability::Available => Self::Available, + ProtectedDataAvailability::Unavailable => Self::Unavailable, + } + } +} + +/// Native boundary object delegating all product behavior to the ordinary Rust core. +#[derive(uniffi::Object)] +pub struct RadrootsRuntime { + inner: tera_core::RadrootsRuntime, + has_host_signer: bool, + subscriptions: Arc<SubscriptionHub>, +} + +#[cfg_attr(not(coverage_nightly), uniffi::export(async_runtime = "tokio"))] +impl RadrootsRuntime { + #[cfg_attr(not(coverage_nightly), uniffi::constructor)] + pub async fn new( + application_support_directory: String, + public_key_hex: String, + source_generation_hex: String, + source_generation_created_at_unix_ms: u64, + protected_data: ProtectedDataAvailability, + ) -> Result<Self, RadrootsAppError> { + build_runtime( + application_support_directory, + public_key_hex, + source_generation_hex, + source_generation_created_at_unix_ms, + protected_data, + None, + ) + .await + } + + #[cfg_attr(not(coverage_nightly), uniffi::constructor)] + pub async fn with_host_signer( + application_support_directory: String, + public_key_hex: String, + source_generation_hex: String, + source_generation_created_at_unix_ms: u64, + protected_data: ProtectedDataAvailability, + host_signer: Box<dyn RadrootsHostSigner>, + ) -> Result<Self, RadrootsAppError> { + build_runtime( + application_support_directory, + public_key_hex, + source_generation_hex, + source_generation_created_at_unix_ms, + protected_data, + Some(host_signer), + ) + .await + } + + pub async fn shutdown(&self) -> Result<FfiShutdownRecord, RadrootsAppError> { + let result = self + .inner + .shutdown() + .await + .map(Into::into) + .map_err(Into::into); + if result.is_ok() { + self.subscriptions + .notify(FfiRuntimeChangeKind::Lifecycle, None); + self.subscriptions.close(); + } + result + } + + pub fn uptime_millis(&self) -> i64 { + self.inner.uptime_millis() + } + + pub fn info(&self) -> FfiRuntimeInfoRecord { + self.inner.info().into() + } + + pub fn info_json(&self) -> String { + self.inner.info_json() + } + + pub fn set_app_info_platform( + &self, + platform: Option<String>, + bundle_id: Option<String>, + version: Option<String>, + build_number: Option<String>, + build_sha: Option<String>, + ) { + self.inner + .set_app_info_platform(platform, bundle_id, version, build_number, build_sha); + } + + pub fn identity_status(&self) -> Result<FfiIdentityStatusRecord, RadrootsAppError> { + let public_key = self + .inner + .authenticated_store_public_key_hex() + .ok_or_else(|| { + RadrootsAppError::failure( + "identity_unavailable", + "identity", + true, + &["unlock_identity"], + "The active identity is unavailable.", + ) + })?; + Ok(FfiIdentityStatusRecord { + schema_version: crate::MOBILE_FFI_SCHEMA_VERSION, + public_key, + host_signer_configured: self.has_host_signer, + }) + } + + pub fn sdk_capabilities(&self) -> Vec<FfiCapabilityRecord> { + self.inner + .sdk_capabilities() + .into_iter() + .map(Into::into) + .collect() + } + + pub async fn sdk_storage_status(&self) -> Result<FfiStorageStatusRecord, RadrootsAppError> { + self.inner + .sdk_storage_status() + .await + .map(Into::into) + .map_err(Into::into) + } + + pub fn sdk_relay_status(&self) -> Result<Option<FfiRelayStatusReportRecord>, RadrootsAppError> { + self.inner + .sdk_relay_status() + .map(|value| value.map(Into::into)) + .map_err(Into::into) + } + + pub fn sdk_blossom_configuration( + &self, + ) -> Result<Option<FfiBlossomConfigurationRecord>, RadrootsAppError> { + self.inner + .sdk_blossom_configuration() + .map(|value| value.map(Into::into)) + .map_err(Into::into) + } + + pub fn sdk_blossom_evidence( + &self, + ) -> Result<Option<FfiBlossomEvidenceRecord>, RadrootsAppError> { + self.inner + .sdk_blossom_evidence() + .map(|value| value.map(Into::into)) + .map_err(Into::into) + } + + pub async fn probe_blossom(&self) -> Result<FfiBlossomEvidenceRecord, RadrootsAppError> { + let evidence = self + .inner + .probe_blossom() + .await + .map(Into::into) + .map_err(RadrootsAppError::from)?; + self.subscriptions.notify(FfiRuntimeChangeKind::Media, None); + Ok(evidence) + } + + pub fn subscribe_changes( + &self, + observer: Box<dyn RadrootsRuntimeObserver>, + ) -> Result<Arc<FfiSubscriptionHandle>, RadrootsAppError> { + self.subscriptions.subscribe(observer) + } + + pub fn configure_public_relays( + &self, + writable_relays: Vec<String>, + ) -> Result<(), RadrootsAppError> { + self.inner + .configure_public_relays(writable_relays) + .map_err(RadrootsAppError::from)?; + self.subscriptions.notify(FfiRuntimeChangeKind::Relay, None); + Ok(()) + } + + pub fn configure_simulator_relays( + &self, + loopback_relays: Vec<String>, + ) -> Result<(), RadrootsAppError> { + self.inner + .configure_simulator_relays(loopback_relays) + .map_err(RadrootsAppError::from)?; + self.subscriptions.notify(FfiRuntimeChangeKind::Relay, None); + Ok(()) + } + + pub fn configure_device_relays( + &self, + writable_relays: Vec<String>, + ) -> Result<(), RadrootsAppError> { + self.inner + .configure_device_relays(writable_relays) + .map_err(RadrootsAppError::from)?; + self.subscriptions.notify(FfiRuntimeChangeKind::Relay, None); + Ok(()) + } + + pub fn configure_blossom( + &self, + host_kind: FfiBlossomHostKind, + endpoint_authority: FfiBlossomEndpointAuthority, + primary_origin: String, + fallback_origins: Vec<String>, + ) -> Result<(), RadrootsAppError> { + self.inner + .configure_blossom( + host_kind.into(), + endpoint_authority.into(), + primary_origin, + fallback_origins, + ) + .map_err(RadrootsAppError::from)?; + self.subscriptions.notify(FfiRuntimeChangeKind::Media, None); + Ok(()) + } + + pub fn phase1_card_add_parity(&self) -> Vec<FfiCardAddParityRecord> { + self.inner + .phase1_card_add_parity() + .into_iter() + .map(|value| FfiCardAddParityRecord { + schema_version: crate::MOBILE_FFI_SCHEMA_VERSION, + card_type: value.card_type.into(), + command_type: value.add_command_type.into(), + }) + .collect() + } + + pub fn phase1_add_schemas(&self) -> Vec<FfiAddSchemaRecord> { + add_schemas() + } + + pub fn phase1_local_network( + &self, + context: FfiLocalNetworkRecord, + ) -> Result<FfiLocalNetworkRecord, RadrootsAppError> { + self.local_network(context).map(Into::into) + } + + pub async fn phase1_today_page( + &self, + context: FfiLocalNetworkRecord, + limit: u16, + as_of_unix_s: Option<u64>, + cursor: Option<String>, + ) -> Result<FfiTodayPageRecord, RadrootsAppError> { + if as_of_unix_s.is_some() == cursor.is_some() { + return Err(RadrootsAppError::invalid_argument( + "invalid_today_page_request", + )); + } + let context = self.local_network(context)?; + let request = match cursor { + Some(cursor) => TodayPageRequest::after(limit, cursor), + None => TodayPageRequest::first( + limit, + as_of_unix_s + .ok_or_else(|| RadrootsAppError::invalid_argument("today_as_of_required"))?, + ), + }; + self.inner + .phase1_today_page(&context, request) + .await + .map(Into::into) + .map_err(Into::into) + } + + pub async fn phase1_refresh_today( + &self, + context: FfiLocalNetworkRecord, + now_unix_s: u64, + update: FfiTodayProjectionUpdate, + ) -> Result<FfiTodayRefreshRecord, RadrootsAppError> { + let context = self.local_network(context)?; + let receipt = self + .inner + .phase1_refresh_today(&context, now_unix_s, update.into()) + .await + .map_err(RadrootsAppError::from)?; + self.subscriptions.notify(FfiRuntimeChangeKind::Today, None); + Ok(receipt.into()) + } + + pub async fn phase1_sync_today( + &self, + context: FfiLocalNetworkRecord, + now_unix_s: u64, + update: FfiTodayProjectionUpdate, + ) -> Result<FfiTodaySyncRecord, RadrootsAppError> { + let context = self.local_network(context)?; + let receipt = self + .inner + .phase1_sync_today(&context, now_unix_s, update.into()) + .await + .map_err(RadrootsAppError::from)?; + self.subscriptions.notify(FfiRuntimeChangeKind::Today, None); + Ok(receipt.into()) + } + + pub async fn phase1_search( + &self, + context: FfiLocalNetworkRecord, + query: String, + limit: u16, + as_of_unix_s: u64, + ) -> Result<Vec<FfiSearchResultRecord>, RadrootsAppError> { + let context = self.local_network(context)?; + self.inner + .phase1_search(&context, &query, limit, as_of_unix_s) + .await + .map(|results| results.into_iter().map(Into::into).collect()) + .map_err(Into::into) + } + + pub async fn phase1_me( + &self, + context: FfiLocalNetworkRecord, + as_of_unix_s: u64, + ) -> Result<FfiMeRecord, RadrootsAppError> { + let context = self.local_network(context)?; + let public_key = self + .inner + .authenticated_store_public_key_hex() + .ok_or_else(|| { + RadrootsAppError::failure( + "identity_unavailable", + "identity", + true, + &["unlock_identity"], + "The active identity is unavailable.", + ) + })?; + self.inner + .phase1_me(&context, &public_key, as_of_unix_s) + .await + .map(Into::into) + .map_err(Into::into) + } + + pub fn phase1_validate_add_draft( + &self, + input: FfiAddDraftInput, + authored_at_unix_s: u64, + ) -> Result<(), RadrootsAppError> { + let blossom = self + .inner + .sdk_blossom_slot() + .map_err(RadrootsAppError::from)?; + input + .command_and_media(authored_at_unix_s, blossom.as_ref()) + .map(|_| ()) + } + + /// Saves one new or existing Add form while Rust owns all identity and + /// timestamp policy. Addressable identifiers are generated when omitted. + pub async fn phase1_save_add_intent( + &self, + mut input: FfiAddDraftInput, + existing_draft_id: Option<String>, + expected_revision: Option<u64>, + ) -> Result<FfiDraftStatusRecord, RadrootsAppError> { + if input.identifier.is_none() + && matches!( + input.command_type, + crate::FfiAddCommandType::CreateEvent + | crate::FfiAddCommandType::CreateFoodAvailability + ) + { + input.identifier = Some(phase1_new_addressable_identifier()); + } + let authored_at_unix_s = + phase1_operation_now_unix_ms().map_err(RadrootsAppError::from)? / 1_000; + let blossom = self + .inner + .sdk_blossom_slot() + .map_err(RadrootsAppError::from)?; + let (command, media, form) = + input.command_media_and_form(authored_at_unix_s, blossom.as_ref())?; + let existing = match (existing_draft_id, expected_revision) { + (Some(draft_id), Some(revision)) => Some( + Phase1ExistingDraft::new(decode_id(&draft_id, "invalid_draft_id")?, revision) + .map_err(RadrootsAppError::from)?, + ), + (None, None) => None, + _ => { + return Err(RadrootsAppError::invalid_argument("invalid_existing_draft")); + } + }; + let status = self + .inner + .phase1_save_add_intent( + Phase1AddIntent::new(command, media, form, existing) + .map_err(RadrootsAppError::from)?, + ) + .await + .map_err(RadrootsAppError::from)?; + let draft_id = hex::encode(status.draft().draft_id().as_bytes()); + self.subscriptions + .notify(FfiRuntimeChangeKind::Drafts, Some(draft_id)); + Ok(status.into()) + } + + #[allow(clippy::too_many_arguments)] + pub async fn phase1_save_draft( + &self, + draft_id: String, + input: FfiAddDraftInput, + authored_at_unix_s: u64, + expected_revision: Option<u64>, + persisted_at_unix_ms: u64, + ) -> Result<FfiDraftStatusRecord, RadrootsAppError> { + let blossom = self + .inner + .sdk_blossom_slot() + .map_err(RadrootsAppError::from)?; + let (command, media, form) = + input.command_media_and_form(authored_at_unix_s, blossom.as_ref())?; + let decoded_id = decode_id(&draft_id, "invalid_draft_id")?; + let status = self + .inner + .phase1_save_draft_with_form( + decoded_id, + command, + authored_at_unix_s, + media, + form, + expected_revision, + persisted_at_unix_ms, + ) + .await + .map_err(RadrootsAppError::from)?; + self.subscriptions + .notify(FfiRuntimeChangeKind::Drafts, Some(draft_id)); + Ok(status.into()) + } + + pub async fn phase1_save_retraction_draft( + &self, + draft_id: String, + input: FfiRetractionDraftInput, + authored_at_unix_s: u64, + persisted_at_unix_ms: u64, + ) -> Result<FfiDraftStatusRecord, RadrootsAppError> { + if input.schema_version != crate::MOBILE_FFI_SCHEMA_VERSION { + return Err(RadrootsAppError::invalid_argument( + "unsupported_schema_version", + )); + } + let decoded_id = decode_id(&draft_id, "invalid_draft_id")?; + let command_type = match input.command_type { + crate::FfiAddCommandType::CreateUpdate => { + tera_core::runtime::product_surface::AddCommandType::CreateUpdate + } + crate::FfiAddCommandType::CreatePhotoUpdate => { + tera_core::runtime::product_surface::AddCommandType::CreatePhotoUpdate + } + crate::FfiAddCommandType::CreateAsk => { + tera_core::runtime::product_surface::AddCommandType::CreateAsk + } + crate::FfiAddCommandType::CreateEvent => { + tera_core::runtime::product_surface::AddCommandType::CreateEvent + } + crate::FfiAddCommandType::CreateFoodAvailability => { + tera_core::runtime::product_surface::AddCommandType::CreateFoodAvailability + } + }; + let card_id = tera_core::runtime::product_surface::CardId::parse(&input.target_card_id) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_card_id"))?; + let status = self + .inner + .phase1_save_retraction_draft( + decoded_id, + command_type, + card_id, + &input.target_event_id, + input.target_kind, + input.target_address.as_deref(), + &input.reason, + authored_at_unix_s, + persisted_at_unix_ms, + ) + .await + .map_err(RadrootsAppError::from)?; + self.subscriptions + .notify(FfiRuntimeChangeKind::Drafts, Some(draft_id)); + Ok(status.into()) + } + + pub async fn phase1_draft_status( + &self, + draft_id: String, + ) -> Result<FfiDraftStatusRecord, RadrootsAppError> { + self.inner + .phase1_draft_status(decode_id(&draft_id, "invalid_draft_id")?) + .await + .map(Into::into) + .map_err(Into::into) + } + + pub async fn phase1_draft_heads( + &self, + limit: u16, + ) -> Result<Vec<FfiDraftStatusRecord>, RadrootsAppError> { + self.inner + .phase1_draft_heads(limit) + .await + .map(|values| values.into_iter().map(Into::into).collect()) + .map_err(Into::into) + } + + pub async fn phase1_queue_draft( + &self, + draft_id: String, + expected_revision: u64, + policy: FfiQueuePolicyRecord, + queued_at_unix_ms: u64, + ) -> Result<FfiDraftStatusRecord, RadrootsAppError> { + let decoded_id = decode_id(&draft_id, "invalid_draft_id")?; + let status = self + .inner + .phase1_queue_draft( + decoded_id, + expected_revision, + policy.try_into()?, + queued_at_unix_ms, + ) + .await + .map_err(RadrootsAppError::from)?; + self.subscriptions + .notify(FfiRuntimeChangeKind::Drafts, Some(draft_id)); + Ok(status.into()) + } + + /// Queues with the Rust-owned active relay and settlement policy. + pub async fn phase1_queue_add_intent( + &self, + draft_id: String, + expected_revision: u64, + ) -> Result<FfiDraftStatusRecord, RadrootsAppError> { + let decoded_id = decode_id(&draft_id, "invalid_draft_id")?; + let intent = Phase1QueueIntent::new(decoded_id, expected_revision) + .map_err(RadrootsAppError::from)?; + let status = self + .inner + .phase1_queue_add_intent(intent) + .await + .map_err(RadrootsAppError::from)?; + self.subscriptions + .notify(FfiRuntimeChangeKind::Drafts, Some(draft_id)); + Ok(status.into()) + } + + pub async fn phase1_recover_draft_queue( + &self, + draft_id: String, + recovered_at_unix_ms: u64, + ) -> Result<FfiDraftStatusRecord, RadrootsAppError> { + let decoded_id = decode_id(&draft_id, "invalid_draft_id")?; + let status = self + .inner + .phase1_recover_draft_queue(decoded_id, recovered_at_unix_ms) + .await + .map_err(RadrootsAppError::from)?; + self.subscriptions + .notify(FfiRuntimeChangeKind::Drafts, Some(draft_id)); + Ok(status.into()) + } + + pub async fn phase1_recover_add_intent( + &self, + draft_id: String, + ) -> Result<FfiDraftStatusRecord, RadrootsAppError> { + let decoded_id = decode_id(&draft_id, "invalid_draft_id")?; + let status = self + .inner + .phase1_recover_add_intent(decoded_id) + .await + .map_err(RadrootsAppError::from)?; + self.subscriptions + .notify(FfiRuntimeChangeKind::Drafts, Some(draft_id)); + Ok(status.into()) + } + + pub async fn phase1_sign_queued_draft( + &self, + draft_id: String, + expected_revision: u64, + ) -> Result<FfiDraftStatusRecord, RadrootsAppError> { + let decoded_id = decode_id(&draft_id, "invalid_draft_id")?; + let status = self + .inner + .phase1_sign_queued_draft(decoded_id, expected_revision) + .await + .map_err(RadrootsAppError::from)?; + self.subscriptions + .notify(FfiRuntimeChangeKind::Drafts, Some(draft_id)); + Ok(status.into()) + } + + pub async fn phase1_advance_draft( + &self, + draft_id: String, + expected_revision: u64, + ) -> Result<FfiDraftStatusRecord, RadrootsAppError> { + let decoded_id = decode_id(&draft_id, "invalid_draft_id")?; + let status = self + .inner + .phase1_advance_draft(decoded_id, expected_revision) + .await + .map_err(RadrootsAppError::from)?; + self.subscriptions + .notify(FfiRuntimeChangeKind::Drafts, Some(draft_id)); + Ok(status.into()) + } + + pub async fn phase1_upload_draft_media( + &self, + input: FfiBlossomUploadInput, + ) -> Result<FfiDraftStatusRecord, RadrootsAppError> { + if input.schema_version != crate::MOBILE_FFI_SCHEMA_VERSION { + return Err(RadrootsAppError::invalid_argument( + "unsupported_schema_version", + )); + } + let draft_id = decode_id(&input.draft_id, "invalid_draft_id")?; + let operation_id = decode_id(&input.operation_id, "invalid_operation_id")?; + let artifact_id = decode_id(&input.artifact_id, "invalid_artifact_id")?; + let media = PreparedMedia::try_from(input.media)?; + let request = media.upload_request(input.verified_at_unix_ms)?; + let content = radroots_blossom::authorization::AuthorizationContent::parse( + &input.authorization_content, + ) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_blossom_authorization"))?; + let status = self + .inner + .phase1_upload_draft_media( + draft_id, + input.expected_revision, + request, + content, + input.authorization_created_at_unix_s, + input.authorization_lifetime_seconds, + operation_id, + artifact_id, + input.signing_deadline_unix_ms, + input.signing_cancellation.core(), + radroots_sdk::transport::BlossomCancellation::default(), + input.updated_at_unix_ms, + ) + .await + .map_err(RadrootsAppError::from)?; + self.subscriptions + .notify(FfiRuntimeChangeKind::Media, Some(input.draft_id.clone())); + self.subscriptions + .notify(FfiRuntimeChangeKind::Drafts, Some(input.draft_id)); + Ok(status.into()) + } + + /// Runs a Rust-planned BUD-11/BUD-02/BUD-01 upload attempt. The host + /// supplies only the selected bounded file handle and draft revision. + pub async fn phase1_upload_add_media_intent( + &self, + input: FfiBlossomUploadIntent, + ) -> Result<FfiDraftStatusRecord, RadrootsAppError> { + if input.schema_version != crate::MOBILE_FFI_SCHEMA_VERSION { + return Err(RadrootsAppError::invalid_argument( + "unsupported_schema_version", + )); + } + let draft_id = decode_id(&input.draft_id, "invalid_draft_id")?; + let intent = PreparedMedia::try_from(input.media)? + .into_upload_intent(draft_id, input.expected_revision)?; + let status = self + .inner + .phase1_upload_add_media_intent(intent) + .await + .map_err(RadrootsAppError::from)?; + self.subscriptions + .notify(FfiRuntimeChangeKind::Media, Some(input.draft_id.clone())); + self.subscriptions + .notify(FfiRuntimeChangeKind::Drafts, Some(input.draft_id)); + Ok(status.into()) + } + + /// Persists the upload transition before returning an immutable native + /// background-transfer job. + pub async fn phase1_prepare_add_media_background( + &self, + input: crate::FfiBlossomUploadIntent, + ) -> Result<crate::FfiNativeUploadJobRecord, RadrootsAppError> { + if input.schema_version != crate::MOBILE_FFI_SCHEMA_VERSION { + return Err(RadrootsAppError::invalid_argument( + "unsupported_schema_version", + )); + } + let draft_id = decode_id(&input.draft_id, "invalid_draft_id")?; + let intent = PreparedMedia::try_from(input.media)? + .into_upload_intent(draft_id, input.expected_revision)?; + let (status, job) = self + .inner + .phase1_prepare_native_upload(intent) + .await + .map_err(RadrootsAppError::from)?; + self.subscriptions + .notify(FfiRuntimeChangeKind::Media, Some(input.draft_id.clone())); + self.subscriptions + .notify(FfiRuntimeChangeKind::Drafts, Some(input.draft_id)); + Ok(crate::FfiNativeUploadJobRecord { + schema_version: crate::MOBILE_FFI_SCHEMA_VERSION, + operation_id: hex::encode(job.operation_id()), + draft: status.into(), + remote_url: job.remote_url().to_owned(), + authorization_header: job.authorization_header().to_owned(), + expected_sha256: job.expected_sha256().to_owned(), + media_type: job.media_type().to_owned(), + byte_size: job.byte_size(), + }) + } + + /// Accepts only bounded native HTTP evidence; Rust performs descriptor and + /// exact-byte retrieval verification before advancing durable state. + pub async fn phase1_complete_add_media_background( + &self, + input: crate::FfiNativeUploadCompletionInput, + ) -> Result<FfiDraftStatusRecord, RadrootsAppError> { + if input.schema_version != crate::MOBILE_FFI_SCHEMA_VERSION + || input.response_body.len() > 16_384 + { + return Err(RadrootsAppError::invalid_argument( + "invalid_native_upload_completion", + )); + } + let draft_id = decode_id(&input.draft_id, "invalid_draft_id")?; + let intent = PreparedMedia::try_from(input.media)? + .into_upload_intent(draft_id, input.expected_revision)?; + let status = self + .inner + .phase1_complete_native_upload( + intent, + input.status_code, + input.response_media_type.as_deref(), + input.response_content_encoding.as_deref(), + input.response_body.as_slice(), + ) + .await + .map_err(RadrootsAppError::from)?; + self.subscriptions + .notify(FfiRuntimeChangeKind::Media, Some(input.draft_id.clone())); + self.subscriptions + .notify(FfiRuntimeChangeKind::Drafts, Some(input.draft_id)); + Ok(status.into()) + } + + pub async fn phase1_cancel_draft( + &self, + draft_id: String, + expected_revision: u64, + cancelled_at_unix_ms: u64, + ) -> Result<FfiDraftStatusRecord, RadrootsAppError> { + let decoded_id = decode_id(&draft_id, "invalid_draft_id")?; + let status = self + .inner + .phase1_cancel_draft(decoded_id, expected_revision, cancelled_at_unix_ms) + .await + .map_err(RadrootsAppError::from)?; + self.subscriptions + .notify(FfiRuntimeChangeKind::Drafts, Some(draft_id)); + Ok(status.into()) + } + + pub async fn phase1_cancel_add_intent( + &self, + draft_id: String, + expected_revision: u64, + ) -> Result<FfiDraftStatusRecord, RadrootsAppError> { + let decoded_id = decode_id(&draft_id, "invalid_draft_id")?; + let status = self + .inner + .phase1_cancel_add_intent(decoded_id, expected_revision) + .await + .map_err(RadrootsAppError::from)?; + self.subscriptions + .notify(FfiRuntimeChangeKind::Drafts, Some(draft_id)); + Ok(status.into()) + } + + pub async fn phase1_settings(&self) -> Result<FfiMobileSettingsRecord, RadrootsAppError> { + self.inner + .phase1_settings() + .await + .map(|settings| (&settings).into()) + .map_err(Into::into) + } + + pub async fn phase1_apply_settings_to_runtime( + &self, + ) -> Result<FfiMobileSettingsRecord, RadrootsAppError> { + let settings = self.inner.phase1_settings().await?; + self.inner + .configure_relay_preferences(settings.relays()) + .map_err(RadrootsAppError::from)?; + self.inner + .configure_blossom_preferences(settings.blossom()) + .map_err(RadrootsAppError::from)?; + self.subscriptions + .notify(FfiRuntimeChangeKind::Settings, None); + Ok((&settings).into()) + } + + pub async fn phase1_replace_settings( + &self, + input: FfiReplaceSettingsRecord, + ) -> Result<FfiSettingsTransitionRecord, RadrootsAppError> { + let current = self.inner.phase1_settings().await?; + let expected_revision = input.expected_revision; + let next = input.apply(current)?; + let transition = self + .inner + .phase1_replace_settings(ReplaceMobileSettings::new(expected_revision, next)?) + .await?; + self.subscriptions + .notify(FfiRuntimeChangeKind::Settings, None); + Ok(transition.into()) + } + + pub async fn phase1_apply_identity_command( + &self, + expected_revision: u64, + command: FfiIdentityCommandRecord, + ) -> Result<FfiSettingsTransitionRecord, RadrootsAppError> { + let transition = self + .inner + .phase1_apply_identity_command(expected_revision, command.try_into()?) + .await?; + let identity_id = transition + .settings + .identity() + .active_identity_id() + .map(str::to_owned); + self.subscriptions + .notify(FfiRuntimeChangeKind::Identity, identity_id); + Ok(transition.into()) + } + + pub async fn phase1_save_profile_metadata( + &self, + input: FfiProfileMetadataInputRecord, + ) -> Result<FfiProfileStatusRecord, RadrootsAppError> { + let blossom = self + .inner + .sdk_blossom_slot() + .map_err(RadrootsAppError::from)?; + let status = self + .inner + .phase1_save_profile_metadata(input.command(blossom.as_ref())?) + .await?; + let operation_id = hex::encode(status.draft().draft_id().as_bytes()); + self.subscriptions + .notify(FfiRuntimeChangeKind::Profile, Some(operation_id)); + Ok(status.into()) + } + + pub async fn phase1_profile_status( + &self, + operation_id: String, + ) -> Result<FfiProfileStatusRecord, RadrootsAppError> { + self.inner + .phase1_profile_status(decode_id(&operation_id, "invalid_operation_id")?) + .await + .map(Into::into) + .map_err(Into::into) + } + + pub async fn phase1_advance_profile( + &self, + operation_id: String, + ) -> Result<FfiProfileStatusRecord, RadrootsAppError> { + let status = self + .inner + .phase1_advance_profile(decode_id(&operation_id, "invalid_operation_id")?) + .await?; + self.subscriptions + .notify(FfiRuntimeChangeKind::Profile, Some(operation_id)); + Ok(status.into()) + } + + pub async fn phase1_cancel_profile( + &self, + operation_id: String, + expected_revision: u64, + ) -> Result<FfiProfileStatusRecord, RadrootsAppError> { + let status = self + .inner + .phase1_cancel_profile( + decode_id(&operation_id, "invalid_operation_id")?, + expected_revision, + ) + .await?; + self.subscriptions + .notify(FfiRuntimeChangeKind::Profile, Some(operation_id)); + Ok(status.into()) + } + + pub async fn phase1_save_revision_intent( + &self, + mut input: FfiRevisionInputRecord, + ) -> Result<FfiRevisionStatusRecord, RadrootsAppError> { + let target = input.target()?; + if input.replacement.identifier.is_none() + && matches!( + input.replacement.command_type, + crate::FfiAddCommandType::CreateEvent + | crate::FfiAddCommandType::CreateFoodAvailability + ) + { + input.replacement.identifier = Some(phase1_new_addressable_identifier()); + } + let authored_at_unix_s = phase1_operation_now_unix_ms()? / 1_000; + let blossom = self + .inner + .sdk_blossom_slot() + .map_err(RadrootsAppError::from)?; + let (command, media, form) = input + .replacement + .command_media_and_form(authored_at_unix_s, blossom.as_ref())?; + let status = self + .inner + .phase1_save_revision_intent(Phase1ReviseIntent::new(target, command, media, form)?) + .await?; + let operation_id = hex::encode(status.replacement().draft().draft_id().as_bytes()); + self.subscriptions + .notify(FfiRuntimeChangeKind::Drafts, Some(operation_id)); + Ok(status.into()) + } + + pub async fn phase1_revision_status( + &self, + operation_id: String, + ) -> Result<FfiRevisionStatusRecord, RadrootsAppError> { + self.inner + .phase1_revision_status(decode_id(&operation_id, "invalid_operation_id")?) + .await + .map(Into::into) + .map_err(Into::into) + } + + pub async fn phase1_advance_revision( + &self, + operation_id: String, + ) -> Result<FfiRevisionStatusRecord, RadrootsAppError> { + let status = self + .inner + .phase1_advance_revision(decode_id(&operation_id, "invalid_operation_id")?) + .await?; + self.subscriptions + .notify(FfiRuntimeChangeKind::Drafts, Some(operation_id)); + Ok(status.into()) + } + + pub async fn phase1_cancel_revision( + &self, + operation_id: String, + ) -> Result<FfiRevisionStatusRecord, RadrootsAppError> { + let status = self + .inner + .phase1_cancel_revision(decode_id(&operation_id, "invalid_operation_id")?) + .await?; + self.subscriptions + .notify(FfiRuntimeChangeKind::Drafts, Some(operation_id)); + Ok(status.into()) + } + + pub async fn phase1_retrieve_media( + &self, + context: FfiLocalNetworkRecord, + reference_fingerprint: String, + operation: Arc<FfiMediaOperation>, + ) -> Result<FfiVerifiedMediaArtifactRecord, RadrootsAppError> { + let context = self.local_network(context)?; + operation.claim()?; + let operation_id = operation.operation_id(); + let settings = self.inner.phase1_settings().await?; + let policy = Phase1MediaCachePolicy::new( + settings.local_storage().media_cache_bytes(), + settings.local_storage().media_cache_artifacts(), + ) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_media_cache_policy"))?; + let artifact = self + .inner + .phase1_retrieve_media( + &context, + decode_reference_fingerprint(&reference_fingerprint)?, + operation.id(), + policy, + operation.cancellation(), + ) + .await + .map_err(|error| { + RadrootsAppError::from(error).with_operation_id(operation_id.clone()) + })?; + self.subscriptions.notify( + FfiRuntimeChangeKind::Media, + Some(artifact.artifact_id().to_hex()), + ); + Ok(FfiVerifiedMediaArtifactRecord::from_artifact( + artifact, + Some(operation_id), + )) + } + + pub async fn phase1_verified_media_artifact( + &self, + context: FfiLocalNetworkRecord, + artifact_id: String, + ) -> Result<Option<FfiVerifiedMediaArtifactRecord>, RadrootsAppError> { + let context = self.local_network(context)?; + self.inner + .phase1_verified_media_artifact( + &context, + decode_artifact_id(&artifact_id)?, + phase1_operation_now_unix_ms()?, + ) + .await + .map(|value| { + value.map(|artifact| FfiVerifiedMediaArtifactRecord::from_artifact(artifact, None)) + }) + .map_err(Into::into) + } + + pub async fn phase1_media_cache_status( + &self, + context: FfiLocalNetworkRecord, + ) -> Result<FfiMediaCacheStatusRecord, RadrootsAppError> { + let context = self.local_network(context)?; + self.inner + .phase1_media_cache_status(&context) + .await + .map(Into::into) + .map_err(Into::into) + } + + pub async fn phase1_invalidate_media_artifact( + &self, + context: FfiLocalNetworkRecord, + artifact_id: String, + ) -> Result<bool, RadrootsAppError> { + let context = self.local_network(context)?; + let changed = self + .inner + .phase1_invalidate_media_artifact(&context, decode_artifact_id(&artifact_id)?) + .await?; + if changed { + self.subscriptions + .notify(FfiRuntimeChangeKind::Media, Some(artifact_id)); + } + Ok(changed) + } + + pub async fn phase1_invalidate_media_configuration( + &self, + context: FfiLocalNetworkRecord, + configuration_fingerprint: String, + ) -> Result<Vec<String>, RadrootsAppError> { + let context = self.local_network(context)?; + let removed = self + .inner + .phase1_invalidate_media_configuration( + &context, + decode_configuration(&configuration_fingerprint)?, + ) + .await?; + self.subscriptions.notify(FfiRuntimeChangeKind::Media, None); + Ok(removed.into_iter().map(|value| value.to_hex()).collect()) + } +} + +impl RadrootsRuntime { + fn local_network( + &self, + context: FfiLocalNetworkRecord, + ) -> Result<tera_core::runtime::product_surface::LocalNetwork, RadrootsAppError> { + let profile = self.inner.sdk_relay_status()?.ok_or_else(|| { + RadrootsAppError::failure( + "relay_profile_unavailable", + "relay", + true, + &["configure_relay"], + "The relay profile is unavailable.", + ) + })?; + let relay_policy = match profile.profile.as_str() { + "public" => LocalNetworkRelayPolicy::Public, + "simulator_local" => LocalNetworkRelayPolicy::Simulator, + "device_development" => LocalNetworkRelayPolicy::Device, + _ => { + return Err(RadrootsAppError::failure( + "relay_profile_unsupported", + "relay", + false, + &["configure_relay"], + "The relay profile is unsupported.", + )); + } + }; + context.try_into_with_relay_policy(relay_policy) + } +} + +async fn build_runtime( + application_support_directory: String, + public_key_hex: String, + source_generation_hex: String, + source_generation_created_at_unix_ms: u64, + protected_data: ProtectedDataAvailability, + host_signer: Option<Box<dyn RadrootsHostSigner>>, +) -> Result<RadrootsRuntime, RadrootsAppError> { + let store = tera_core::runtime::store::MobileUserStoreConfig::from_encoded( + application_support_directory, + public_key_hex.as_str(), + source_generation_hex.as_str(), + source_generation_created_at_unix_ms, + protected_data.into(), + )?; + let has_host_signer = host_signer.is_some(); + let mut builder = tera_core::runtime::builder::RuntimeBuilder::new(store); + if let Some(host_signer) = host_signer { + builder = builder.signer(Arc::new(HostSignerAdapter::new(host_signer))); + } + builder + .build() + .await + .map(|inner| RadrootsRuntime { + inner, + has_host_signer, + subscriptions: SubscriptionHub::new(), + }) + .map_err(Into::into) +} diff --git a/core/crates/tera_ffi/src/signer.rs b/core/crates/tera_ffi/src/signer.rs @@ -0,0 +1,404 @@ +//! Opaque, secret-free host signing bridge. + +use std::sync::Arc; + +use radroots_event::{SignedEvent, wire::v1::Nip01EventWire}; +use radroots_signing::{ + Error, SignReceipt, SignRequest, Signer, SignerStatus, + capability::{CancellationSupport, SignerCapability, SignerKind}, + error::Kind, + recovery::ReplayCapability, + signer::BoxFuture, + status::SignerAvailability, +}; + +use crate::MOBILE_FFI_SCHEMA_VERSION; + +const SIGNED_EVENT_MAX_BYTES: usize = 1_048_576; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum SignerAvailabilityRecord { + Ready, + Busy, + Locked, + Unavailable, +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct SignerStatusRecord { + pub schema_version: u16, + pub availability: SignerAvailabilityRecord, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum HostSigningPurpose { + NostrEvent, + BlossomUpload, +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct HostSigningRequest { + pub schema_version: u16, + pub operation_kind: String, + pub operation_id: String, + pub artifact_id: String, + pub signer_request_id: String, + pub public_key: String, + pub purpose: HostSigningPurpose, + pub deadline_unix_ms: u64, + pub event_id_digest: Vec<u8>, + pub expected_event_id: String, + pub created_at_unix_s: u64, + pub kind: u32, + pub tags: Vec<Vec<String>>, + pub content: String, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum HostSigningOutcome { + Signed, + Locked, + Cancelled, + Rejected, + TimedOut, + Unavailable, + Invalidated, + Failed, +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct HostSigningResult { + pub schema_version: u16, + pub outcome: HostSigningOutcome, + pub operation_id: String, + pub signer_request_id: String, + pub public_key: String, + pub purpose: HostSigningPurpose, + pub signature_hex: Option<String>, + pub completed_at_unix_ms: u64, +} + +#[uniffi::export(callback_interface)] +#[async_trait::async_trait] +pub trait RadrootsHostSigner: Send + Sync { + async fn signer_status(&self) -> SignerStatusRecord; + async fn sign(&self, request: HostSigningRequest) -> HostSigningResult; +} + +pub(crate) struct HostSignerAdapter { + host: Arc<dyn RadrootsHostSigner>, +} + +impl HostSignerAdapter { + pub(crate) fn new(host: Box<dyn RadrootsHostSigner>) -> Self { + Self { + host: Arc::from(host), + } + } +} + +impl Signer for HostSignerAdapter { + fn status(&self) -> BoxFuture<'_, Result<SignerStatus, Error>> { + Box::pin(async move { + let status = self.host.signer_status().await; + if status.schema_version != MOBILE_FFI_SCHEMA_VERSION { + return Err(Error::new(Kind::SignerOutputInvalid)); + } + let availability = match status.availability { + SignerAvailabilityRecord::Ready => SignerAvailability::Ready, + SignerAvailabilityRecord::Busy => SignerAvailability::Busy, + SignerAvailabilityRecord::Locked => SignerAvailability::AwaitingAuthentication, + SignerAvailabilityRecord::Unavailable => SignerAvailability::Unavailable, + }; + Ok(SignerStatus::new( + availability, + vec![SignerCapability::new( + SignerKind::HostMediated, + ReplayCapability::ExactReplayByRequestId, + CancellationSupport::BeforeAndAfterPublication, + false, + true, + )], + None, + )) + }) + } + + fn sign(&self, request: SignRequest) -> BoxFuture<'_, Result<SignReceipt, Error>> { + Box::pin(async move { + request.ensure_active(now_unix_ms())?; + let ffi_request = HostSigningRequest::from_request(&request)?; + let result = self.host.sign(ffi_request.clone()).await; + request.ensure_active(now_unix_ms())?; + request.ensure_active(result.completed_at_unix_ms)?; + validate_result_binding(&ffi_request, &result)?; + match result.outcome { + HostSigningOutcome::Signed => signed_receipt(&request, result), + HostSigningOutcome::Locked | HostSigningOutcome::Unavailable => { + Err(Error::new(Kind::SignerUnavailable)) + } + HostSigningOutcome::Cancelled => Err(Error::new(Kind::SignerCancelled)), + HostSigningOutcome::Rejected => Err(Error::new(Kind::SignerRejected)), + HostSigningOutcome::TimedOut => Err(Error::new(Kind::SignerTimeout)), + HostSigningOutcome::Invalidated => Err(Error::new(Kind::SignerOutputInvalid)), + HostSigningOutcome::Failed => Err(Error::new(Kind::InternalError)), + } + }) + } +} + +impl HostSigningRequest { + fn from_request(request: &SignRequest) -> Result<Self, Error> { + let purpose = match request.purpose() { + radroots_signing::SigningPurpose::AuthoredEvent => HostSigningPurpose::NostrEvent, + radroots_signing::SigningPurpose::BlossomUploadAuthorization => { + HostSigningPurpose::BlossomUpload + } + _ => return Err(Error::new(Kind::SignerOutputInvalid)), + }; + Ok(Self { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + operation_kind: request.operation_kind().as_str().to_owned(), + operation_id: uuid_string(request.intent_id().operation_id().as_bytes()), + artifact_id: hex::encode(request.intent_id().artifact_id().as_bytes()), + signer_request_id: request.signer_request_id().to_hex(), + public_key: request.expected_author().to_hex(), + purpose, + deadline_unix_ms: request.policy().deadline_unix_ms(), + event_id_digest: request.expected_event_id().as_bytes().to_vec(), + expected_event_id: request.expected_event_id().to_hex(), + created_at_unix_s: request.created_at(), + kind: request.kind(), + tags: request.tags().to_vec(), + content: request.content().to_owned(), + }) + } +} + +fn validate_result_binding( + request: &HostSigningRequest, + result: &HostSigningResult, +) -> Result<(), Error> { + if result.schema_version != MOBILE_FFI_SCHEMA_VERSION + || result.operation_id != request.operation_id + || result.signer_request_id != request.signer_request_id + || result.public_key != request.public_key + || result.purpose != request.purpose + || result.completed_at_unix_ms == 0 + || (result.outcome == HostSigningOutcome::Signed) != result.signature_hex.is_some() + { + return Err(Error::new(Kind::SignerOutputInvalid)); + } + Ok(()) +} + +fn signed_receipt(request: &SignRequest, result: HostSigningResult) -> Result<SignReceipt, Error> { + let signature = result + .signature_hex + .filter(|value| { + value.len() == 128 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + }) + .ok_or_else(|| Error::new(Kind::SignerOutputInvalid))?; + let wire = Nip01EventWire { + id: request.expected_event_id().to_hex(), + pubkey: request.expected_author().to_hex(), + created_at: request.created_at(), + kind: request.kind(), + tags: request.tags().to_vec(), + content: request.content().to_owned(), + sig: signature, + extra: Default::default(), + }; + let raw_json = serde_json::to_string(&wire).map_err(|_| Error::new(Kind::InternalError))?; + if raw_json.len() > SIGNED_EVENT_MAX_BYTES { + return Err(Error::new(Kind::SignerOutputInvalid)); + } + let signed = SignedEvent::from_wire_verified_id(wire, raw_json) + .map_err(|_| Error::new(Kind::SignerOutputInvalid))?; + SignReceipt::from_signed_event(request, signed, result.completed_at_unix_ms) +} + +fn uuid_string(bytes: &[u8; 16]) -> String { + let hex = hex::encode(bytes); + format!( + "{}-{}-{}-{}-{}", + &hex[0..8], + &hex[8..12], + &hex[12..16], + &hex[16..20], + &hex[20..32] + ) +} + +fn now_unix_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |duration| { + duration.as_millis().try_into().unwrap_or(u64::MAX) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + struct StatusHost { + schema_version: u16, + availability: SignerAvailabilityRecord, + } + + #[async_trait::async_trait] + impl RadrootsHostSigner for StatusHost { + async fn signer_status(&self) -> SignerStatusRecord { + SignerStatusRecord { + schema_version: self.schema_version, + availability: self.availability, + } + } + + async fn sign(&self, request: HostSigningRequest) -> HostSigningResult { + HostSigningResult { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + outcome: HostSigningOutcome::Failed, + operation_id: request.operation_id, + signer_request_id: request.signer_request_id, + public_key: request.public_key, + purpose: request.purpose, + signature_hex: None, + completed_at_unix_ms: 1, + } + } + } + + #[test] + fn opaque_operation_identity_has_canonical_uuid_shape() { + assert_eq!( + uuid_string(&[0xabu8; 16]), + "abababab-abab-abab-abab-abababababab" + ); + } + + #[test] + fn result_binding_rejects_signature_on_failure() { + let request = HostSigningRequest { + schema_version: 1, + operation_kind: "sync.push".to_owned(), + operation_id: "11111111-1111-1111-1111-111111111111".to_owned(), + artifact_id: "22".repeat(16), + signer_request_id: "33".repeat(32), + public_key: "44".repeat(32), + purpose: HostSigningPurpose::NostrEvent, + deadline_unix_ms: 10, + event_id_digest: vec![5; 32], + expected_event_id: "55".repeat(32), + created_at_unix_s: 1, + kind: 1, + tags: Vec::new(), + content: "test".to_owned(), + }; + let result = HostSigningResult { + schema_version: 1, + outcome: HostSigningOutcome::Failed, + operation_id: request.operation_id.clone(), + signer_request_id: request.signer_request_id.clone(), + public_key: request.public_key.clone(), + purpose: request.purpose, + signature_hex: Some("66".repeat(64)), + completed_at_unix_ms: 2, + }; + assert_eq!( + validate_result_binding(&request, &result) + .expect_err("failure cannot carry signature") + .kind(), + Kind::SignerOutputInvalid + ); + + let valid = HostSigningResult { + signature_hex: None, + ..result.clone() + }; + assert!(validate_result_binding(&request, &valid).is_ok()); + let invalid_results = [ + HostSigningResult { + schema_version: 2, + ..valid.clone() + }, + HostSigningResult { + operation_id: "different".to_owned(), + ..valid.clone() + }, + HostSigningResult { + signer_request_id: "different".to_owned(), + ..valid.clone() + }, + HostSigningResult { + public_key: "different".to_owned(), + ..valid.clone() + }, + HostSigningResult { + purpose: HostSigningPurpose::BlossomUpload, + ..valid.clone() + }, + HostSigningResult { + completed_at_unix_ms: 0, + ..valid.clone() + }, + HostSigningResult { + outcome: HostSigningOutcome::Signed, + signature_hex: None, + ..valid + }, + ]; + for invalid in invalid_results { + assert_eq!( + validate_result_binding(&request, &invalid) + .expect_err("binding mismatch") + .kind(), + Kind::SignerOutputInvalid + ); + } + } + + #[tokio::test] + async fn host_status_maps_every_availability_and_rejects_schema_drift() { + for (ffi, expected) in [ + (SignerAvailabilityRecord::Ready, SignerAvailability::Ready), + (SignerAvailabilityRecord::Busy, SignerAvailability::Busy), + ( + SignerAvailabilityRecord::Locked, + SignerAvailability::AwaitingAuthentication, + ), + ( + SignerAvailabilityRecord::Unavailable, + SignerAvailability::Unavailable, + ), + ] { + let adapter = HostSignerAdapter::new(Box::new(StatusHost { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + availability: ffi, + })); + assert_eq!( + Signer::status(&adapter) + .await + .expect("mapped host status") + .availability(), + expected + ); + } + + let adapter = HostSignerAdapter::new(Box::new(StatusHost { + schema_version: MOBILE_FFI_SCHEMA_VERSION + 1, + availability: SignerAvailabilityRecord::Ready, + })); + assert_eq!( + Signer::status(&adapter) + .await + .expect_err("schema drift") + .kind(), + Kind::SignerOutputInvalid + ); + } +} diff --git a/core/crates/tera_ffi/src/subscription.rs b/core/crates/tera_ffi/src/subscription.rs @@ -0,0 +1,321 @@ +//! Bounded, independent host subscriptions for focused runtime invalidation signals. + +use std::collections::BTreeMap; +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::mpsc::{SyncSender, TrySendError, sync_channel}; +use std::sync::{Arc, Mutex, Weak}; + +use crate::{MOBILE_FFI_SCHEMA_VERSION, RadrootsAppError}; + +const MAX_SUBSCRIPTIONS: usize = 32; +const CHANGE_BUFFER_CAPACITY: usize = 16; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum FfiRuntimeChangeKind { + Initial, + Identity, + Settings, + Profile, + Today, + Drafts, + Relay, + Media, + Lifecycle, +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiRuntimeChangeRecord { + pub schema_version: u16, + pub generation: u64, + pub kind: FfiRuntimeChangeKind, + pub entity_id: Option<String>, +} + +#[uniffi::export(callback_interface)] +pub trait RadrootsRuntimeObserver: Send + Sync { + fn on_change(&self, change: FfiRuntimeChangeRecord); +} + +pub(crate) struct SubscriptionHub { + next_id: AtomicU64, + generation: AtomicU64, + closed: AtomicBool, + subscriptions: Mutex<BTreeMap<u64, SyncSender<FfiRuntimeChangeRecord>>>, +} + +impl SubscriptionHub { + pub(crate) fn new() -> Arc<Self> { + Arc::new(Self { + next_id: AtomicU64::new(1), + generation: AtomicU64::new(1), + closed: AtomicBool::new(false), + subscriptions: Mutex::new(BTreeMap::new()), + }) + } + + pub(crate) fn subscribe( + self: &Arc<Self>, + observer: Box<dyn RadrootsRuntimeObserver>, + ) -> Result<Arc<FfiSubscriptionHandle>, RadrootsAppError> { + if self.closed.load(Ordering::Acquire) { + return Err(subscription_error("runtime_closed", false)); + } + let id = self.next_id.fetch_add(1, Ordering::AcqRel); + let (sender, receiver) = sync_channel(CHANGE_BUFFER_CAPACITY); + let observer: Arc<dyn RadrootsRuntimeObserver> = Arc::from(observer); + let hub = Arc::downgrade(self); + std::thread::Builder::new() + .name(format!("radroots-ffi-observer-{id}")) + .spawn(move || { + while let Ok(change) = receiver.recv() { + if catch_unwind(AssertUnwindSafe(|| observer.on_change(change))).is_err() { + break; + } + } + if let Some(hub) = hub.upgrade() { + hub.remove(id); + } + }) + .map_err(|_| subscription_error("subscription_worker_unavailable", true))?; + + { + let mut subscriptions = self + .subscriptions + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if self.closed.load(Ordering::Acquire) || subscriptions.len() >= MAX_SUBSCRIPTIONS { + return Err(subscription_error("subscription_limit_reached", true)); + } + subscriptions.insert(id, sender.clone()); + } + + let initial = FfiRuntimeChangeRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + generation: self.generation.load(Ordering::Acquire), + kind: FfiRuntimeChangeKind::Initial, + entity_id: None, + }; + let _ = sender.try_send(initial); + Ok(Arc::new(FfiSubscriptionHandle { + hub: Arc::downgrade(self), + id: Mutex::new(Some(id)), + })) + } + + pub(crate) fn notify(&self, kind: FfiRuntimeChangeKind, entity_id: Option<String>) { + if self.closed.load(Ordering::Acquire) { + return; + } + let change = FfiRuntimeChangeRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + generation: self.generation.fetch_add(1, Ordering::AcqRel) + 1, + kind, + entity_id, + }; + let senders = self + .subscriptions + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .iter() + .map(|(id, sender)| (*id, sender.clone())) + .collect::<Vec<_>>(); + let mut disconnected = Vec::new(); + for (id, sender) in senders { + match sender.try_send(change.clone()) { + Ok(()) | Err(TrySendError::Full(_)) => {} + Err(TrySendError::Disconnected(_)) => disconnected.push(id), + } + } + if !disconnected.is_empty() { + let mut subscriptions = self + .subscriptions + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + for id in disconnected { + subscriptions.remove(&id); + } + } + } + + pub(crate) fn close(&self) { + if !self.closed.swap(true, Ordering::AcqRel) { + self.generation.fetch_add(1, Ordering::AcqRel); + self.subscriptions + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clear(); + } + } + + fn remove(&self, id: u64) { + self.subscriptions + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&id); + } +} + +#[derive(uniffi::Object)] +pub struct FfiSubscriptionHandle { + hub: Weak<SubscriptionHub>, + id: Mutex<Option<u64>>, +} + +#[uniffi::export] +impl FfiSubscriptionHandle { + pub fn unsubscribe(&self) { + let id = self + .id + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take(); + if let (Some(hub), Some(id)) = (self.hub.upgrade(), id) { + hub.remove(id); + } + } + + pub fn is_active(&self) -> bool { + let id = *self + .id + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let (Some(hub), Some(id)) = (self.hub.upgrade(), id) else { + return false; + }; + !hub.closed.load(Ordering::Acquire) + && hub + .subscriptions + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .contains_key(&id) + } +} + +impl Drop for FfiSubscriptionHandle { + fn drop(&mut self) { + let id = self + .id + .get_mut() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take(); + if let (Some(hub), Some(id)) = (self.hub.upgrade(), id) { + hub.remove(id); + } + } +} + +fn subscription_error(code: &str, retryable: bool) -> RadrootsAppError { + RadrootsAppError::failure( + code, + "subscription", + retryable, + if retryable { &["retry"] } else { &[] }, + "The runtime change subscription is unavailable.", + ) +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Condvar}; + use std::time::{Duration, Instant}; + + use super::*; + + struct NoopObserver; + + impl RadrootsRuntimeObserver for NoopObserver { + fn on_change(&self, _change: FfiRuntimeChangeRecord) {} + } + + struct PanicObserver; + + impl RadrootsRuntimeObserver for PanicObserver { + fn on_change(&self, _change: FfiRuntimeChangeRecord) { + panic!("observer panic is isolated"); + } + } + + struct BlockingObserver(Arc<(Mutex<bool>, Condvar)>); + + impl RadrootsRuntimeObserver for BlockingObserver { + fn on_change(&self, change: FfiRuntimeChangeRecord) { + if change.kind == FfiRuntimeChangeKind::Initial { + let (released, wake) = &*self.0; + let guard = released + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _guard = wake + .wait_while(guard, |released| !*released) + .unwrap_or_else(std::sync::PoisonError::into_inner); + } + } + } + + #[test] + fn closed_limit_and_detached_handle_paths_are_typed_and_idempotent() { + let closed = SubscriptionHub::new(); + closed.close(); + closed.close(); + closed.notify(FfiRuntimeChangeKind::Today, None); + let error = closed + .subscribe(Box::new(NoopObserver)) + .err() + .expect("closed hub"); + assert_eq!(error.report().code, "runtime_closed"); + assert!(!error.report().retryable); + + let hub = SubscriptionHub::new(); + let handles = (0..MAX_SUBSCRIPTIONS) + .map(|_| hub.subscribe(Box::new(NoopObserver)).expect("subscription")) + .collect::<Vec<_>>(); + let error = hub + .subscribe(Box::new(NoopObserver)) + .err() + .expect("bounded subscription limit"); + assert_eq!(error.report().code, "subscription_limit_reached"); + assert!(error.report().retryable); + drop(handles); + + let detached_hub = SubscriptionHub::new(); + let detached = detached_hub + .subscribe(Box::new(NoopObserver)) + .expect("detached subscription"); + drop(detached_hub); + assert!(!detached.is_active()); + detached.unsubscribe(); + detached.unsubscribe(); + } + + #[test] + fn callback_panics_and_full_buffers_never_escape_or_block_publishers() { + let hub = SubscriptionHub::new(); + let panicking = hub + .subscribe(Box::new(PanicObserver)) + .expect("panicking subscription"); + let deadline = Instant::now() + Duration::from_secs(1); + while panicking.is_active() && Instant::now() < deadline { + std::thread::yield_now(); + } + assert!(!panicking.is_active()); + + let release = Arc::new((Mutex::new(false), Condvar::new())); + let blocked = hub + .subscribe(Box::new(BlockingObserver(Arc::clone(&release)))) + .expect("blocked subscription"); + for generation in 0..=CHANGE_BUFFER_CAPACITY { + hub.notify( + FfiRuntimeChangeKind::Drafts, + Some(format!("draft-{generation}")), + ); + } + assert!(blocked.is_active()); + let (released, wake) = &*release; + *released + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = true; + wake.notify_all(); + hub.close(); + assert!(!blocked.is_active()); + } +} diff --git a/core/crates/tera_ffi/tests/compatibility.rs b/core/crates/tera_ffi/tests/compatibility.rs @@ -0,0 +1,201 @@ +//! Frozen compatibility across application FFI history transfer. All stores are +//! isolated fixtures; signer keys are generated in memory and never serialized. + +use secp256k1::{Keypair, Message, Secp256k1}; +use serde_json::Value; +use std::path::Path; +use tera_ffi::{ + FfiAddCommandType, FfiAddDraftInput, FfiCancellationPolicy, FfiDraftStatusRecord, + FfiQueuePolicyRecord, FfiRelaySatisfaction, HostSigningOutcome, HostSigningRequest, + HostSigningResult, MOBILE_FFI_SCHEMA_VERSION, ProtectedDataAvailability, RadrootsHostSigner, + RadrootsRuntime, SignerAvailabilityRecord, SignerStatusRecord, +}; + +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/core/crates/tera_ffi/tests/local_mvp_real_io.rs b/core/crates/tera_ffi/tests/local_mvp_real_io.rs @@ -0,0 +1,964 @@ +use std::io::Write; +use std::os::fd::AsRawFd; +use std::sync::Arc; +use std::time::Duration; + +use nostr::{EventBuilder, Keys, Kind, Metadata, Tag, Timestamp}; +use nostr_relay_builder::MockRelay; +use nostr_sdk::Client; +use radroots_blossom::Sha256; +use secp256k1::{Keypair, Message, Secp256k1, SecretKey}; +use tera_ffi::{ + FfiAddCommandType, FfiAddDraftInput, FfiBlossomEndpointAuthority, FfiBlossomHostKind, + FfiBlossomUploadInput, FfiCancellationPolicy, FfiEventTimingKind, FfiLocalNetworkRecord, + FfiMediaOperation, FfiMediaStage, FfiOutboxState, FfiPreparedMediaInput, FfiQueuePolicyRecord, + FfiRelaySatisfaction, FfiRetractionDraftInput, FfiTodayCardType, FfiTodayProjectionUpdate, + FfiTodayRelaySyncState, HostSigningOutcome, HostSigningRequest, HostSigningResult, + MOBILE_FFI_SCHEMA_VERSION, ProtectedDataAvailability, RadrootsHostSigner, RadrootsRuntime, + SignerAvailabilityRecord, SignerStatusRecord, +}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; + +#[allow(dead_code)] +mod support; + +const AUTHORED_AT: u64 = 1_786_000_000; +const AS_OF: u64 = 1_786_200_000; +const FIXTURE_SECRET: &str = "0000000000000000000000000000000000000000000000000000000000000001"; +const REPLY_SECRET: &str = "0000000000000000000000000000000000000000000000000000000000000002"; + +struct FixtureHostSigner; + +#[async_trait::async_trait] +impl RadrootsHostSigner for FixtureHostSigner { + async fn signer_status(&self) -> SignerStatusRecord { + SignerStatusRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + availability: SignerAvailabilityRecord::Ready, + } + } + + async fn sign(&self, request: HostSigningRequest) -> HostSigningResult { + let secret = SecretKey::from_slice(&hex::decode(FIXTURE_SECRET).expect("fixture secret")) + .expect("valid fixture secret"); + let keypair = Keypair::from_secret_key(&Secp256k1::new(), &secret); + let digest: [u8; 32] = request + .event_id_digest + .clone() + .try_into() + .expect("32-byte event digest"); + assert_eq!(hex::encode(digest), request.expected_event_id); + assert_eq!( + keypair.x_only_public_key().0.to_string(), + request.public_key + ); + let signature = Secp256k1::new() + .sign_schnorr_no_aux_rand(&Message::from_digest(digest), &keypair) + .to_string(); + 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), + completed_at_unix_ms: unix_time_ms(), + } + } +} + +struct BlossomServer { + origin: String, + task: tokio::task::JoinHandle<()>, +} + +impl BlossomServer { + async fn spawn(bytes: Vec<u8>, corrupt_retrieval: bool) -> Self { + Self::spawn_with_retrievals(bytes, corrupt_retrieval, 1).await + } + + async fn spawn_with_retrievals( + bytes: Vec<u8>, + corrupt_retrieval: bool, + retrievals: usize, + ) -> Self { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind Blossom server"); + let origin = format!("http://{}", listener.local_addr().expect("Blossom address")); + let hash = Sha256::digest(bytes.as_slice()).to_string(); + let blob_url = format!("{origin}/{hash}.png"); + let descriptor = serde_json::to_vec(&serde_json::json!({ + "url": blob_url, + "sha256": hash, + "size": bytes.len(), + "type": "image/png", + "uploaded": AUTHORED_AT, + })) + .expect("descriptor JSON"); + let task = tokio::spawn(async move { + let (mut upload, _) = listener.accept().await.expect("accept Blossom upload"); + let request = read_http_request(&mut upload).await; + let request_text = String::from_utf8_lossy(&request); + assert!(request_text.starts_with("PUT /upload HTTP/1.1\r\n")); + assert!( + request_text + .to_ascii_lowercase() + .contains("authorization: nostr ") + ); + assert!( + request_text + .to_ascii_lowercase() + .contains(format!("x-sha-256: {hash}").as_str()) + ); + write_http_response(&mut upload, "application/json", &descriptor).await; + + for _ in 0..retrievals { + let (mut retrieval, _) = listener.accept().await.expect("accept Blossom retrieval"); + let request = read_http_request(&mut retrieval).await; + assert!( + String::from_utf8_lossy(&request) + .starts_with(format!("GET /{hash}.png HTTP/1.1\r\n").as_str()) + ); + let mut response_bytes = bytes.clone(); + if corrupt_retrieval { + response_bytes[0] ^= 1; + } + write_http_response(&mut retrieval, "image/png", &response_bytes).await; + } + }); + Self { origin, task } + } + + async fn finish(self) { + self.task.await.expect("Blossom server task"); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn public_runtime_completes_the_local_mvp_against_real_protocol_services() { + let relay = MockRelay::run().await.expect("local NIP-01 relay"); + let relay_url = relay.url().await.to_string(); + let image_bytes = png(2, 3); + let blossom = BlossomServer::spawn_with_retrievals(image_bytes.clone(), false, 2).await; + let mut image_file = tempfile::tempfile().expect("media file"); + image_file.write_all(&image_bytes).expect("write media"); + let media = prepared_media(&blossom.origin, &image_bytes, &image_file, "Harvest photo"); + + let publisher_root = tempfile::tempdir().expect("publisher root"); + support::prepare(publisher_root.path()); + let publisher = runtime_with_signer(publisher_root.path()).await; + configure_simulator(&publisher, &relay_url, &blossom.origin); + let context = local_network(&relay_url); + + let update_id = draft_id(1); + let update = publisher + .phase1_save_draft( + update_id.clone(), + add_input( + FfiAddCommandType::CreateUpdate, + "Equal-time harvest update", + None, + ), + AUTHORED_AT, + None, + 1_800_000_000_000, + ) + .await + .expect("save offline update"); + let queued_update = queue( + &publisher, + &update_id, + update.revision, + &relay_url, + false, + 1_800_000_000_500, + ) + .await; + assert_eq!(queued_update.state, FfiOutboxState::Queued); + publisher + .shutdown() + .await + .expect("shutdown before delivery"); + + let publisher = runtime_with_signer(publisher_root.path()).await; + configure_simulator(&publisher, &relay_url, &blossom.origin); + let recovered = publisher + .phase1_recover_draft_queue(update_id.clone(), 1_800_000_001_000) + .await + .expect("recover queued update after restart"); + assert_eq!(recovered.state, FfiOutboxState::Queued); + advance_complete(&publisher, &update_id, recovered.revision).await; + + let flows = [ + ( + 2, + add_input( + FfiAddCommandType::CreatePhotoUpdate, + "Equal-time photo harvest", + Some(media.clone()), + ), + ), + ( + 3, + add_input( + FfiAddCommandType::CreateAsk, + "Equal-time ask: who has basil?", + None, + ), + ), + (4, event_input("Equal-time Saturday market")), + (5, food_input("Equal-time carrots", "today-carrots")), + ]; + for (index, input) in flows { + let id = draft_id(index); + let saved = publisher + .phase1_save_draft( + id.clone(), + input, + AUTHORED_AT, + None, + 1_800_000_000_000 + u64::from(index), + ) + .await + .expect("save authored flow"); + let ready = if index == 2 { + let uploaded = publisher + .phase1_upload_draft_media(FfiBlossomUploadInput { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + draft_id: id.clone(), + expected_revision: saved.revision, + media: media.clone(), + authorization_content: "Upload the exact local harvest image".to_owned(), + authorization_created_at_unix_s: AUTHORED_AT, + authorization_lifetime_seconds: 300, + operation_id: "21".repeat(16), + artifact_id: "22".repeat(16), + signing_deadline_unix_ms: u64::MAX, + signing_cancellation: FfiCancellationPolicy::LocalCooperative, + verified_at_unix_ms: 1_800_000_000_100, + updated_at_unix_ms: 1_800_000_000_200, + }) + .await + .expect("upload and re-fetch exact media"); + assert_eq!(uploaded.media[0].stage, FfiMediaStage::Verified); + assert_eq!(uploaded.state, FfiOutboxState::MediaPreparing); + let evidence = publisher + .sdk_blossom_evidence() + .expect("Blossom evidence") + .expect("configured evidence"); + assert_eq!(evidence.state, "retrieval_verified"); + assert_eq!(evidence.last_successful_state, "retrieval_verified"); + assert!(evidence.error_code.is_none()); + uploaded + } else { + saved + }; + let queued = queue( + &publisher, + &id, + ready.revision, + &relay_url, + false, + 1_800_000_000_500 + u64::from(index), + ) + .await; + advance_complete(&publisher, &id, queued.revision).await; + } + let reader_root = tempfile::tempdir().expect("fresh reader root"); + support::prepare(reader_root.path()); + let reader = RadrootsRuntime::new( + reader_root.path().to_string_lossy().into_owned(), + support::PUBLIC_KEY.to_owned(), + support::GENERATION.to_owned(), + 1_800_000_000_000, + ProtectedDataAvailability::Available, + ) + .await + .expect("fresh reader runtime"); + reader + .configure_simulator_relays(vec![relay_url.clone()]) + .expect("reader relay profile"); + let first_sync = reader + .phase1_sync_today(context.clone(), AS_OF, FfiTodayProjectionUpdate::Rebuild) + .await + .expect("fresh relay re-read"); + assert_eq!(first_sync.relay_state, FfiTodayRelaySyncState::Complete); + assert_eq!(first_sync.events_admitted, 5); + + let cards = collect_pages(&reader, &context, 2, AS_OF).await; + assert_eq!(cards.len(), 5, "all equal-time cards survive frozen paging"); + let mut card_types = cards.iter().map(|card| card.card_type).collect::<Vec<_>>(); + card_types.sort_by_key(|card_type| match card_type { + FfiTodayCardType::Update => 0, + FfiTodayCardType::PhotoUpdate => 1, + FfiTodayCardType::Ask => 2, + FfiTodayCardType::Event => 3, + FfiTodayCardType::FoodAvailability => 4, + }); + assert_eq!( + card_types, + vec![ + FfiTodayCardType::Update, + FfiTodayCardType::PhotoUpdate, + FfiTodayCardType::Ask, + FfiTodayCardType::Event, + FfiTodayCardType::FoodAvailability, + ] + ); + let photo = cards + .iter() + .find(|card| card.card_type == FfiTodayCardType::PhotoUpdate) + .expect("photo card"); + assert_eq!(photo.media.len(), 1); + assert_eq!( + photo.media[0].sha256.as_deref(), + Some(media.sha256.as_str()) + ); + reader + .configure_blossom( + FfiBlossomHostKind::Simulator, + FfiBlossomEndpointAuthority::LoopbackDevelopment, + blossom.origin.clone(), + vec![], + ) + .expect("reader Blossom profile"); + let artifact = reader + .phase1_retrieve_media( + context.clone(), + photo.media[0].reference_fingerprint.clone(), + Arc::new(FfiMediaOperation::new().expect("media operation")), + ) + .await + .expect("retrieve projected media"); + assert_eq!(artifact.bytes, image_bytes); + assert_eq!( + artifact.byte_size, + u64::try_from(image_bytes.len()).unwrap() + ); + assert!( + reader + .phase1_verified_media_artifact(context.clone(), artifact.artifact_id.clone()) + .await + .expect("verify cached media") + .is_some() + ); + let media_cache = reader + .phase1_media_cache_status(context.clone()) + .await + .expect("media cache status"); + assert_eq!(media_cache.artifact_count, 1); + assert_eq!(media_cache.total_bytes, artifact.byte_size); + assert!(media_cache.configuration_fingerprint.is_some()); + assert!( + reader + .phase1_invalidate_media_artifact(context.clone(), artifact.artifact_id.clone()) + .await + .expect("invalidate cached media") + ); + assert!( + reader + .phase1_verified_media_artifact(context.clone(), artifact.artifact_id) + .await + .expect("verify invalidated media") + .is_none() + ); + blossom.finish().await; + let update = cards + .iter() + .find(|card| card.card_type == FfiTodayCardType::Update) + .expect("update card"); + let food = cards + .iter() + .find(|card| card.card_type == FfiTodayCardType::FoodAvailability) + .expect("food card"); + assert_eq!(food.food_summary.as_deref(), Some("Freshly harvested")); + assert_eq!(food.food_published_at_unix_s, Some(AUTHORED_AT)); + assert_eq!(food.food_status.as_deref(), Some("active")); + let update_event_id = update.source_event_id.clone(); + let update_card_id = update.card_id.clone(); + let food_event_id = food.source_event_id.clone(); + let food_card_id = food.card_id.clone(); + + publish_supporting_events(&relay_url, &update_event_id, &food_event_id).await; + reader + .phase1_sync_today( + context.clone(), + AS_OF, + FfiTodayProjectionUpdate::Incremental, + ) + .await + .expect("sync profile and thread events"); + let enriched = reader + .phase1_today_page(context.clone(), 20, Some(AS_OF), None) + .await + .expect("enriched Today"); + assert_eq!( + enriched + .items + .iter() + .find(|card| card.card_id == update_card_id) + .expect("reply root") + .thread + .len(), + 1 + ); + assert_eq!( + enriched + .items + .iter() + .find(|card| card.card_id == food_card_id) + .expect("comment root") + .thread + .len(), + 1 + ); + + let offline_port = unused_loopback_port().await; + let replacement_id = draft_id(6); + let mut replacement_input = food_input("Corrected carrots from Moss Farm", "today-carrots"); + replacement_input.food_published_at_unix_s = Some(AUTHORED_AT); + let replacement = publisher + .phase1_save_draft( + replacement_id.clone(), + replacement_input, + AUTHORED_AT + 10, + None, + 1_800_000_010_000, + ) + .await + .expect("save replacement"); + let replacement_queued = publisher + .phase1_queue_draft( + replacement_id.clone(), + replacement.revision, + FfiQueuePolicyRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + relay_urls: vec![relay_url.clone(), format!("ws://127.0.0.1:{offline_port}")], + satisfaction: FfiRelaySatisfaction::AllAccepted, + delivery_deadline_unix_ms: u64::MAX, + cancellation: FfiCancellationPolicy::LocalCooperative, + }, + 1_800_000_010_100, + ) + .await + .expect("queue replacement"); + let partial = publisher + .phase1_advance_draft(replacement_id, replacement_queued.revision) + .await + .expect("attempt partial replacement delivery"); + assert_eq!( + partial.state, + FfiOutboxState::PartiallyDelivered, + "partial delivery status: {partial:?}" + ); + let settlement = partial.settlement.expect("partial settlement"); + assert_eq!(settlement.delivery_satisfied, 0); + assert_eq!(settlement.delivery_exhausted, 1); + + reader + .phase1_sync_today( + context.clone(), + AS_OF, + FfiTodayProjectionUpdate::Incremental, + ) + .await + .expect("sync replacement"); + let replaced = reader + .phase1_today_page(context.clone(), 20, Some(AS_OF), None) + .await + .expect("replacement projection"); + let current_food = replaced + .items + .iter() + .find(|card| card.card_id == food_card_id) + .expect("current food head"); + assert_eq!(current_food.content, "Corrected carrots from Moss Farm"); + assert_ne!(current_food.source_event_id, food_event_id); + + let retraction_id = draft_id(7); + let retraction = publisher + .phase1_save_retraction_draft( + retraction_id.clone(), + FfiRetractionDraftInput { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + command_type: FfiAddCommandType::CreateUpdate, + target_card_id: update_card_id.clone(), + target_event_id: update_event_id, + target_kind: 1, + target_address: None, + reason: "Superseded local update".to_owned(), + }, + AUTHORED_AT + 20, + 1_800_000_020_000, + ) + .await + .expect("save deletion request"); + let retraction_queued = queue( + &publisher, + &retraction_id, + retraction.revision, + &relay_url, + false, + 1_800_000_020_100, + ) + .await; + advance_complete(&publisher, &retraction_id, retraction_queued.revision).await; + reader + .phase1_sync_today( + context.clone(), + AS_OF, + FfiTodayProjectionUpdate::Incremental, + ) + .await + .expect("sync deletion"); + let after_deletion = reader + .phase1_today_page(context.clone(), 20, Some(AS_OF), None) + .await + .expect("projection after deletion"); + assert_eq!(after_deletion.items.len(), 4); + assert!( + after_deletion + .items + .iter() + .all(|card| card.card_id != update_card_id) + ); + + let search = reader + .phase1_search(context.clone(), "moss farm".to_owned(), 20, AS_OF) + .await + .expect("search current projection"); + assert!(search.iter().any(|result| result.profile.is_some())); + assert!(search.iter().any(|result| { + result + .card + .as_ref() + .is_some_and(|card| card.content == "Corrected carrots from Moss Farm") + })); + let me = reader + .phase1_me(context.clone(), AS_OF) + .await + .expect("Me projection"); + assert_eq!( + me.profile + .as_ref() + .and_then(|profile| profile.display_name.as_deref()), + Some("Moss Farm") + ); + assert_eq!(me.cards.len(), 4); + + prove_corrupted_media_fails(&publisher, &image_bytes, &image_file).await; + + reader.shutdown().await.expect("reader shutdown"); + publisher.shutdown().await.expect("publisher shutdown"); + relay.shutdown(); +} + +async fn runtime_with_signer(root: &std::path::Path) -> RadrootsRuntime { + RadrootsRuntime::with_host_signer( + root.to_string_lossy().into_owned(), + support::PUBLIC_KEY.to_owned(), + support::GENERATION.to_owned(), + 1_800_000_000_000, + ProtectedDataAvailability::Available, + Box::new(FixtureHostSigner), + ) + .await + .expect("runtime with fixture host signer") +} + +fn configure_simulator(runtime: &RadrootsRuntime, relay_url: &str, blossom_origin: &str) { + runtime + .configure_simulator_relays(vec![relay_url.to_owned()]) + .expect("simulator relay profile"); + runtime + .configure_blossom( + FfiBlossomHostKind::Simulator, + FfiBlossomEndpointAuthority::LoopbackDevelopment, + blossom_origin.to_owned(), + vec![], + ) + .expect("simulator Blossom profile"); +} + +fn local_network(relay_url: &str) -> FfiLocalNetworkRecord { + FfiLocalNetworkRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + id: "local-mvp".to_owned(), + label: "Local MVP".to_owned(), + relay_urls: vec![relay_url.to_owned()], + locality: None, + followed_authors: Vec::new(), + generation: 1, + } +} + +fn add_input( + command_type: FfiAddCommandType, + content: &str, + media: Option<FfiPreparedMediaInput>, +) -> FfiAddDraftInput { + FfiAddDraftInput { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + command_type, + content: 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: media.into_iter().collect(), + } +} + +fn event_input(content: &str) -> FfiAddDraftInput { + FfiAddDraftInput { + identifier: Some("saturday-market".to_owned()), + title: Some("Saturday Market".to_owned()), + location: Some("Victoria".to_owned()), + event_timing: Some(FfiEventTimingKind::AllDay), + event_start_date: Some("2026-08-09".to_owned()), + ..add_input(FfiAddCommandType::CreateEvent, content, None) + } +} + +fn food_input(content: &str, identifier: &str) -> FfiAddDraftInput { + FfiAddDraftInput { + identifier: Some(identifier.to_owned()), + title: Some("Carrots".to_owned()), + summary: Some("Freshly harvested".to_owned()), + location: Some("Victoria".to_owned()), + price_amount: Some("4.5".to_owned()), + currency: Some("CAD".to_owned()), + unit: Some("kg".to_owned()), + quantity: Some("12".to_owned()), + food_status: Some("active".to_owned()), + ..add_input(FfiAddCommandType::CreateFoodAvailability, content, None) + } +} + +fn prepared_media( + _origin: &str, + bytes: &[u8], + file: &std::fs::File, + alt: &str, +) -> FfiPreparedMediaInput { + let hash = Sha256::digest(bytes).to_string(); + FfiPreparedMediaInput { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + opaque_reference: format!("media:{hash}"), + file_descriptor: u64::try_from(file.as_raw_fd()).expect("nonnegative media descriptor"), + sha256: hash, + media_type: "image/png".to_owned(), + byte_size: u64::try_from(bytes.len()).expect("media size"), + width: 2, + height: 3, + alt: alt.to_owned(), + prepared_at_unix_s: AUTHORED_AT, + } +} + +async fn queue( + runtime: &RadrootsRuntime, + draft_id: &str, + revision: u64, + relay_url: &str, + all: bool, + queued_at_unix_ms: u64, +) -> tera_ffi::FfiDraftStatusRecord { + runtime + .phase1_queue_draft( + draft_id.to_owned(), + revision, + FfiQueuePolicyRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + relay_urls: vec![relay_url.to_owned()], + satisfaction: if all { + FfiRelaySatisfaction::AllAccepted + } else { + FfiRelaySatisfaction::AnyAccepted + }, + delivery_deadline_unix_ms: u64::MAX, + cancellation: FfiCancellationPolicy::LocalCooperative, + }, + queued_at_unix_ms, + ) + .await + .expect("queue draft") +} + +async fn advance_complete(runtime: &RadrootsRuntime, draft_id: &str, revision: u64) { + let status = match runtime + .phase1_advance_draft(draft_id.to_owned(), revision) + .await + { + Ok(status) => status, + Err(error) => { + let durable = runtime + .phase1_draft_status(draft_id.to_owned()) + .await + .expect("durable failure status"); + panic!("advance failed: {error:?}; durable status: {durable:?}"); + } + }; + assert_eq!(status.state, FfiOutboxState::Complete); + let settlement = status.settlement.expect("complete settlement"); + assert_eq!(settlement.signed, 1); + assert_eq!(settlement.admitted, 1); + assert_eq!(settlement.delivery_satisfied, 1); +} + +async fn collect_pages( + runtime: &RadrootsRuntime, + context: &FfiLocalNetworkRecord, + limit: u16, + as_of: u64, +) -> Vec<tera_ffi::FfiTodayCardRecord> { + let first = runtime + .phase1_today_page(context.clone(), limit, Some(as_of), None) + .await + .expect("first Today page"); + let mut items = first.items; + let mut cursor = first.next_cursor; + while let Some(next) = cursor { + let page = runtime + .phase1_today_page(context.clone(), limit, None, Some(next)) + .await + .expect("continued Today page"); + items.extend(page.items); + cursor = page.next_cursor; + } + let mut unique = items.iter().map(|card| &card.card_id).collect::<Vec<_>>(); + unique.sort_unstable(); + unique.dedup(); + assert_eq!(unique.len(), items.len(), "frozen pages have no duplicates"); + items +} + +async fn publish_supporting_events(relay_url: &str, update_id: &str, food_id: &str) { + let profile_keys = Keys::parse(FIXTURE_SECRET).expect("profile keys"); + let profile_client = Client::new(profile_keys); + profile_client + .add_relay(relay_url) + .await + .expect("profile relay"); + profile_client.connect().await; + profile_client + .wait_for_connection(Duration::from_secs(2)) + .await; + profile_client + .send_event_builder( + EventBuilder::metadata( + &Metadata::new() + .name("moss") + .display_name("Moss Farm") + .about("Local harvests"), + ) + .custom_created_at(Timestamp::from_secs(AUTHORED_AT + 1)), + ) + .await + .expect("publish profile"); + profile_client.shutdown().await; + + let reply_keys = Keys::parse(REPLY_SECRET).expect("reply keys"); + let reply_author = reply_keys.public_key().to_string(); + let reply_client = Client::new(reply_keys); + reply_client + .add_relay(relay_url) + .await + .expect("reply relay"); + reply_client.connect().await; + reply_client + .wait_for_connection(Duration::from_secs(2)) + .await; + reply_client + .send_event_builder( + EventBuilder::text_note("The farm stand is open") + .tags([ + Tag::parse(["e", update_id, relay_url, "root"]).expect("reply root tag"), + Tag::parse(["p", support::PUBLIC_KEY]).expect("reply author tag"), + ]) + .custom_created_at(Timestamp::from_secs(AUTHORED_AT + 2)), + ) + .await + .expect("publish reply"); + reply_client + .send_event_builder( + EventBuilder::new(Kind::Custom(1_111), "Are these available Saturday?") + .tags([ + Tag::parse(["E", food_id, relay_url, support::PUBLIC_KEY]) + .expect("comment root event tag"), + Tag::parse(["K", "30402"]).expect("comment root kind tag"), + Tag::parse(["P", support::PUBLIC_KEY, relay_url]) + .expect("comment root author tag"), + Tag::parse(["e", food_id, relay_url, support::PUBLIC_KEY]) + .expect("comment parent event tag"), + Tag::parse(["k", "30402"]).expect("comment parent kind tag"), + Tag::parse(["p", support::PUBLIC_KEY, relay_url]) + .expect("comment parent author tag"), + Tag::parse(["p", reply_author.as_str()]).expect("self author context tag"), + ]) + .custom_created_at(Timestamp::from_secs(AUTHORED_AT + 3)), + ) + .await + .expect("publish comment"); + reply_client.shutdown().await; +} + +async fn prove_corrupted_media_fails( + runtime: &RadrootsRuntime, + bytes: &[u8], + image_file: &std::fs::File, +) { + let corrupt = BlossomServer::spawn(bytes.to_vec(), true).await; + runtime + .configure_blossom( + FfiBlossomHostKind::Simulator, + FfiBlossomEndpointAuthority::LoopbackDevelopment, + corrupt.origin.clone(), + vec![], + ) + .expect("corrupt test Blossom profile"); + let media = prepared_media( + &corrupt.origin, + bytes, + image_file, + "Corrupt retrieval proof", + ); + let id = draft_id(8); + let saved = runtime + .phase1_save_draft( + id.clone(), + add_input( + FfiAddCommandType::CreatePhotoUpdate, + "This image must fail closed", + Some(media.clone()), + ), + AUTHORED_AT + 30, + None, + 1_800_000_030_000, + ) + .await + .expect("save corrupt-media draft"); + let error = runtime + .phase1_upload_draft_media(FfiBlossomUploadInput { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + draft_id: id.clone(), + expected_revision: saved.revision, + media, + authorization_content: "Verify corrupt retrieval rejection".to_owned(), + authorization_created_at_unix_s: AUTHORED_AT, + authorization_lifetime_seconds: 300, + operation_id: "31".repeat(16), + artifact_id: "32".repeat(16), + signing_deadline_unix_ms: u64::MAX, + signing_cancellation: FfiCancellationPolicy::LocalCooperative, + verified_at_unix_ms: 1_800_000_030_100, + updated_at_unix_ms: 1_800_000_030_200, + }) + .await + .expect_err("corrupt media retrieval must fail"); + assert_eq!(error.report().code, "authoring_failed"); + let failed = runtime + .phase1_draft_status(id) + .await + .expect("durable corrupt-media status"); + assert_eq!(failed.media[0].stage, FfiMediaStage::Failed); + assert_eq!(failed.state, FfiOutboxState::MediaPreparing); + assert!(failed.media[0].possible_orphan); + let evidence = runtime + .sdk_blossom_evidence() + .expect("Blossom evidence") + .expect("configured evidence"); + assert_eq!(evidence.state, "terminal_failure"); + assert_eq!(evidence.last_successful_state, "upload_verified"); + assert_eq!( + evidence.error_code.as_deref(), + Some("blossom_response_hash_mismatch") + ); + assert_eq!(evidence.error_phase.as_deref(), Some("verification")); + assert!(evidence.possible_orphan); + corrupt.finish().await; +} + +async fn unused_loopback_port() -> u16 { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("reserve unused port"); + listener.local_addr().expect("unused address").port() +} + +async fn read_http_request(stream: &mut tokio::net::TcpStream) -> Vec<u8> { + let mut request = Vec::new(); + let header_end = loop { + let mut chunk = [0_u8; 1_024]; + let read = stream.read(&mut chunk).await.expect("read HTTP request"); + assert_ne!(read, 0, "HTTP request ended before headers"); + request.extend_from_slice(&chunk[..read]); + if let Some(index) = request.windows(4).position(|value| value == b"\r\n\r\n") { + break index + 4; + } + assert!(request.len() < 64 * 1_024, "HTTP headers are bounded"); + }; + let content_length = String::from_utf8_lossy(&request[..header_end]) + .lines() + .find_map(|line| { + line.to_ascii_lowercase() + .strip_prefix("content-length: ") + .and_then(|value| value.trim().parse::<usize>().ok()) + }) + .unwrap_or_default(); + while request.len() - header_end < content_length { + let mut chunk = [0_u8; 1_024]; + let read = stream.read(&mut chunk).await.expect("read HTTP body"); + assert_ne!(read, 0, "HTTP request ended before body"); + request.extend_from_slice(&chunk[..read]); + } + request +} + +async fn write_http_response(stream: &mut tokio::net::TcpStream, content_type: &str, body: &[u8]) { + let head = format!( + "HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + stream + .write_all(head.as_bytes()) + .await + .expect("write response head"); + stream.write_all(body).await.expect("write response body"); + stream.shutdown().await.expect("close HTTP response"); +} + +fn draft_id(index: u8) -> String { + format!("{index:02x}").repeat(16) +} + +fn png(width: u32, height: u32) -> Vec<u8> { + let mut bytes = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec(); + bytes.extend_from_slice(&width.to_be_bytes()); + bytes.extend_from_slice(&height.to_be_bytes()); + bytes +} + +fn unix_time_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock after epoch") + .as_millis() + .try_into() + .expect("current time fits u64") +} diff --git a/core/crates/tera_ffi/tests/logging_error.rs b/core/crates/tera_ffi/tests/logging_error.rs @@ -0,0 +1,13 @@ +use tera_ffi::RadrootsAppError; +use tera_ffi::logging; + +#[test] +fn init_logging_stdout_maps_global_subscriber_error() { + let _ = tracing_subscriber::fmt().try_init(); + let err = logging::init_logging_stdout(); + assert!(matches!( + err, + Err(RadrootsAppError::Failure { report }) + if report.code == "initialization_failed" + )); +} diff --git a/core/crates/tera_ffi/tests/runtime_delegation.rs b/core/crates/tera_ffi/tests/runtime_delegation.rs @@ -0,0 +1,850 @@ +use std::sync::Arc; + +use tera_ffi::{ + FfiAddCommandType, FfiAddDraftInput, FfiBlossomAuthorityPreference, + FfiBlossomEndpointAuthority, FfiBlossomHostKind, FfiBlossomPreferencesRecord, + FfiBlossomUploadIntent, FfiCancellationPolicy, FfiDraftKind, FfiEventTimingKind, + FfiIdentityCommandKind, FfiIdentityCommandRecord, FfiIdentityLockState, FfiLocalNetworkRecord, + FfiLocalStoragePolicyRecord, FfiMediaNetworkPolicyRecord, FfiMediaOperation, + FfiMobileNetworkEnvironment, FfiNativeUploadCompletionInput, FfiOutboxState, + FfiPreparedMediaInput, FfiProfileMetadataInputRecord, FfiQueuePolicyRecord, + FfiRelayAccessPreference, FfiRelayAccessRecord, FfiRelayPreferenceRecord, + FfiRelayPreferencesRecord, FfiRelaySatisfaction, FfiReplaceSettingsRecord, + FfiRetractionDraftInput, FfiRevisionInputRecord, FfiRevisionPhase, FfiTodayCardType, + FfiTodayProjectionUpdate, MOBILE_FFI_SCHEMA_VERSION, RadrootsAppError, +}; + +mod support; + +#[tokio::test] +async fn native_boundary_delegates_the_complete_core_surface() { + let (_root, runtime) = support::runtime().await; + assert!(runtime.uptime_millis() >= 0); + assert!(runtime.info_json().contains("sdk")); + runtime.set_app_info_platform( + Some("ios".to_owned()), + Some("org.radroots.app".to_owned()), + Some("0.1.0-alpha".to_owned()), + Some("1".to_owned()), + Some("0123456789abcdef0123456789abcdef01234567".to_owned()), + ); + assert_eq!( + runtime.info().app.platform.expect("platform").platform, + Some("ios".to_owned()) + ); + assert!(!runtime.sdk_capabilities().is_empty()); + assert_eq!( + runtime.sdk_storage_status().await.expect("storage").backend, + "sqlite" + ); + + let public = runtime + .sdk_relay_status() + .expect("relay status") + .expect("default public profile"); + assert_eq!(public.profile, "public"); + assert_eq!(public.state, "configured"); + assert_eq!(public.read_availability, "unavailable"); + assert_eq!(public.write_availability, "unavailable"); + assert_eq!(public.relays.len(), 1); + assert_eq!(public.relays[0].access, FfiRelayAccessRecord::ReadWrite); + assert_eq!(public.relays[0].read_state, "unobserved"); + assert_eq!(public.relays[0].write_state, "unobserved"); + + runtime + .configure_public_relays(vec!["wss://write.example".to_owned()]) + .expect("public relays"); + let public = runtime + .sdk_relay_status() + .expect("relay status") + .expect("public profile"); + assert_eq!(public.relays.len(), 1); + assert_eq!(public.relays[0].relay_url, "wss://write.example"); + assert_eq!(public.relays[0].access, FfiRelayAccessRecord::ReadWrite); + assert!( + runtime + .configure_public_relays(vec!["ws://127.0.0.1:7447".to_owned()]) + .is_err() + ); + + let settings = runtime.phase1_settings().await.expect("settings"); + runtime + .phase1_replace_settings(FfiReplaceSettingsRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + expected_revision: settings.revision, + relays: FfiRelayPreferencesRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + environment: FfiMobileNetworkEnvironment::Public, + endpoints: vec![ + FfiRelayPreferenceRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + url: "wss://read.example".to_owned(), + access: FfiRelayAccessPreference::ReadOnly, + }, + FfiRelayPreferenceRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + url: "wss://write.example".to_owned(), + access: FfiRelayAccessPreference::ReadWrite, + }, + ], + }, + blossom: FfiBlossomPreferencesRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + environment: FfiMobileNetworkEnvironment::Public, + authority: FfiBlossomAuthorityPreference::PublicWebPki, + primary_origin: "https://media.example".to_owned(), + fallback_origins: vec![], + }, + media_network: FfiMediaNetworkPolicyRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + allow_cellular_downloads: true, + allow_cellular_uploads: true, + allow_background_transfers: true, + }, + local_storage: FfiLocalStoragePolicyRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + media_cache_bytes: 256 * 1024 * 1024, + media_cache_artifacts: 1024, + }, + }) + .await + .expect("replace settings"); + runtime + .phase1_apply_settings_to_runtime() + .await + .expect("apply settings"); + let applied = runtime + .sdk_relay_status() + .expect("relay status") + .expect("applied settings profile"); + assert_eq!(applied.relays.len(), 2); + assert_eq!(applied.relays[0].access, FfiRelayAccessRecord::ReadOnly); + assert_eq!(applied.relays[1].access, FfiRelayAccessRecord::ReadWrite); + + runtime + .configure_simulator_relays(vec!["ws://127.0.0.1:7447".to_owned()]) + .expect("simulator relays"); + let simulator = runtime + .sdk_relay_status() + .expect("relay status") + .expect("simulator profile"); + assert_eq!(simulator.profile, "simulator_local"); + assert_eq!(simulator.relays.len(), 1); + assert_eq!(simulator.relays[0].access, FfiRelayAccessRecord::ReadWrite); + assert!( + runtime + .phase1_local_network(FfiLocalNetworkRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + id: "simulator".to_owned(), + label: "Simulator".to_owned(), + relay_urls: vec!["ws://127.0.0.1:7447".to_owned()], + locality: None, + followed_authors: vec![], + generation: 1, + }) + .is_ok() + ); + assert!( + runtime + .configure_simulator_relays(vec!["wss://relay.example".to_owned()]) + .is_err() + ); + + runtime + .configure_device_relays(vec!["wss://10.0.0.5:7447".to_owned()]) + .expect("device relays"); + let device = runtime + .sdk_relay_status() + .expect("relay status") + .expect("device profile"); + assert_eq!(device.profile, "device_development"); + assert_eq!(device.relays.len(), 1); + assert_eq!(device.relays[0].relay_url, "wss://10.0.0.5:7447"); + assert_eq!(device.relays[0].access, FfiRelayAccessRecord::ReadWrite); + assert!( + runtime + .configure_device_relays(vec!["wss://127.0.0.1:7447".to_owned()]) + .is_err() + ); + + runtime + .configure_blossom( + FfiBlossomHostKind::PhysicalDevice, + FfiBlossomEndpointAuthority::PublicWebPki, + "https://media.example".to_owned(), + vec!["https://fallback.example".to_owned()], + ) + .expect("public Blossom"); + let blossom = runtime + .sdk_blossom_configuration() + .expect("Blossom configuration") + .expect("configured Blossom"); + assert_eq!(blossom.host_kind, "physical_device"); + assert_eq!(blossom.endpoint_authority, "public_webpki"); + assert_eq!(blossom.primary_origin, "https://media.example"); + assert_eq!(blossom.fallback_origins, ["https://fallback.example"]); + assert_eq!(blossom.config_fingerprint.len(), 64); + let evidence = runtime + .sdk_blossom_evidence() + .expect("Blossom evidence") + .expect("configured evidence"); + assert_eq!(evidence.schema_version, 2); + assert_eq!(evidence.origin, "https://media.example"); + assert_eq!(evidence.config_fingerprint, blossom.config_fingerprint); + assert_eq!(evidence.state, "configured_unobserved"); + assert_eq!(evidence.transport_security, "public_webpki"); + assert!(evidence.observed_at_unix_ms.is_none()); + assert!(evidence.error_code.is_none()); + assert!(evidence.server_error_code.is_none()); + runtime + .configure_blossom( + FfiBlossomHostKind::Simulator, + FfiBlossomEndpointAuthority::LoopbackDevelopment, + "http://127.0.0.1:3100".to_owned(), + vec![], + ) + .expect("simulator Blossom"); + runtime + .configure_blossom( + FfiBlossomHostKind::Simulator, + FfiBlossomEndpointAuthority::LoopbackDevelopment, + "http://127.0.0.1:1".to_owned(), + vec![], + ) + .expect("unavailable simulator Blossom"); + assert!(runtime.probe_blossom().await.is_err()); + runtime + .configure_blossom( + FfiBlossomHostKind::PhysicalDevice, + FfiBlossomEndpointAuthority::PrivateNetworkDevelopment, + "https://10.0.0.5:3100".to_owned(), + vec![], + ) + .expect("device Blossom"); + + assert_eq!( + runtime + .phase1_card_add_parity() + .into_iter() + .map(|item| item.card_type) + .collect::<Vec<_>>(), + vec![ + FfiTodayCardType::Update, + FfiTodayCardType::PhotoUpdate, + FfiTodayCardType::Ask, + FfiTodayCardType::Event, + FfiTodayCardType::FoodAvailability, + ] + ); + assert_eq!( + runtime + .phase1_add_schemas() + .into_iter() + .map(|schema| schema.command_type) + .collect::<Vec<_>>(), + vec![ + FfiAddCommandType::CreateUpdate, + FfiAddCommandType::CreatePhotoUpdate, + FfiAddCommandType::CreateAsk, + FfiAddCommandType::CreateEvent, + FfiAddCommandType::CreateFoodAvailability, + ] + ); + let parity = runtime.phase1_card_add_parity(); + let schemas = runtime.phase1_add_schemas(); + assert_eq!(parity.len(), 5); + for (index, item) in parity.iter().enumerate() { + assert_eq!(item.command_type, schemas[index].command_type); + } + assert_eq!( + schemas[1] + .fields + .iter() + .find(|field| field.id == "media") + .and_then(|field| field.max_items), + Some(20) + ); + assert_eq!( + schemas[3] + .fields + .iter() + .find(|field| field.id == "media") + .and_then(|field| field.max_items), + Some(1) + ); + let local_network = runtime + .phase1_local_network(FfiLocalNetworkRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + id: "nearby".to_owned(), + label: "Near me".to_owned(), + relay_urls: vec!["ws://192.168.1.7:7447".to_owned()], + locality: Some("u10h".to_owned()), + followed_authors: vec!["a".repeat(64)], + generation: 1, + }) + .expect("valid local network"); + assert_eq!(local_network.id, "nearby"); + assert!( + runtime + .phase1_local_network(FfiLocalNetworkRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + id: "public-host".to_owned(), + label: "Public host".to_owned(), + relay_urls: vec!["wss://relay.example".to_owned()], + locality: None, + followed_authors: vec![], + generation: 1, + }) + .is_err() + ); + let refresh = runtime + .phase1_refresh_today( + local_network.clone(), + 1_800_000_001, + FfiTodayProjectionUpdate::Incremental, + ) + .await + .expect("empty Today refresh"); + assert_eq!(refresh.update, FfiTodayProjectionUpdate::Incremental); + assert_eq!(refresh.source_events, 0); + assert_eq!(refresh.visible_cards, 0); + assert!(refresh.content_generation > 0); + let today = runtime + .phase1_today_page(local_network.clone(), 20, Some(1_800_000_001), None) + .await + .expect("empty Today page"); + assert!(today.items.is_empty()); + assert!(today.next_cursor.is_none()); + assert!( + runtime + .phase1_today_page(local_network.clone(), 20, None, None) + .await + .is_err() + ); + assert!( + runtime + .phase1_today_page( + local_network.clone(), + 20, + Some(1_800_000_001), + Some("opaque".to_owned()), + ) + .await + .is_err() + ); + assert!( + runtime + .phase1_search( + local_network.clone(), + "carrots".to_owned(), + 20, + 1_800_000_001, + ) + .await + .expect("empty search") + .is_empty() + ); + let me = runtime + .phase1_me(local_network.clone(), 1_800_000_001) + .await + .expect("Me snapshot"); + assert_eq!(me.public_key, support::PUBLIC_KEY); + + let add = FfiAddDraftInput { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + command_type: FfiAddCommandType::CreateUpdate, + content: "Farm stand opens at noon".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(), + }; + runtime + .phase1_validate_add_draft(add.clone(), 1_800_000_001) + .expect("valid draft"); + let saved = runtime + .phase1_save_add_intent(add, None, None) + .await + .expect("saved draft"); + let draft_id = saved.draft_id.clone(); + assert_eq!(draft_id.len(), 32); + assert_eq!(saved.state, FfiOutboxState::Draft); + assert_eq!(saved.kind, FfiDraftKind::Add); + assert_eq!( + saved.form.as_ref().map(|form| form.content.as_str()), + Some("Farm stand opens at noon") + ); + assert_eq!( + runtime + .phase1_draft_status(draft_id.clone()) + .await + .expect("draft status") + .revision, + saved.revision + ); + assert_eq!( + runtime + .phase1_draft_heads(10) + .await + .expect("draft heads") + .len(), + 1 + ); + let queued = runtime + .phase1_queue_add_intent(draft_id.clone(), saved.revision) + .await + .expect("queued draft"); + assert_eq!(queued.state, FfiOutboxState::Queued); + let recovered = runtime + .phase1_recover_add_intent(draft_id.clone()) + .await + .expect("recovered queue"); + assert_eq!(recovered.revision, queued.revision); + let cancelled = runtime + .phase1_cancel_add_intent(draft_id.clone(), recovered.revision) + .await + .expect("cancelled draft"); + assert_eq!(cancelled.state, FfiOutboxState::Cancelled); + assert!( + runtime + .phase1_save_add_intent( + FfiAddDraftInput { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + command_type: FfiAddCommandType::CreateEvent, + content: "Market opens Saturday".to_owned(), + identifier: None, + title: Some("Saturday market".to_owned()), + summary: Some("Weekly market".to_owned()), + location: Some("Town square".to_owned()), + event_timing: Some(FfiEventTimingKind::AllDay), + event_start_date: Some("2027-01-02".to_owned()), + 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(), + }, + None, + Some(1), + ) + .await + .is_err() + ); + assert!( + runtime + .phase1_advance_draft(draft_id.clone(), cancelled.revision) + .await + .is_err() + ); + + let retraction_id = "0a".repeat(16); + let retraction_input = FfiRetractionDraftInput { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + command_type: FfiAddCommandType::CreateUpdate, + target_card_id: "c".repeat(64), + target_event_id: "a".repeat(64), + target_kind: 1, + target_address: None, + reason: "Replaced with a corrected copy".to_owned(), + }; + let mut unsupported_retraction = retraction_input.clone(); + unsupported_retraction.schema_version += 1; + assert_eq!( + runtime + .phase1_save_retraction_draft( + retraction_id.clone(), + unsupported_retraction, + 1_800_000_005, + 1_800_000_005_000, + ) + .await + .expect_err("unsupported retraction schema") + .report() + .code, + "unsupported_schema_version" + ); + let retraction = runtime + .phase1_save_retraction_draft( + retraction_id.clone(), + retraction_input, + 1_800_000_005, + 1_800_000_005_000, + ) + .await + .expect("saved retraction"); + assert_eq!(retraction.kind, FfiDraftKind::Retraction); + assert_eq!(retraction.card_id, "c".repeat(64)); + assert!(retraction.form.is_none()); + let queued_retraction = runtime + .phase1_queue_draft( + retraction_id.clone(), + retraction.revision, + FfiQueuePolicyRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + relay_urls: vec!["wss://write.example".to_owned()], + satisfaction: FfiRelaySatisfaction::AllAccepted, + delivery_deadline_unix_ms: 1_800_100_000_000, + cancellation: FfiCancellationPolicy::LocalCooperative, + }, + 1_800_000_006_000, + ) + .await + .expect("queued retraction"); + let cancelled_retraction = runtime + .phase1_cancel_draft(retraction_id, queued_retraction.revision, 1_800_000_007_000) + .await + .expect("cancelled retraction"); + assert_eq!(cancelled_retraction.state, FfiOutboxState::Cancelled); + + let upload = FfiBlossomUploadIntent { + schema_version: MOBILE_FFI_SCHEMA_VERSION + 1, + draft_id, + expected_revision: cancelled.revision, + media: FfiPreparedMediaInput { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + opaque_reference: "media:unused".to_owned(), + file_descriptor: 0, + sha256: "00".repeat(32), + media_type: "image/png".to_owned(), + byte_size: 1, + width: 1, + height: 1, + alt: "unused".to_owned(), + prepared_at_unix_s: 1_800_000_000, + }, + }; + let upload_error = runtime + .phase1_upload_add_media_intent(upload.clone()) + .await + .expect_err("unsupported upload schema"); + assert_eq!(upload_error.report().code, "unsupported_schema_version"); + assert_eq!( + runtime + .phase1_prepare_add_media_background(upload.clone()) + .await + .expect_err("unsupported background upload schema") + .report() + .code, + "unsupported_schema_version" + ); + assert_eq!( + runtime + .phase1_complete_add_media_background(FfiNativeUploadCompletionInput { + schema_version: MOBILE_FFI_SCHEMA_VERSION + 1, + draft_id: upload.draft_id.clone(), + expected_revision: upload.expected_revision, + media: upload.media.clone(), + status_code: 200, + response_media_type: Some("application/json".to_owned()), + response_content_encoding: None, + response_body: Vec::new(), + }) + .await + .expect_err("unsupported completion schema") + .report() + .code, + "invalid_native_upload_completion" + ); + assert_eq!( + runtime + .phase1_complete_add_media_background(FfiNativeUploadCompletionInput { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + draft_id: upload.draft_id.clone(), + expected_revision: upload.expected_revision, + media: upload.media.clone(), + status_code: 200, + response_media_type: Some("application/json".to_owned()), + response_content_encoding: None, + response_body: vec![0; 16_385], + }) + .await + .expect_err("oversized completion body") + .report() + .code, + "invalid_native_upload_completion" + ); + let mut invalid_id_upload = upload; + invalid_id_upload.schema_version = MOBILE_FFI_SCHEMA_VERSION; + invalid_id_upload.draft_id = "not-a-draft-id".to_owned(); + assert_eq!( + runtime + .phase1_upload_add_media_intent(invalid_id_upload.clone()) + .await + .expect_err("invalid draft id") + .report() + .code, + "invalid_draft_id" + ); + assert_eq!( + runtime + .phase1_prepare_add_media_background(invalid_id_upload.clone()) + .await + .expect_err("invalid background draft id") + .report() + .code, + "invalid_draft_id" + ); + assert_eq!( + runtime + .phase1_complete_add_media_background(FfiNativeUploadCompletionInput { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + draft_id: invalid_id_upload.draft_id, + expected_revision: invalid_id_upload.expected_revision, + media: invalid_id_upload.media, + status_code: 200, + response_media_type: Some("application/json".to_owned()), + response_content_encoding: None, + response_body: Vec::new(), + }) + .await + .expect_err("invalid completion draft id") + .report() + .code, + "invalid_draft_id" + ); + assert!( + runtime + .phase1_local_network(FfiLocalNetworkRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + id: "nearby".to_owned(), + label: "Near me".to_owned(), + relay_urls: Vec::new(), + locality: None, + followed_authors: Vec::new(), + generation: 1, + }) + .is_err() + ); + + let initial_settings = runtime.phase1_settings().await.expect("settings"); + let begun = runtime + .phase1_apply_identity_command( + initial_settings.revision, + FfiIdentityCommandRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + kind: FfiIdentityCommandKind::BeginImport, + operation_id: Some("import-ffi-1".to_owned()), + identity_id: None, + public_key: None, + }, + ) + .await + .expect("begin identity import"); + let completed = runtime + .phase1_apply_identity_command( + begun.settings.revision, + FfiIdentityCommandRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + kind: FfiIdentityCommandKind::CompleteImport, + operation_id: Some("import-ffi-1".to_owned()), + identity_id: Some("primary".to_owned()), + public_key: Some(support::PUBLIC_KEY.to_owned()), + }, + ) + .await + .expect("complete identity import"); + let unlocked = runtime + .phase1_apply_identity_command( + completed.settings.revision, + FfiIdentityCommandRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + kind: FfiIdentityCommandKind::Unlock, + operation_id: None, + identity_id: None, + public_key: None, + }, + ) + .await + .expect("record process-local unlock"); + assert_eq!( + unlocked.settings.identity.lock_state, + FfiIdentityLockState::Unlocked + ); + assert_eq!(unlocked.settings.revision, completed.settings.revision); + + let source_event_id = "ab".repeat(32); + let source = tera_core::runtime::product_surface::CardSourceIdentity::Event( + radroots_event::EventId::parse(&source_event_id).expect("source event id"), + ); + let card_id = tera_core::runtime::product_surface::CardId::derive( + tera_core::runtime::product_surface::TodayCardType::Update, + &source, + ) + .to_hex(); + let revision = runtime + .phase1_save_revision_intent(FfiRevisionInputRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + card_id, + source_event_id, + source_address: None, + author_public_key: support::PUBLIC_KEY.to_owned(), + replacement: FfiAddDraftInput { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + command_type: FfiAddCommandType::CreateUpdate, + content: "Corrected farm stand hours".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(), + }, + }) + .await + .expect("save lossless revision intent"); + assert_eq!(revision.phase, FfiRevisionPhase::ReplacementPending); + assert_eq!(revision.operation_id, revision.replacement.draft_id); + assert!(revision.replacement.is_revision); + assert_eq!( + runtime + .phase1_revision_status(revision.operation_id.clone()) + .await + .expect("revision status") + .phase, + FfiRevisionPhase::ReplacementPending + ); + assert!( + runtime + .phase1_advance_revision(revision.operation_id.clone()) + .await + .is_err() + ); + let cancelled_revision = runtime + .phase1_cancel_revision(revision.operation_id.clone()) + .await + .expect("cancel revision intent"); + assert_eq!(cancelled_revision.operation_id, revision.operation_id); + assert_eq!(cancelled_revision.phase, FfiRevisionPhase::Cancelled); + + let profile = runtime + .phase1_save_profile_metadata(FfiProfileMetadataInputRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + name: "grower".to_owned(), + display_name: Some("Local Grower".to_owned()), + about: Some("Seasonal produce".to_owned()), + picture: None, + banner: None, + nip05: Some("grower@farm.example".to_owned()), + bot: Some(false), + }) + .await + .expect("save profile intent"); + assert_eq!(profile.state, FfiOutboxState::Draft); + assert_eq!(profile.operation_id.len(), 32); + assert_eq!( + runtime + .phase1_profile_status(profile.operation_id.clone()) + .await + .expect("profile status") + .revision, + profile.revision + ); + let cancelled_profile = runtime + .phase1_cancel_profile(profile.operation_id.clone(), profile.revision) + .await + .expect("cancel profile intent"); + assert_eq!(cancelled_profile.operation_id, profile.operation_id); + assert_eq!(cancelled_profile.state, FfiOutboxState::Cancelled); + assert_eq!( + runtime + .phase1_advance_profile(profile.operation_id.clone()) + .await + .expect("advance cancelled profile") + .state, + FfiOutboxState::Cancelled + ); + + let cache = runtime + .phase1_media_cache_status(local_network.clone()) + .await + .expect("empty media cache status"); + assert_eq!(cache.artifact_count, 0); + assert_eq!(cache.total_bytes, 0); + assert!(cache.configuration_fingerprint.is_none()); + assert!( + runtime + .phase1_verified_media_artifact(local_network.clone(), "11".repeat(32)) + .await + .expect("empty verified media lookup") + .is_none() + ); + assert!( + !runtime + .phase1_invalidate_media_artifact(local_network.clone(), "11".repeat(32)) + .await + .expect("missing media invalidation") + ); + assert!( + runtime + .phase1_invalidate_media_configuration(local_network.clone(), "22".repeat(32)) + .await + .expect("empty configuration invalidation") + .is_empty() + ); + let media_operation = Arc::new(FfiMediaOperation::new().expect("media operation")); + let media_error = runtime + .phase1_retrieve_media( + local_network.clone(), + "invalid-reference".to_owned(), + Arc::clone(&media_operation), + ) + .await + .expect_err("invalid media reference"); + assert_eq!( + media_error.report().code, + "invalid_media_reference_fingerprint" + ); + assert_eq!( + runtime + .phase1_retrieve_media(local_network, "00".repeat(32), Arc::clone(&media_operation),) + .await + .expect_err("single-use media operation") + .report() + .code, + "media_operation_already_used" + ); + + runtime.shutdown().await.expect("shutdown"); + assert!(matches!( + runtime.sdk_storage_status().await, + Err(RadrootsAppError::Failure { .. }) + )); + assert!(matches!( + runtime.sdk_relay_status(), + Err(RadrootsAppError::Failure { .. }) + )); + assert!(matches!( + runtime.configure_public_relays(Vec::new()), + Err(RadrootsAppError::Failure { .. }) + )); +} diff --git a/core/crates/tera_ffi/tests/runtime_lifecycle.rs b/core/crates/tera_ffi/tests/runtime_lifecycle.rs @@ -0,0 +1,44 @@ +use std::{sync::Arc, time::Duration}; + +use tera_ffi::RadrootsAppError; + +mod support; + +#[tokio::test] +async fn host_release_ordering_retains_close_and_finishes_within_deadline() { + let (_root, runtime) = support::runtime().await; + let host = Arc::new(runtime); + let closing_owner = Arc::clone(&host); + let close = tokio::spawn(async move { closing_owner.shutdown().await }); + drop(host); + + let result = tokio::time::timeout(Duration::from_secs(1), close) + .await + .expect("mobile shutdown exceeded its host deadline") + .expect("shutdown task panicked") + .expect("shutdown failed"); + assert_eq!(result.state, "closed"); + assert!(!result.already_closed); +} + +#[tokio::test] +async fn concurrent_host_references_converge_and_repeated_close_is_idempotent() { + let (_root, runtime) = support::runtime().await; + let runtime = Arc::new(runtime); + let first = Arc::clone(&runtime); + let second = Arc::clone(&runtime); + let (first, second) = tokio::join!(first.shutdown(), second.shutdown()); + + for outcome in [&first, &second] { + assert!( + outcome.is_ok() + || matches!( + outcome, + Err(RadrootsAppError::Failure { report }) + if report.code == "client_close_in_progress" + ) + ); + } + let repeated = runtime.shutdown().await.expect("repeated close"); + assert!(repeated.already_closed); +} diff --git a/core/crates/tera_ffi/tests/subscription_contract.rs b/core/crates/tera_ffi/tests/subscription_contract.rs @@ -0,0 +1,67 @@ +use std::sync::mpsc::{Receiver, Sender, channel}; +use std::time::Duration; + +use tera_ffi::{FfiRuntimeChangeKind, FfiRuntimeChangeRecord, RadrootsRuntimeObserver}; + +mod support; + +struct Observer(Sender<FfiRuntimeChangeRecord>); + +impl RadrootsRuntimeObserver for Observer { + fn on_change(&self, change: FfiRuntimeChangeRecord) { + let _ = self.0.send(change); + } +} + +fn observer() -> ( + Box<dyn RadrootsRuntimeObserver>, + Receiver<FfiRuntimeChangeRecord>, +) { + let (sender, receiver) = channel(); + (Box::new(Observer(sender)), receiver) +} + +fn receive(receiver: &Receiver<FfiRuntimeChangeRecord>) -> FfiRuntimeChangeRecord { + receiver + .recv_timeout(Duration::from_secs(1)) + .expect("bounded observer delivery") +} + +#[tokio::test] +async fn subscriptions_are_independent_bounded_handles_and_stop_individually() { + let (_root, runtime) = support::runtime().await; + let (first_observer, first_receiver) = observer(); + let (second_observer, second_receiver) = observer(); + let first = runtime + .subscribe_changes(first_observer) + .expect("first subscription"); + let second = runtime + .subscribe_changes(second_observer) + .expect("second subscription"); + + assert_eq!(receive(&first_receiver).kind, FfiRuntimeChangeKind::Initial); + assert_eq!( + receive(&second_receiver).kind, + FfiRuntimeChangeKind::Initial + ); + first.unsubscribe(); + assert!(!first.is_active()); + assert!(second.is_active()); + + runtime + .configure_public_relays(vec!["wss://write.example".to_owned()]) + .expect("relay configuration"); + assert_eq!(receive(&second_receiver).kind, FfiRuntimeChangeKind::Relay); + assert!( + first_receiver + .recv_timeout(Duration::from_millis(50)) + .is_err() + ); + + runtime.shutdown().await.expect("shutdown"); + assert_eq!( + receive(&second_receiver).kind, + FfiRuntimeChangeKind::Lifecycle + ); + assert!(!second.is_active()); +} diff --git a/core/crates/tera_ffi/tests/support/mod.rs b/core/crates/tera_ffi/tests/support/mod.rs @@ -0,0 +1,24 @@ +use tera_ffi::{ProtectedDataAvailability, RadrootsRuntime}; + +pub const PUBLIC_KEY: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; +pub const GENERATION: &str = "0404040404040404040404040404040404040404040404040404040404040404"; + +pub fn prepare(root: &std::path::Path) { + std::fs::create_dir_all(root.join("radroots").join("users").join(PUBLIC_KEY)) + .expect("owner directory"); +} + +pub async fn runtime() -> (tempfile::TempDir, RadrootsRuntime) { + let root = tempfile::tempdir().expect("tempdir"); + prepare(root.path()); + let runtime = RadrootsRuntime::new( + root.path().to_string_lossy().into_owned(), + PUBLIC_KEY.to_owned(), + GENERATION.to_owned(), + 1_800_000_000_000, + ProtectedDataAvailability::Available, + ) + .await + .expect("runtime"); + (root, runtime) +} diff --git a/core/crates/tera_ffi/tests/uniffi_contract.rs b/core/crates/tera_ffi/tests/uniffi_contract.rs @@ -0,0 +1,258 @@ +use secp256k1::{Keypair, Message, Secp256k1, SecretKey}; +use std::sync::{Arc, Mutex}; +use tera_ffi::{ + FfiAddCommandType, FfiAddDraftInput, FfiCancellationPolicy, FfiMediaOperation, + FfiQueuePolicyRecord, FfiRelaySatisfaction, FfiTradeEvidenceCoverage, FfiTradeEvidenceOutcome, + HostSigningOutcome, HostSigningRequest, HostSigningResult, MOBILE_FFI_SCHEMA_VERSION, + ProtectedDataAvailability, RadrootsAppError, RadrootsHostSigner, RadrootsRuntime, + SignerAvailabilityRecord, SignerStatusRecord, +}; + +mod support; + +struct TestHostSigner(Arc<Mutex<HostSigningOutcome>>); + +#[async_trait::async_trait] +impl RadrootsHostSigner for TestHostSigner { + async fn signer_status(&self) -> SignerStatusRecord { + SignerStatusRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + availability: SignerAvailabilityRecord::Ready, + } + } + + async fn sign(&self, request: HostSigningRequest) -> HostSigningResult { + let outcome = *self + .0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let signature_hex = (outcome == HostSigningOutcome::Signed).then(|| { + let mut secret_bytes = [0; 32]; + secret_bytes[31] = 1; + let secret = SecretKey::from_slice(&secret_bytes).expect("fixture secret key"); + let keypair = Keypair::from_secret_key(&Secp256k1::new(), &secret); + let digest: [u8; 32] = request + .event_id_digest + .clone() + .try_into() + .expect("32-byte event ID digest"); + assert_eq!(hex::encode(digest), request.expected_event_id); + assert_eq!( + keypair.x_only_public_key().0.to_string(), + request.public_key + ); + let message = Message::from_digest(digest); + let signature = Secp256k1::new().sign_schnorr_no_aux_rand(&message, &keypair); + Secp256k1::new() + .verify_schnorr(&signature, &message, &keypair.x_only_public_key().0) + .expect("fixture host signature verifies"); + signature.to_string() + }); + let completed_at_unix_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock after epoch") + .as_millis() + .try_into() + .expect("current time fits u64"); + assert!(completed_at_unix_ms < request.deadline_unix_ms); + HostSigningResult { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + outcome, + operation_id: request.operation_id, + signer_request_id: request.signer_request_id, + public_key: request.public_key, + purpose: request.purpose, + signature_hex, + completed_at_unix_ms, + } + } +} + +#[test] +fn swift_module_names_bind_the_tera_producer() { + let config = include_str!("../uniffi.toml"); + assert_eq!( + config, + "[bindings.swift]\nmodule_name = \"TeraKitBindings\"\nffi_module_name = \"TeraFFI\"\n" + ); +} + +#[test] +fn final_evidence_vocabularies_are_exact_at_the_mobile_boundary() { + assert_eq!( + [ + FfiTradeEvidenceCoverage::Missing, + FfiTradeEvidenceCoverage::Partial, + FfiTradeEvidenceCoverage::ScopeSatisfied, + FfiTradeEvidenceCoverage::Unsupported, + ] + .len(), + 4 + ); + assert_eq!( + [ + FfiTradeEvidenceOutcome::Valid, + FfiTradeEvidenceOutcome::Invalid, + FfiTradeEvidenceOutcome::Indeterminate, + ] + .len(), + 3 + ); +} + +#[test] +fn media_cancellation_handle_owns_one_stable_opaque_operation_identity() { + let operation = FfiMediaOperation::new().expect("media operation"); + let operation_id = operation.operation_id(); + assert_eq!(operation_id.len(), 32); + assert!(!operation.is_cancelled()); + operation.cancel(); + assert!(operation.is_cancelled()); + assert_eq!(operation.operation_id(), operation_id); +} + +#[tokio::test] +async fn protected_data_failure_is_typed_and_opens_no_store() { + let root = tempfile::tempdir().expect("tempdir"); + support::prepare(root.path()); + let result = RadrootsRuntime::new( + root.path().to_string_lossy().into_owned(), + support::PUBLIC_KEY.to_owned(), + support::GENERATION.to_owned(), + 1_800_000_000_000, + ProtectedDataAvailability::Unavailable, + ) + .await; + let Err(RadrootsAppError::Failure { report }) = result else { + panic!("protected data failure must remain typed across UniFFI"); + }; + assert_eq!(report.code, "protected_data_unavailable"); + assert!(report.retryable); + assert!( + !root + .path() + .join("radroots/users") + .join(support::PUBLIC_KEY) + .join("runtime.sqlite") + .exists() + ); +} + +#[tokio::test] +async fn final_mobile_abi_uses_async_sdk_dtos_and_versioned_errors() { + let (_root, runtime) = support::runtime().await; + let storage = runtime.sdk_storage_status().await.expect("storage status"); + assert_eq!(storage.backend, "sqlite"); + + runtime.shutdown().await.expect("shutdown"); + let error = runtime + .sdk_storage_status() + .await + .expect_err("closed client must reject operations"); + let RadrootsAppError::Failure { report } = error; + assert_eq!(report.schema_version, 1); + assert_eq!(report.code, "client_closed"); + assert_eq!(report.category, "runtime"); + assert!(!report.retryable); + assert_eq!(report.safe_message, "SDK client is closed"); +} + +#[tokio::test] +async fn host_signer_constructor_exposes_only_an_opaque_configured_boundary() { + let root = tempfile::tempdir().expect("tempdir"); + support::prepare(root.path()); + let outcome = Arc::new(Mutex::new(HostSigningOutcome::Rejected)); + let runtime = RadrootsRuntime::with_host_signer( + root.path().to_string_lossy().into_owned(), + support::PUBLIC_KEY.to_owned(), + support::GENERATION.to_owned(), + 1_800_000_000_000, + ProtectedDataAvailability::Available, + Box::new(TestHostSigner(Arc::clone(&outcome))), + ) + .await + .expect("runtime with host signer"); + + let identity = runtime.identity_status().expect("identity status"); + assert_eq!(identity.public_key, support::PUBLIC_KEY); + assert!(identity.host_signer_configured); + + for (index, host_outcome) in [ + HostSigningOutcome::Signed, + HostSigningOutcome::Locked, + HostSigningOutcome::Cancelled, + HostSigningOutcome::Rejected, + HostSigningOutcome::TimedOut, + HostSigningOutcome::Unavailable, + HostSigningOutcome::Invalidated, + HostSigningOutcome::Failed, + ] + .into_iter() + .enumerate() + { + *outcome + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = host_outcome; + let draft_id = format!("{:02x}", index + 17).repeat(16); + let saved = runtime + .phase1_save_draft( + draft_id.clone(), + FfiAddDraftInput { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + command_type: FfiAddCommandType::CreateUpdate, + content: format!("Signer boundary test {index}"), + 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(), + }, + 1_800_000_000 + index as u64, + None, + 1_800_000_000_000 + index as u64, + ) + .await + .expect("saved draft"); + let queued = runtime + .phase1_queue_draft( + draft_id.clone(), + saved.revision, + FfiQueuePolicyRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + relay_urls: vec!["wss://relay.example".to_owned()], + satisfaction: FfiRelaySatisfaction::AnyAccepted, + delivery_deadline_unix_ms: u64::MAX, + cancellation: FfiCancellationPolicy::PreservePublishedRequest, + }, + 1_800_000_001_000 + index as u64, + ) + .await + .expect("queued draft"); + let signing_result = runtime + .phase1_sign_queued_draft(draft_id, queued.revision) + .await; + if host_outcome == HostSigningOutcome::Signed { + assert_eq!( + signing_result.expect("valid host signature").state, + tera_ffi::FfiOutboxState::Signed + ); + } else { + let signing_error = signing_result.expect_err("host failure remains typed"); + assert_eq!(signing_error.report().category, "authoring"); + assert!(!signing_error.report().safe_message.contains("signature")); + } + } + runtime.shutdown().await.expect("shutdown"); +} diff --git a/core/crates/tera_ffi/uniffi.toml b/core/crates/tera_ffi/uniffi.toml @@ -0,0 +1,3 @@ +[bindings.swift] +module_name = "TeraKitBindings" +ffi_module_name = "TeraFFI" diff --git a/core/provenance/tera_ffi/LICENSE-APACHE b/core/provenance/tera_ffi/LICENSE-APACHE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2025 Tyson Lupul + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/core/provenance/tera_ffi/LICENSE-GPL-3.0-only b/core/provenance/tera_ffi/LICENSE-GPL-3.0-only @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/> + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + <one line to give the program's name and a brief idea of what it does.> + Copyright (C) <year> <name of author> + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see <https://www.gnu.org/licenses/>. + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + <program> Copyright (C) <year> <name of author> + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +<https://www.gnu.org/licenses/>. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +<https://www.gnu.org/licenses/why-not-lgpl.html>. diff --git a/core/provenance/tera_ffi/LICENSE-GPL-3.0-or-later b/core/provenance/tera_ffi/LICENSE-GPL-3.0-or-later @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/> + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + <one line to give the program's name and a brief idea of what it does.> + Copyright (C) <year> <name of author> + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see <https://www.gnu.org/licenses/>. + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + <program> Copyright (C) <year> <name of author> + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +<https://www.gnu.org/licenses/>. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +<https://www.gnu.org/licenses/why-not-lgpl.html>. diff --git a/core/provenance/tera_ffi/LICENSE-MIT b/core/provenance/tera_ffi/LICENSE-MIT @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2025 Tyson Lupul + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/core/test-fixtures/tera_ffi/authored_operations.v1.json b/core/test-fixtures/tera_ffi/authored_operations.v1.json @@ -0,0 +1,765 @@ +{ + "suite": "authored_operations_wire", + "contract_version": "1.0.0", + "vectors": [ + { + "id": "typed_profile_optional_escaping_001", + "kind": "authored_operations.wire", + "input": { + "contract_id": "radroots.profile.metadata.v1", + "authoring": "typed" + }, + "expected": { + "kind": 0, + "created_at": 1784347200, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "tags": [], + "content": "{\"name\":\"Alice \\\"Sprout\\\"\",\"display_name\":\"Alice's Orchard\",\"about\":\"Tree fruit\\nDirect from the farm\",\"bot\":false}", + "preimage": "[0,\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",1784347200,0,[],\"{\\\"name\\\":\\\"Alice \\\\\\\"Sprout\\\\\\\"\\\",\\\"display_name\\\":\\\"Alice's Orchard\\\",\\\"about\\\":\\\"Tree fruit\\\\nDirect from the farm\\\",\\\"bot\\\":false}\"]", + "event_id": "cd7c7d681ca7c11536da8008ca404cca394d68c9217d943939b385784d7327e8", + "signature": "c07b2e5fade95ad0cb9c0ffe58812afb359d0fed77af57d532eb68c15f40d0e07a0231daf681de5309da4baafa679f3c07fc93123fe772c9a76f4268c3ee9cb9", + "raw_json": "{\"id\":\"cd7c7d681ca7c11536da8008ca404cca394d68c9217d943939b385784d7327e8\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1784347200,\"kind\":0,\"tags\":[],\"content\":\"{\\\"name\\\":\\\"Alice \\\\\\\"Sprout\\\\\\\"\\\",\\\"display_name\\\":\\\"Alice's Orchard\\\",\\\"about\\\":\\\"Tree fruit\\\\nDirect from the farm\\\",\\\"bot\\\":false}\",\"sig\":\"c07b2e5fade95ad0cb9c0ffe58812afb359d0fed77af57d532eb68c15f40d0e07a0231daf681de5309da4baafa679f3c07fc93123fe772c9a76f4268c3ee9cb9\"}" + } + }, + { + "id": "typed_update_escaping_002", + "kind": "authored_operations.wire", + "input": { + "contract_id": "radroots.social.update.v1", + "authoring": "typed" + }, + "expected": { + "kind": 1, + "created_at": 1784347200, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "tags": [], + "content": "Farm update: \"ready\"\\\n🍓", + "preimage": "[0,\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",1784347200,1,[],\"Farm update: \\\"ready\\\"\\\\\\n🍓\"]", + "event_id": "11bcbaeab205194e26ae4d950190c03d18edb1b65f428048e589e4bbdcfa9d50", + "signature": "a6a323774f1ce4aafa6c479e758eb350a7e5c4bdc55c7690beba2ccb1631839a1246a83e62f4b29e74f9afda62802794981570e10c7d4ad41c392dab4066b88c", + "raw_json": "{\"id\":\"11bcbaeab205194e26ae4d950190c03d18edb1b65f428048e589e4bbdcfa9d50\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1784347200,\"kind\":1,\"tags\":[],\"content\":\"Farm update: \\\"ready\\\"\\\\\\n🍓\",\"sig\":\"a6a323774f1ce4aafa6c479e758eb350a7e5c4bdc55c7690beba2ccb1631839a1246a83e62f4b29e74f9afda62802794981570e10c7d4ad41c392dab4066b88c\"}" + } + }, + { + "id": "typed_photo_update_imeta_003", + "kind": "authored_operations.wire", + "input": { + "contract_id": "radroots.social.photo_update.v1", + "authoring": "typed" + }, + "expected": { + "kind": 1, + "created_at": 1784347200, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "tags": [ + [ + "imeta", + "url https://media.example/1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276.webp", + "x 1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276", + "m image/webp", + "dim 1200x900", + "size 12", + "alt Harvest" + ] + ], + "content": "Harvest https://media.example/1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276.webp", + "preimage": "[0,\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",1784347200,1,[[\"imeta\",\"url https://media.example/1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276.webp\",\"x 1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276\",\"m image/webp\",\"dim 1200x900\",\"size 12\",\"alt Harvest\"]],\"Harvest https://media.example/1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276.webp\"]", + "event_id": "de986339a659b8586390a7e64a59f67dc7f2702c3882fa8677e17ea0cbf99847", + "signature": "d38dfb534c2df4e9b62165cf7e557e060bf024f9b1e44794438924511620750533dd76c307f06ceeae5c852fcc9293e9d61e83bded9c432f50a2886ba59b7817", + "raw_json": "{\"id\":\"de986339a659b8586390a7e64a59f67dc7f2702c3882fa8677e17ea0cbf99847\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1784347200,\"kind\":1,\"tags\":[[\"imeta\",\"url https://media.example/1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276.webp\",\"x 1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276\",\"m image/webp\",\"dim 1200x900\",\"size 12\",\"alt Harvest\"]],\"content\":\"Harvest https://media.example/1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276.webp\",\"sig\":\"d38dfb534c2df4e9b62165cf7e557e060bf024f9b1e44794438924511620750533dd76c307f06ceeae5c852fcc9293e9d61e83bded9c432f50a2886ba59b7817\"}" + } + }, + { + "id": "typed_ask_marker_order_004", + "kind": "authored_operations.wire", + "input": { + "contract_id": "radroots.social.ask.v1", + "authoring": "typed" + }, + "expected": { + "kind": 1, + "created_at": 1784347200, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "tags": [ + [ + "t", + "radroots-ask" + ], + [ + "imeta", + "url https://media.example/1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276.webp", + "x 1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276", + "m image/webp", + "dim 1200x900", + "size 12", + "alt Harvest" + ] + ], + "content": "Is this ready? https://media.example/1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276.webp", + "preimage": "[0,\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",1784347200,1,[[\"t\",\"radroots-ask\"],[\"imeta\",\"url https://media.example/1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276.webp\",\"x 1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276\",\"m image/webp\",\"dim 1200x900\",\"size 12\",\"alt Harvest\"]],\"Is this ready? https://media.example/1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276.webp\"]", + "event_id": "21b3d75acd9c78b6caeacd2c14dc8371ca1348ec688b1bf2b606199c73e7155b", + "signature": "e90f3919f0045527bc131fb9f5c59708ce62074f78c6a3e7912e1da8647b1765ab513881c0894c9814c7a2ec574d1fd0d09565d06aa559d3efe6a3785c76bf38", + "raw_json": "{\"id\":\"21b3d75acd9c78b6caeacd2c14dc8371ca1348ec688b1bf2b606199c73e7155b\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1784347200,\"kind\":1,\"tags\":[[\"t\",\"radroots-ask\"],[\"imeta\",\"url https://media.example/1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276.webp\",\"x 1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276\",\"m image/webp\",\"dim 1200x900\",\"size 12\",\"alt Harvest\"]],\"content\":\"Is this ready? https://media.example/1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276.webp\",\"sig\":\"e90f3919f0045527bc131fb9f5c59708ce62074f78c6a3e7912e1da8647b1765ab513881c0894c9814c7a2ec574d1fd0d09565d06aa559d3efe6a3785c76bf38\"}" + } + }, + { + "id": "typed_nip10_direct_reply_005", + "kind": "authored_operations.wire", + "input": { + "contract_id": "radroots.social.reply.v1", + "authoring": "typed" + }, + "expected": { + "kind": 1, + "created_at": 1784347200, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "tags": [ + [ + "e", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "wss://relay.example.com", + "root" + ], + [ + "p", + "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af" + ] + ], + "content": "Direct reply", + "preimage": "[0,\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",1784347200,1,[[\"e\",\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"wss://relay.example.com\",\"root\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"]],\"Direct reply\"]", + "event_id": "89ce87ff86cd157d115d23ea63ba9f9323525af4a1035edc58407a2733efb413", + "signature": "3d012eae90eed5b0b00a0ce91c3779ad72bc91233b33fe520c588479619060ab6271f06933ac50d92a0e7dfffa91c41c7ad1291b4310166fb4a58a469bc4dfec", + "raw_json": "{\"id\":\"89ce87ff86cd157d115d23ea63ba9f9323525af4a1035edc58407a2733efb413\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1784347200,\"kind\":1,\"tags\":[[\"e\",\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"wss://relay.example.com\",\"root\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"]],\"content\":\"Direct reply\",\"sig\":\"3d012eae90eed5b0b00a0ce91c3779ad72bc91233b33fe520c588479619060ab6271f06933ac50d92a0e7dfffa91c41c7ad1291b4310166fb4a58a469bc4dfec\"}" + } + }, + { + "id": "typed_nip09_sorted_targets_006", + "kind": "authored_operations.wire", + "input": { + "contract_id": "radroots.social.deletion_request.v1", + "authoring": "typed" + }, + "expected": { + "kind": 5, + "created_at": 1784347200, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "tags": [ + [ + "e", + "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + ], + [ + "a", + "0:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:" + ], + [ + "a", + "30402:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:carrots" + ], + [ + "k", + "0" + ], + [ + "k", + "1" + ], + [ + "k", + "30402" + ] + ], + "content": "superseded", + "preimage": "[0,\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",1784347200,5,[[\"e\",\"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff\"],[\"a\",\"0:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:\"],[\"a\",\"30402:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:carrots\"],[\"k\",\"0\"],[\"k\",\"1\"],[\"k\",\"30402\"]],\"superseded\"]", + "event_id": "f8f3e07f23928f32be533b6a3b013eb15dd0ed2ecca851774461a4e4081acfa3", + "signature": "cf54061a7224e13cc54880ad0e64d95e971eb48b9f08f96de7fac68b5c2056e9f737253cdb15e61a566bb503142c1992abdb025ccadf08634efc7e623514aa0e", + "raw_json": "{\"id\":\"f8f3e07f23928f32be533b6a3b013eb15dd0ed2ecca851774461a4e4081acfa3\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1784347200,\"kind\":5,\"tags\":[[\"e\",\"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff\"],[\"a\",\"0:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:\"],[\"a\",\"30402:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:carrots\"],[\"k\",\"0\"],[\"k\",\"1\"],[\"k\",\"30402\"]],\"content\":\"superseded\",\"sig\":\"cf54061a7224e13cc54880ad0e64d95e971eb48b9f08f96de7fac68b5c2056e9f737253cdb15e61a566bb503142c1992abdb025ccadf08634efc7e623514aa0e\"}" + } + }, + { + "id": "typed_nip22_top_level_event_007", + "kind": "authored_operations.wire", + "input": { + "contract_id": "radroots.social.comment.v1", + "authoring": "typed" + }, + "expected": { + "kind": 1111, + "created_at": 1784347200, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "tags": [ + [ + "E", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "wss://relay.example.com", + "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af" + ], + [ + "K", + "30402" + ], + [ + "P", + "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", + "wss://relay.example.com" + ], + [ + "e", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "wss://relay.example.com", + "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af" + ], + [ + "k", + "30402" + ], + [ + "p", + "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", + "wss://relay.example.com" + ] + ], + "content": "Are these carrots available Saturday?", + "preimage": "[0,\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",1784347200,1111,[[\"E\",\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"wss://relay.example.com\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"],[\"K\",\"30402\"],[\"P\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\",\"wss://relay.example.com\"],[\"e\",\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"wss://relay.example.com\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"],[\"k\",\"30402\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\",\"wss://relay.example.com\"]],\"Are these carrots available Saturday?\"]", + "event_id": "a3d324a8eef83c53168ed0635d416a6152575f6882a1e67984a3d62144aa1789", + "signature": "c6e72dbbb54c7d1230257f5cb40a859461a6ad538ae689cc9794c5dfa28f5d8a031bfcd04a156769fbffd35832f5ee05fd1e01bea20776f8d5a227ff7fbffcba", + "raw_json": "{\"id\":\"a3d324a8eef83c53168ed0635d416a6152575f6882a1e67984a3d62144aa1789\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1784347200,\"kind\":1111,\"tags\":[[\"E\",\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"wss://relay.example.com\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"],[\"K\",\"30402\"],[\"P\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\",\"wss://relay.example.com\"],[\"e\",\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"wss://relay.example.com\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"],[\"k\",\"30402\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\",\"wss://relay.example.com\"]],\"content\":\"Are these carrots available Saturday?\",\"sig\":\"c6e72dbbb54c7d1230257f5cb40a859461a6ad538ae689cc9794c5dfa28f5d8a031bfcd04a156769fbffd35832f5ee05fd1e01bea20776f8d5a227ff7fbffcba\"}" + } + }, + { + "id": "typed_food_availability_optional_quantity_008", + "kind": "authored_operations.wire", + "input": { + "contract_id": "radroots.food.availability.v1", + "authoring": "typed" + }, + "expected": { + "kind": 30402, + "created_at": 1784347200, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "tags": [ + [ + "d", + "nantes-carrots" + ], + [ + "title", + "Nantes Carrots" + ], + [ + "summary", + "Fresh bunches" + ], + [ + "published_at", + "1784347100" + ], + [ + "location", + "Central Saanich, BC" + ], + [ + "price", + "3", + "CAD" + ], + [ + "radroots:price_unit", + "lb" + ], + [ + "radroots:quantity", + "24", + "lb" + ], + [ + "status", + "active" + ] + ], + "content": "Carrots available this week.", + "preimage": "[0,\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",1784347200,30402,[[\"d\",\"nantes-carrots\"],[\"title\",\"Nantes Carrots\"],[\"summary\",\"Fresh bunches\"],[\"published_at\",\"1784347100\"],[\"location\",\"Central Saanich, BC\"],[\"price\",\"3\",\"CAD\"],[\"radroots:price_unit\",\"lb\"],[\"radroots:quantity\",\"24\",\"lb\"],[\"status\",\"active\"]],\"Carrots available this week.\"]", + "event_id": "46e6584b2a23a96bad5d899d4df2c7803dfa363a9ecf95e389ecb935b512f454", + "signature": "e159b747ba94215066bb0a6e3845d828204b528551722a76e7d4a867f47f2fd4ff87abf6bbc9c90eab1f4e265d43fcadf2a5afb494bb90f73183f14ff637e98b", + "raw_json": "{\"id\":\"46e6584b2a23a96bad5d899d4df2c7803dfa363a9ecf95e389ecb935b512f454\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1784347200,\"kind\":30402,\"tags\":[[\"d\",\"nantes-carrots\"],[\"title\",\"Nantes Carrots\"],[\"summary\",\"Fresh bunches\"],[\"published_at\",\"1784347100\"],[\"location\",\"Central Saanich, BC\"],[\"price\",\"3\",\"CAD\"],[\"radroots:price_unit\",\"lb\"],[\"radroots:quantity\",\"24\",\"lb\"],[\"status\",\"active\"]],\"content\":\"Carrots available this week.\",\"sig\":\"e159b747ba94215066bb0a6e3845d828204b528551722a76e7d4a867f47f2fd4ff87abf6bbc9c90eab1f4e265d43fcadf2a5afb494bb90f73183f14ff637e98b\"}" + } + }, + { + "id": "typed_calendar_date_event_011", + "kind": "authored_operations.wire", + "input": { + "contract_id": "radroots.calendar.date_event.v1", + "authoring": "typed" + }, + "expected": { + "kind": 31922, + "created_at": 1784347200, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "tags": [ + [ + "d", + "market-day" + ], + [ + "title", + "Saturday Market" + ], + [ + "start", + "2026-08-08" + ], + [ + "end", + "2026-08-09" + ], + [ + "location", + "Central Saanich, BC" + ], + [ + "summary", + "Local food and seeds" + ] + ], + "content": "Farmers market and seed swap.", + "preimage": "[0,\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",1784347200,31922,[[\"d\",\"market-day\"],[\"title\",\"Saturday Market\"],[\"start\",\"2026-08-08\"],[\"end\",\"2026-08-09\"],[\"location\",\"Central Saanich, BC\"],[\"summary\",\"Local food and seeds\"]],\"Farmers market and seed swap.\"]", + "event_id": "b63f56e5a5a8ec22d084fbcafbaa03f3ffd2580dc9df50bb18d5df8c64ddcbe6", + "signature": "0de7de628fb564be7b5d34e49cfe0d217476d8cca4a6e2cdf06b351401cd6e2476a30d7ad87a815bf74cd7ae47987379341487d71dc70913674dd7fc792a4469", + "raw_json": "{\"id\":\"b63f56e5a5a8ec22d084fbcafbaa03f3ffd2580dc9df50bb18d5df8c64ddcbe6\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1784347200,\"kind\":31922,\"tags\":[[\"d\",\"market-day\"],[\"title\",\"Saturday Market\"],[\"start\",\"2026-08-08\"],[\"end\",\"2026-08-09\"],[\"location\",\"Central Saanich, BC\"],[\"summary\",\"Local food and seeds\"]],\"content\":\"Farmers market and seed swap.\",\"sig\":\"0de7de628fb564be7b5d34e49cfe0d217476d8cca4a6e2cdf06b351401cd6e2476a30d7ad87a815bf74cd7ae47987379341487d71dc70913674dd7fc792a4469\"}" + } + }, + { + "id": "typed_calendar_time_event_012", + "kind": "authored_operations.wire", + "input": { + "contract_id": "radroots.calendar.time_event.v1", + "authoring": "typed" + }, + "expected": { + "kind": 31923, + "created_at": 1784347200, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "tags": [ + [ + "d", + "farm-tour" + ], + [ + "title", + "Farm Tour" + ], + [ + "start", + "1784380800" + ], + [ + "end", + "1784387900" + ], + [ + "D", + "20652" + ], + [ + "start_tzid", + "America/Vancouver" + ], + [ + "location", + "Saanich Peninsula" + ] + ], + "content": "Walk the fields and meet the growers.", + "preimage": "[0,\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",1784347200,31923,[[\"d\",\"farm-tour\"],[\"title\",\"Farm Tour\"],[\"start\",\"1784380800\"],[\"end\",\"1784387900\"],[\"D\",\"20652\"],[\"start_tzid\",\"America/Vancouver\"],[\"location\",\"Saanich Peninsula\"]],\"Walk the fields and meet the growers.\"]", + "event_id": "22d1538c18614f9c089cbccd9de91dab5dcd55d7a41bce07a7282f49ce757433", + "signature": "ddac1ac03aed8b888bde2fae0915e07cab85a1f1bcd2f1ce2812befb0456f320a03bea94e1469f62aa1ecaceb241bada4449c3707f34d42cb378299c6a4793f5", + "raw_json": "{\"id\":\"22d1538c18614f9c089cbccd9de91dab5dcd55d7a41bce07a7282f49ce757433\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1784347200,\"kind\":31923,\"tags\":[[\"d\",\"farm-tour\"],[\"title\",\"Farm Tour\"],[\"start\",\"1784380800\"],[\"end\",\"1784387900\"],[\"D\",\"20652\"],[\"start_tzid\",\"America/Vancouver\"],[\"location\",\"Saanich Peninsula\"]],\"content\":\"Walk the fields and meet the growers.\",\"sig\":\"ddac1ac03aed8b888bde2fae0915e07cab85a1f1bcd2f1ce2812befb0456f320a03bea94e1469f62aa1ecaceb241bada4449c3707f34d42cb378299c6a4793f5\"}" + } + }, + { + "id": "generic_operational_listing_009", + "kind": "authored_operations.wire", + "input": { + "contract_id": "radroots.operational_listing.published.v1", + "authoring": "generic" + }, + "expected": { + "kind": 30402, + "created_at": 1784347200, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "tags": [ + [ + "d", + "AAAAAAAAAAAAAAAAAAAAAg" + ], + [ + "p", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ], + [ + "a", + "30340:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:AAAAAAAAAAAAAAAAAAAAAA" + ], + [ + "key", + "carrot-nantes" + ], + [ + "title", + "Nantes Carrots" + ], + [ + "category", + "produce" + ], + [ + "summary", + "Fresh bunches harvested in Saanich" + ], + [ + "published_at", + "1700000000" + ], + [ + "radroots:primary_bin", + "bunch" + ], + [ + "radroots:bin", + "bunch", + "1", + "each" + ], + [ + "radroots:price", + "bunch", + "4", + "CAD", + "1", + "each" + ], + [ + "price", + "4", + "CAD" + ], + [ + "inventory", + "24" + ], + [ + "status", + "active" + ], + [ + "delivery", + "pickup" + ], + [ + "location", + "Saanich Peninsula", + "Victoria", + "BC", + "CA" + ], + [ + "g", + "c28hr" + ] + ], + "content": "# Nantes Carrots\n\nFresh bunches harvested in Saanich", + "preimage": "[0,\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",1784347200,30402,[[\"d\",\"AAAAAAAAAAAAAAAAAAAAAg\"],[\"p\",\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"],[\"a\",\"30340:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:AAAAAAAAAAAAAAAAAAAAAA\"],[\"key\",\"carrot-nantes\"],[\"title\",\"Nantes Carrots\"],[\"category\",\"produce\"],[\"summary\",\"Fresh bunches harvested in Saanich\"],[\"published_at\",\"1700000000\"],[\"radroots:primary_bin\",\"bunch\"],[\"radroots:bin\",\"bunch\",\"1\",\"each\"],[\"radroots:price\",\"bunch\",\"4\",\"CAD\",\"1\",\"each\"],[\"price\",\"4\",\"CAD\"],[\"inventory\",\"24\"],[\"status\",\"active\"],[\"delivery\",\"pickup\"],[\"location\",\"Saanich Peninsula\",\"Victoria\",\"BC\",\"CA\"],[\"g\",\"c28hr\"]],\"# Nantes Carrots\\n\\nFresh bunches harvested in Saanich\"]", + "event_id": "da14c35c4afe472a2ddef6d7298cc736782eaf09b55511c3b5774ad79468ada8", + "signature": "07edaeab0b05807d4987346c95fd2441b46949be03f455bfbb3678c07b50fa95fb563b509b33f5d547e7cb74f09475c04e281c66362a8db206e57ee757d60dbe", + "raw_json": "{\"id\":\"da14c35c4afe472a2ddef6d7298cc736782eaf09b55511c3b5774ad79468ada8\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1784347200,\"kind\":30402,\"tags\":[[\"d\",\"AAAAAAAAAAAAAAAAAAAAAg\"],[\"p\",\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"],[\"a\",\"30340:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:AAAAAAAAAAAAAAAAAAAAAA\"],[\"key\",\"carrot-nantes\"],[\"title\",\"Nantes Carrots\"],[\"category\",\"produce\"],[\"summary\",\"Fresh bunches harvested in Saanich\"],[\"published_at\",\"1700000000\"],[\"radroots:primary_bin\",\"bunch\"],[\"radroots:bin\",\"bunch\",\"1\",\"each\"],[\"radroots:price\",\"bunch\",\"4\",\"CAD\",\"1\",\"each\"],[\"price\",\"4\",\"CAD\"],[\"inventory\",\"24\"],[\"status\",\"active\"],[\"delivery\",\"pickup\"],[\"location\",\"Saanich Peninsula\",\"Victoria\",\"BC\",\"CA\"],[\"g\",\"c28hr\"]],\"content\":\"# Nantes Carrots\\n\\nFresh bunches harvested in Saanich\",\"sig\":\"07edaeab0b05807d4987346c95fd2441b46949be03f455bfbb3678c07b50fa95fb563b509b33f5d547e7cb74f09475c04e281c66362a8db206e57ee757d60dbe\"}" + } + }, + { + "id": "typed_trade_proposal_010", + "kind": "authored_operations.wire", + "input": { + "contract_id": "radroots.trade.proposal.v1", + "authoring": "typed" + }, + "expected": { + "kind": 3470, + "created_at": 1784347200, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "tags": [ + [ + "contract", + "radroots.trade.proposal.v1" + ], + [ + "d", + "11111111111111111111111111111111" + ], + [ + "x", + "05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799", + "mutation" + ], + [ + "p", + "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df" + ], + [ + "p", + "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af" + ] + ], + "content": "{\"author_pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"authored_at_unix_s\":1784347200,\"body\":{\"candidate\":{\"base_candidate_id\":null,\"buyer_pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"cancellation\":{\"buyer_pre_agreement\":true,\"post_agreement_cutoff_unix_s\":null,\"profile_id\":\"buyer-pre-agreement\"},\"candidate_id\":\"cd6471a9bc91766a455e4adb59a63c8b26881c80e2f0d96a2e882bbf75b7d533\",\"economics\":{\"adjustment_total_mantissa\":\"0\",\"adjustments\":[],\"currency_code\":\"USD\",\"currency_exponent\":2,\"discount_total_mantissa\":\"0\",\"profile_id\":\"mvp-fixed\",\"rounding_profile\":\"half-even\",\"subtotal_mantissa\":\"1000\",\"total_mantissa\":\"1000\"},\"farm_id\":\"farm-1\",\"fulfillment\":{\"ends_at_unix_s\":1800003600,\"fold\":0,\"location_class\":\"farmstand\",\"method\":\"pickup\",\"profile_id\":\"market-pickup\",\"requires_private_terms\":false,\"starts_at_unix_s\":1800000000,\"timezone\":\"America/New_York\",\"utc_offset_seconds\":-18000},\"line_tombstones\":[],\"lines\":[{\"bin_id\":\"bin-1\",\"currency_code\":\"USD\",\"line_id\":\"line-1\",\"line_subtotal_mantissa\":\"1000\",\"listing_addr\":\"30402:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:listing-1\",\"listing_event_id\":\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"listing_snapshot_sha256\":\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"option_id\":null,\"product_id\":\"carrots\",\"quantity_mantissa\":\"2\",\"quantity_scale\":0,\"replaces_line_id\":null,\"unit_code\":\"count\",\"unit_price_mantissa\":\"500\",\"unit_profile\":\"mvp-count\"}],\"private_terms\":null,\"proposal_expires_at_unix_s\":1799999000,\"schema_version\":1,\"seller_pubkey\":\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\",\"supersession_intent\":null},\"mutation_type\":\"proposal\"},\"buyer_pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"contract_id\":\"radroots.trade.proposal.v1\",\"counterparty_pubkey\":\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\",\"farm_id\":\"farm-1\",\"mutation_id\":\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\",\"parent_mutation_ids\":[],\"root_mutation_id\":null,\"schema_version\":1,\"seller_pubkey\":\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\",\"trade_id\":\"11111111111111111111111111111111\"}", + "preimage": "[0,\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",1784347200,3470,[[\"contract\",\"radroots.trade.proposal.v1\"],[\"d\",\"11111111111111111111111111111111\"],[\"x\",\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\",\"mutation\"],[\"p\",\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"]],\"{\\\"author_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"authored_at_unix_s\\\":1784347200,\\\"body\\\":{\\\"candidate\\\":{\\\"base_candidate_id\\\":null,\\\"buyer_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"cancellation\\\":{\\\"buyer_pre_agreement\\\":true,\\\"post_agreement_cutoff_unix_s\\\":null,\\\"profile_id\\\":\\\"buyer-pre-agreement\\\"},\\\"candidate_id\\\":\\\"cd6471a9bc91766a455e4adb59a63c8b26881c80e2f0d96a2e882bbf75b7d533\\\",\\\"economics\\\":{\\\"adjustment_total_mantissa\\\":\\\"0\\\",\\\"adjustments\\\":[],\\\"currency_code\\\":\\\"USD\\\",\\\"currency_exponent\\\":2,\\\"discount_total_mantissa\\\":\\\"0\\\",\\\"profile_id\\\":\\\"mvp-fixed\\\",\\\"rounding_profile\\\":\\\"half-even\\\",\\\"subtotal_mantissa\\\":\\\"1000\\\",\\\"total_mantissa\\\":\\\"1000\\\"},\\\"farm_id\\\":\\\"farm-1\\\",\\\"fulfillment\\\":{\\\"ends_at_unix_s\\\":1800003600,\\\"fold\\\":0,\\\"location_class\\\":\\\"farmstand\\\",\\\"method\\\":\\\"pickup\\\",\\\"profile_id\\\":\\\"market-pickup\\\",\\\"requires_private_terms\\\":false,\\\"starts_at_unix_s\\\":1800000000,\\\"timezone\\\":\\\"America/New_York\\\",\\\"utc_offset_seconds\\\":-18000},\\\"line_tombstones\\\":[],\\\"lines\\\":[{\\\"bin_id\\\":\\\"bin-1\\\",\\\"currency_code\\\":\\\"USD\\\",\\\"line_id\\\":\\\"line-1\\\",\\\"line_subtotal_mantissa\\\":\\\"1000\\\",\\\"listing_addr\\\":\\\"30402:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:listing-1\\\",\\\"listing_event_id\\\":\\\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\\\",\\\"listing_snapshot_sha256\\\":\\\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\\\",\\\"option_id\\\":null,\\\"product_id\\\":\\\"carrots\\\",\\\"quantity_mantissa\\\":\\\"2\\\",\\\"quantity_scale\\\":0,\\\"replaces_line_id\\\":null,\\\"unit_code\\\":\\\"count\\\",\\\"unit_price_mantissa\\\":\\\"500\\\",\\\"unit_profile\\\":\\\"mvp-count\\\"}],\\\"private_terms\\\":null,\\\"proposal_expires_at_unix_s\\\":1799999000,\\\"schema_version\\\":1,\\\"seller_pubkey\\\":\\\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\\\",\\\"supersession_intent\\\":null},\\\"mutation_type\\\":\\\"proposal\\\"},\\\"buyer_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"contract_id\\\":\\\"radroots.trade.proposal.v1\\\",\\\"counterparty_pubkey\\\":\\\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\\\",\\\"farm_id\\\":\\\"farm-1\\\",\\\"mutation_id\\\":\\\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\\\",\\\"parent_mutation_ids\\\":[],\\\"root_mutation_id\\\":null,\\\"schema_version\\\":1,\\\"seller_pubkey\\\":\\\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\\\",\\\"trade_id\\\":\\\"11111111111111111111111111111111\\\"}\"]", + "event_id": "58eacb99d9b4e4da7f614fb37ed84b482d2cb85e30785d49ea6a29b555bfcd3a", + "signature": "b39fc5d285826bda6462dc9fb8c20c6519b54d4c8ac9673f3c53c6424e2e1703762b4126282e579dcb11c6738477738a61396595fb6c13331e038c3b06b18517", + "raw_json": "{\"id\":\"58eacb99d9b4e4da7f614fb37ed84b482d2cb85e30785d49ea6a29b555bfcd3a\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1784347200,\"kind\":3470,\"tags\":[[\"contract\",\"radroots.trade.proposal.v1\"],[\"d\",\"11111111111111111111111111111111\"],[\"x\",\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\",\"mutation\"],[\"p\",\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"]],\"content\":\"{\\\"author_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"authored_at_unix_s\\\":1784347200,\\\"body\\\":{\\\"candidate\\\":{\\\"base_candidate_id\\\":null,\\\"buyer_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"cancellation\\\":{\\\"buyer_pre_agreement\\\":true,\\\"post_agreement_cutoff_unix_s\\\":null,\\\"profile_id\\\":\\\"buyer-pre-agreement\\\"},\\\"candidate_id\\\":\\\"cd6471a9bc91766a455e4adb59a63c8b26881c80e2f0d96a2e882bbf75b7d533\\\",\\\"economics\\\":{\\\"adjustment_total_mantissa\\\":\\\"0\\\",\\\"adjustments\\\":[],\\\"currency_code\\\":\\\"USD\\\",\\\"currency_exponent\\\":2,\\\"discount_total_mantissa\\\":\\\"0\\\",\\\"profile_id\\\":\\\"mvp-fixed\\\",\\\"rounding_profile\\\":\\\"half-even\\\",\\\"subtotal_mantissa\\\":\\\"1000\\\",\\\"total_mantissa\\\":\\\"1000\\\"},\\\"farm_id\\\":\\\"farm-1\\\",\\\"fulfillment\\\":{\\\"ends_at_unix_s\\\":1800003600,\\\"fold\\\":0,\\\"location_class\\\":\\\"farmstand\\\",\\\"method\\\":\\\"pickup\\\",\\\"profile_id\\\":\\\"market-pickup\\\",\\\"requires_private_terms\\\":false,\\\"starts_at_unix_s\\\":1800000000,\\\"timezone\\\":\\\"America/New_York\\\",\\\"utc_offset_seconds\\\":-18000},\\\"line_tombstones\\\":[],\\\"lines\\\":[{\\\"bin_id\\\":\\\"bin-1\\\",\\\"currency_code\\\":\\\"USD\\\",\\\"line_id\\\":\\\"line-1\\\",\\\"line_subtotal_mantissa\\\":\\\"1000\\\",\\\"listing_addr\\\":\\\"30402:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:listing-1\\\",\\\"listing_event_id\\\":\\\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\\\",\\\"listing_snapshot_sha256\\\":\\\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\\\",\\\"option_id\\\":null,\\\"product_id\\\":\\\"carrots\\\",\\\"quantity_mantissa\\\":\\\"2\\\",\\\"quantity_scale\\\":0,\\\"replaces_line_id\\\":null,\\\"unit_code\\\":\\\"count\\\",\\\"unit_price_mantissa\\\":\\\"500\\\",\\\"unit_profile\\\":\\\"mvp-count\\\"}],\\\"private_terms\\\":null,\\\"proposal_expires_at_unix_s\\\":1799999000,\\\"schema_version\\\":1,\\\"seller_pubkey\\\":\\\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\\\",\\\"supersession_intent\\\":null},\\\"mutation_type\\\":\\\"proposal\\\"},\\\"buyer_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"contract_id\\\":\\\"radroots.trade.proposal.v1\\\",\\\"counterparty_pubkey\\\":\\\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\\\",\\\"farm_id\\\":\\\"farm-1\\\",\\\"mutation_id\\\":\\\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\\\",\\\"parent_mutation_ids\\\":[],\\\"root_mutation_id\\\":null,\\\"schema_version\\\":1,\\\"seller_pubkey\\\":\\\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\\\",\\\"trade_id\\\":\\\"11111111111111111111111111111111\\\"}\",\"sig\":\"b39fc5d285826bda6462dc9fb8c20c6519b54d4c8ac9673f3c53c6424e2e1703762b4126282e579dcb11c6738477738a61396595fb6c13331e038c3b06b18517\"}" + } + }, + { + "id": "typed_trade_decision_013", + "kind": "authored_operations.wire", + "input": { + "contract_id": "radroots.trade.decision.v1", + "authoring": "typed" + }, + "expected": { + "kind": 3471, + "created_at": 1784347200, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "tags": [ + [ + "contract", + "radroots.trade.decision.v1" + ], + [ + "d", + "11111111111111111111111111111111" + ], + [ + "x", + "07741093fa274aa94d045fb7e5ade2938f0c9192f34ecdf19b27f512c940b027", + "mutation" + ], + [ + "x", + "05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799", + "root" + ], + [ + "x", + "05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799", + "parent" + ], + [ + "p", + "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df" + ], + [ + "p", + "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af" + ] + ], + "content": "{\"author_pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"authored_at_unix_s\":1784347200,\"body\":{\"candidate_id\":\"cd6471a9bc91766a455e4adb59a63c8b26881c80e2f0d96a2e882bbf75b7d533\",\"decision\":{\"decision\":\"declined\",\"reason\":\"inventory unavailable\"},\"mutation_type\":\"decision\",\"proposal_mutation_id\":\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\"},\"buyer_pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"contract_id\":\"radroots.trade.decision.v1\",\"counterparty_pubkey\":\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\",\"farm_id\":\"farm-1\",\"mutation_id\":\"07741093fa274aa94d045fb7e5ade2938f0c9192f34ecdf19b27f512c940b027\",\"parent_mutation_ids\":[\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\"],\"root_mutation_id\":\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\",\"schema_version\":1,\"seller_pubkey\":\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\",\"trade_id\":\"11111111111111111111111111111111\"}", + "preimage": "[0,\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",1784347200,3471,[[\"contract\",\"radroots.trade.decision.v1\"],[\"d\",\"11111111111111111111111111111111\"],[\"x\",\"07741093fa274aa94d045fb7e5ade2938f0c9192f34ecdf19b27f512c940b027\",\"mutation\"],[\"x\",\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\",\"root\"],[\"x\",\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\",\"parent\"],[\"p\",\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"]],\"{\\\"author_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"authored_at_unix_s\\\":1784347200,\\\"body\\\":{\\\"candidate_id\\\":\\\"cd6471a9bc91766a455e4adb59a63c8b26881c80e2f0d96a2e882bbf75b7d533\\\",\\\"decision\\\":{\\\"decision\\\":\\\"declined\\\",\\\"reason\\\":\\\"inventory unavailable\\\"},\\\"mutation_type\\\":\\\"decision\\\",\\\"proposal_mutation_id\\\":\\\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\\\"},\\\"buyer_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"contract_id\\\":\\\"radroots.trade.decision.v1\\\",\\\"counterparty_pubkey\\\":\\\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\\\",\\\"farm_id\\\":\\\"farm-1\\\",\\\"mutation_id\\\":\\\"07741093fa274aa94d045fb7e5ade2938f0c9192f34ecdf19b27f512c940b027\\\",\\\"parent_mutation_ids\\\":[\\\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\\\"],\\\"root_mutation_id\\\":\\\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\\\",\\\"schema_version\\\":1,\\\"seller_pubkey\\\":\\\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\\\",\\\"trade_id\\\":\\\"11111111111111111111111111111111\\\"}\"]", + "event_id": "7dbe947cbeb1cde706bb240657fee585aa4214baa61d728b7bec7ac5ee18350e", + "signature": "222a04c15f66c6028c5b6619116ae92958a5e853293697f2fdbe0450300a50f9bfd2f1f56f1ce3eb2bd037d938bf22f6d99ecdb924de8c2fd5f0da846c1d2817", + "raw_json": "{\"id\":\"7dbe947cbeb1cde706bb240657fee585aa4214baa61d728b7bec7ac5ee18350e\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1784347200,\"kind\":3471,\"tags\":[[\"contract\",\"radroots.trade.decision.v1\"],[\"d\",\"11111111111111111111111111111111\"],[\"x\",\"07741093fa274aa94d045fb7e5ade2938f0c9192f34ecdf19b27f512c940b027\",\"mutation\"],[\"x\",\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\",\"root\"],[\"x\",\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\",\"parent\"],[\"p\",\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"]],\"content\":\"{\\\"author_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"authored_at_unix_s\\\":1784347200,\\\"body\\\":{\\\"candidate_id\\\":\\\"cd6471a9bc91766a455e4adb59a63c8b26881c80e2f0d96a2e882bbf75b7d533\\\",\\\"decision\\\":{\\\"decision\\\":\\\"declined\\\",\\\"reason\\\":\\\"inventory unavailable\\\"},\\\"mutation_type\\\":\\\"decision\\\",\\\"proposal_mutation_id\\\":\\\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\\\"},\\\"buyer_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"contract_id\\\":\\\"radroots.trade.decision.v1\\\",\\\"counterparty_pubkey\\\":\\\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\\\",\\\"farm_id\\\":\\\"farm-1\\\",\\\"mutation_id\\\":\\\"07741093fa274aa94d045fb7e5ade2938f0c9192f34ecdf19b27f512c940b027\\\",\\\"parent_mutation_ids\\\":[\\\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\\\"],\\\"root_mutation_id\\\":\\\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\\\",\\\"schema_version\\\":1,\\\"seller_pubkey\\\":\\\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\\\",\\\"trade_id\\\":\\\"11111111111111111111111111111111\\\"}\",\"sig\":\"222a04c15f66c6028c5b6619116ae92958a5e853293697f2fdbe0450300a50f9bfd2f1f56f1ce3eb2bd037d938bf22f6d99ecdb924de8c2fd5f0da846c1d2817\"}" + } + }, + { + "id": "typed_trade_revision_proposal_014", + "kind": "authored_operations.wire", + "input": { + "contract_id": "radroots.trade.revision_proposal.v1", + "authoring": "typed" + }, + "expected": { + "kind": 3472, + "created_at": 1784347200, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "tags": [ + [ + "contract", + "radroots.trade.revision_proposal.v1" + ], + [ + "d", + "11111111111111111111111111111111" + ], + [ + "x", + "cbb2837497fd46b47b599c916b246cd569e0697b2cd8d50a51363790ae587847", + "mutation" + ], + [ + "x", + "05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799", + "root" + ], + [ + "x", + "05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799", + "parent" + ], + [ + "p", + "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df" + ], + [ + "p", + "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af" + ] + ], + "content": "{\"author_pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"authored_at_unix_s\":1784347200,\"body\":{\"candidate\":{\"base_candidate_id\":null,\"buyer_pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"cancellation\":{\"buyer_pre_agreement\":true,\"post_agreement_cutoff_unix_s\":null,\"profile_id\":\"buyer-pre-agreement\"},\"candidate_id\":\"cd6471a9bc91766a455e4adb59a63c8b26881c80e2f0d96a2e882bbf75b7d533\",\"economics\":{\"adjustment_total_mantissa\":\"0\",\"adjustments\":[],\"currency_code\":\"USD\",\"currency_exponent\":2,\"discount_total_mantissa\":\"0\",\"profile_id\":\"mvp-fixed\",\"rounding_profile\":\"half-even\",\"subtotal_mantissa\":\"1000\",\"total_mantissa\":\"1000\"},\"farm_id\":\"farm-1\",\"fulfillment\":{\"ends_at_unix_s\":1800003600,\"fold\":0,\"location_class\":\"farmstand\",\"method\":\"pickup\",\"profile_id\":\"market-pickup\",\"requires_private_terms\":false,\"starts_at_unix_s\":1800000000,\"timezone\":\"America/New_York\",\"utc_offset_seconds\":-18000},\"line_tombstones\":[],\"lines\":[{\"bin_id\":\"bin-1\",\"currency_code\":\"USD\",\"line_id\":\"line-1\",\"line_subtotal_mantissa\":\"1000\",\"listing_addr\":\"30402:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:listing-1\",\"listing_event_id\":\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"listing_snapshot_sha256\":\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"option_id\":null,\"product_id\":\"carrots\",\"quantity_mantissa\":\"2\",\"quantity_scale\":0,\"replaces_line_id\":null,\"unit_code\":\"count\",\"unit_price_mantissa\":\"500\",\"unit_profile\":\"mvp-count\"}],\"private_terms\":null,\"proposal_expires_at_unix_s\":1799999000,\"schema_version\":1,\"seller_pubkey\":\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\",\"supersession_intent\":null},\"mutation_type\":\"revision_proposal\"},\"buyer_pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"contract_id\":\"radroots.trade.revision_proposal.v1\",\"counterparty_pubkey\":\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\",\"farm_id\":\"farm-1\",\"mutation_id\":\"cbb2837497fd46b47b599c916b246cd569e0697b2cd8d50a51363790ae587847\",\"parent_mutation_ids\":[\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\"],\"root_mutation_id\":\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\",\"schema_version\":1,\"seller_pubkey\":\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\",\"trade_id\":\"11111111111111111111111111111111\"}", + "preimage": "[0,\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",1784347200,3472,[[\"contract\",\"radroots.trade.revision_proposal.v1\"],[\"d\",\"11111111111111111111111111111111\"],[\"x\",\"cbb2837497fd46b47b599c916b246cd569e0697b2cd8d50a51363790ae587847\",\"mutation\"],[\"x\",\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\",\"root\"],[\"x\",\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\",\"parent\"],[\"p\",\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"]],\"{\\\"author_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"authored_at_unix_s\\\":1784347200,\\\"body\\\":{\\\"candidate\\\":{\\\"base_candidate_id\\\":null,\\\"buyer_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"cancellation\\\":{\\\"buyer_pre_agreement\\\":true,\\\"post_agreement_cutoff_unix_s\\\":null,\\\"profile_id\\\":\\\"buyer-pre-agreement\\\"},\\\"candidate_id\\\":\\\"cd6471a9bc91766a455e4adb59a63c8b26881c80e2f0d96a2e882bbf75b7d533\\\",\\\"economics\\\":{\\\"adjustment_total_mantissa\\\":\\\"0\\\",\\\"adjustments\\\":[],\\\"currency_code\\\":\\\"USD\\\",\\\"currency_exponent\\\":2,\\\"discount_total_mantissa\\\":\\\"0\\\",\\\"profile_id\\\":\\\"mvp-fixed\\\",\\\"rounding_profile\\\":\\\"half-even\\\",\\\"subtotal_mantissa\\\":\\\"1000\\\",\\\"total_mantissa\\\":\\\"1000\\\"},\\\"farm_id\\\":\\\"farm-1\\\",\\\"fulfillment\\\":{\\\"ends_at_unix_s\\\":1800003600,\\\"fold\\\":0,\\\"location_class\\\":\\\"farmstand\\\",\\\"method\\\":\\\"pickup\\\",\\\"profile_id\\\":\\\"market-pickup\\\",\\\"requires_private_terms\\\":false,\\\"starts_at_unix_s\\\":1800000000,\\\"timezone\\\":\\\"America/New_York\\\",\\\"utc_offset_seconds\\\":-18000},\\\"line_tombstones\\\":[],\\\"lines\\\":[{\\\"bin_id\\\":\\\"bin-1\\\",\\\"currency_code\\\":\\\"USD\\\",\\\"line_id\\\":\\\"line-1\\\",\\\"line_subtotal_mantissa\\\":\\\"1000\\\",\\\"listing_addr\\\":\\\"30402:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:listing-1\\\",\\\"listing_event_id\\\":\\\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\\\",\\\"listing_snapshot_sha256\\\":\\\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\\\",\\\"option_id\\\":null,\\\"product_id\\\":\\\"carrots\\\",\\\"quantity_mantissa\\\":\\\"2\\\",\\\"quantity_scale\\\":0,\\\"replaces_line_id\\\":null,\\\"unit_code\\\":\\\"count\\\",\\\"unit_price_mantissa\\\":\\\"500\\\",\\\"unit_profile\\\":\\\"mvp-count\\\"}],\\\"private_terms\\\":null,\\\"proposal_expires_at_unix_s\\\":1799999000,\\\"schema_version\\\":1,\\\"seller_pubkey\\\":\\\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\\\",\\\"supersession_intent\\\":null},\\\"mutation_type\\\":\\\"revision_proposal\\\"},\\\"buyer_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"contract_id\\\":\\\"radroots.trade.revision_proposal.v1\\\",\\\"counterparty_pubkey\\\":\\\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\\\",\\\"farm_id\\\":\\\"farm-1\\\",\\\"mutation_id\\\":\\\"cbb2837497fd46b47b599c916b246cd569e0697b2cd8d50a51363790ae587847\\\",\\\"parent_mutation_ids\\\":[\\\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\\\"],\\\"root_mutation_id\\\":\\\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\\\",\\\"schema_version\\\":1,\\\"seller_pubkey\\\":\\\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\\\",\\\"trade_id\\\":\\\"11111111111111111111111111111111\\\"}\"]", + "event_id": "3be0e2a2f3f9eef21127eed0dab79ce31932e05927d6558999c5a50022d2eefe", + "signature": "777eb26ba6b01ee154a006907cb99ad43dace95208138a30dc48d36419c93f8e1e256d3707eb28a0dd42ed50ed3954b803656f83b0259afae02bb86cc7ae3742", + "raw_json": "{\"id\":\"3be0e2a2f3f9eef21127eed0dab79ce31932e05927d6558999c5a50022d2eefe\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1784347200,\"kind\":3472,\"tags\":[[\"contract\",\"radroots.trade.revision_proposal.v1\"],[\"d\",\"11111111111111111111111111111111\"],[\"x\",\"cbb2837497fd46b47b599c916b246cd569e0697b2cd8d50a51363790ae587847\",\"mutation\"],[\"x\",\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\",\"root\"],[\"x\",\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\",\"parent\"],[\"p\",\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"]],\"content\":\"{\\\"author_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"authored_at_unix_s\\\":1784347200,\\\"body\\\":{\\\"candidate\\\":{\\\"base_candidate_id\\\":null,\\\"buyer_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"cancellation\\\":{\\\"buyer_pre_agreement\\\":true,\\\"post_agreement_cutoff_unix_s\\\":null,\\\"profile_id\\\":\\\"buyer-pre-agreement\\\"},\\\"candidate_id\\\":\\\"cd6471a9bc91766a455e4adb59a63c8b26881c80e2f0d96a2e882bbf75b7d533\\\",\\\"economics\\\":{\\\"adjustment_total_mantissa\\\":\\\"0\\\",\\\"adjustments\\\":[],\\\"currency_code\\\":\\\"USD\\\",\\\"currency_exponent\\\":2,\\\"discount_total_mantissa\\\":\\\"0\\\",\\\"profile_id\\\":\\\"mvp-fixed\\\",\\\"rounding_profile\\\":\\\"half-even\\\",\\\"subtotal_mantissa\\\":\\\"1000\\\",\\\"total_mantissa\\\":\\\"1000\\\"},\\\"farm_id\\\":\\\"farm-1\\\",\\\"fulfillment\\\":{\\\"ends_at_unix_s\\\":1800003600,\\\"fold\\\":0,\\\"location_class\\\":\\\"farmstand\\\",\\\"method\\\":\\\"pickup\\\",\\\"profile_id\\\":\\\"market-pickup\\\",\\\"requires_private_terms\\\":false,\\\"starts_at_unix_s\\\":1800000000,\\\"timezone\\\":\\\"America/New_York\\\",\\\"utc_offset_seconds\\\":-18000},\\\"line_tombstones\\\":[],\\\"lines\\\":[{\\\"bin_id\\\":\\\"bin-1\\\",\\\"currency_code\\\":\\\"USD\\\",\\\"line_id\\\":\\\"line-1\\\",\\\"line_subtotal_mantissa\\\":\\\"1000\\\",\\\"listing_addr\\\":\\\"30402:e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af:listing-1\\\",\\\"listing_event_id\\\":\\\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\\\",\\\"listing_snapshot_sha256\\\":\\\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\\\",\\\"option_id\\\":null,\\\"product_id\\\":\\\"carrots\\\",\\\"quantity_mantissa\\\":\\\"2\\\",\\\"quantity_scale\\\":0,\\\"replaces_line_id\\\":null,\\\"unit_code\\\":\\\"count\\\",\\\"unit_price_mantissa\\\":\\\"500\\\",\\\"unit_profile\\\":\\\"mvp-count\\\"}],\\\"private_terms\\\":null,\\\"proposal_expires_at_unix_s\\\":1799999000,\\\"schema_version\\\":1,\\\"seller_pubkey\\\":\\\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\\\",\\\"supersession_intent\\\":null},\\\"mutation_type\\\":\\\"revision_proposal\\\"},\\\"buyer_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"contract_id\\\":\\\"radroots.trade.revision_proposal.v1\\\",\\\"counterparty_pubkey\\\":\\\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\\\",\\\"farm_id\\\":\\\"farm-1\\\",\\\"mutation_id\\\":\\\"cbb2837497fd46b47b599c916b246cd569e0697b2cd8d50a51363790ae587847\\\",\\\"parent_mutation_ids\\\":[\\\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\\\"],\\\"root_mutation_id\\\":\\\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\\\",\\\"schema_version\\\":1,\\\"seller_pubkey\\\":\\\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\\\",\\\"trade_id\\\":\\\"11111111111111111111111111111111\\\"}\",\"sig\":\"777eb26ba6b01ee154a006907cb99ad43dace95208138a30dc48d36419c93f8e1e256d3707eb28a0dd42ed50ed3954b803656f83b0259afae02bb86cc7ae3742\"}" + } + }, + { + "id": "typed_trade_revision_decision_015", + "kind": "authored_operations.wire", + "input": { + "contract_id": "radroots.trade.revision_decision.v1", + "authoring": "typed" + }, + "expected": { + "kind": 3473, + "created_at": 1784347200, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "tags": [ + [ + "contract", + "radroots.trade.revision_decision.v1" + ], + [ + "d", + "11111111111111111111111111111111" + ], + [ + "x", + "bd7ab3a8f4bbff02000fa95446232f0959a3bd5fa2753f495742ad25a7809732", + "mutation" + ], + [ + "x", + "05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799", + "root" + ], + [ + "x", + "05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799", + "parent" + ], + [ + "p", + "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df" + ], + [ + "p", + "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af" + ] + ], + "content": "{\"author_pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"authored_at_unix_s\":1784347200,\"body\":{\"candidate_id\":\"cd6471a9bc91766a455e4adb59a63c8b26881c80e2f0d96a2e882bbf75b7d533\",\"decision\":{\"decision\":\"declined\",\"reason\":\"inventory unavailable\"},\"mutation_type\":\"revision_decision\",\"proposal_mutation_id\":\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\"},\"buyer_pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"contract_id\":\"radroots.trade.revision_decision.v1\",\"counterparty_pubkey\":\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\",\"farm_id\":\"farm-1\",\"mutation_id\":\"bd7ab3a8f4bbff02000fa95446232f0959a3bd5fa2753f495742ad25a7809732\",\"parent_mutation_ids\":[\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\"],\"root_mutation_id\":\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\",\"schema_version\":1,\"seller_pubkey\":\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\",\"trade_id\":\"11111111111111111111111111111111\"}", + "preimage": "[0,\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",1784347200,3473,[[\"contract\",\"radroots.trade.revision_decision.v1\"],[\"d\",\"11111111111111111111111111111111\"],[\"x\",\"bd7ab3a8f4bbff02000fa95446232f0959a3bd5fa2753f495742ad25a7809732\",\"mutation\"],[\"x\",\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\",\"root\"],[\"x\",\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\",\"parent\"],[\"p\",\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"]],\"{\\\"author_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"authored_at_unix_s\\\":1784347200,\\\"body\\\":{\\\"candidate_id\\\":\\\"cd6471a9bc91766a455e4adb59a63c8b26881c80e2f0d96a2e882bbf75b7d533\\\",\\\"decision\\\":{\\\"decision\\\":\\\"declined\\\",\\\"reason\\\":\\\"inventory unavailable\\\"},\\\"mutation_type\\\":\\\"revision_decision\\\",\\\"proposal_mutation_id\\\":\\\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\\\"},\\\"buyer_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"contract_id\\\":\\\"radroots.trade.revision_decision.v1\\\",\\\"counterparty_pubkey\\\":\\\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\\\",\\\"farm_id\\\":\\\"farm-1\\\",\\\"mutation_id\\\":\\\"bd7ab3a8f4bbff02000fa95446232f0959a3bd5fa2753f495742ad25a7809732\\\",\\\"parent_mutation_ids\\\":[\\\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\\\"],\\\"root_mutation_id\\\":\\\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\\\",\\\"schema_version\\\":1,\\\"seller_pubkey\\\":\\\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\\\",\\\"trade_id\\\":\\\"11111111111111111111111111111111\\\"}\"]", + "event_id": "064a6208b3a064e472fd8f1c4d56c9ca49513693d1343feb0ceeb7a8814f93b6", + "signature": "60a9241287ba780bcccc8b3b5c85c102bfab40fa6898990f420d634f3eba01c4b9a76a6ad9efed64d111f1e39d5b5d923810c6cd2793f71d2bd19cd7fb21cdf3", + "raw_json": "{\"id\":\"064a6208b3a064e472fd8f1c4d56c9ca49513693d1343feb0ceeb7a8814f93b6\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1784347200,\"kind\":3473,\"tags\":[[\"contract\",\"radroots.trade.revision_decision.v1\"],[\"d\",\"11111111111111111111111111111111\"],[\"x\",\"bd7ab3a8f4bbff02000fa95446232f0959a3bd5fa2753f495742ad25a7809732\",\"mutation\"],[\"x\",\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\",\"root\"],[\"x\",\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\",\"parent\"],[\"p\",\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"]],\"content\":\"{\\\"author_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"authored_at_unix_s\\\":1784347200,\\\"body\\\":{\\\"candidate_id\\\":\\\"cd6471a9bc91766a455e4adb59a63c8b26881c80e2f0d96a2e882bbf75b7d533\\\",\\\"decision\\\":{\\\"decision\\\":\\\"declined\\\",\\\"reason\\\":\\\"inventory unavailable\\\"},\\\"mutation_type\\\":\\\"revision_decision\\\",\\\"proposal_mutation_id\\\":\\\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\\\"},\\\"buyer_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"contract_id\\\":\\\"radroots.trade.revision_decision.v1\\\",\\\"counterparty_pubkey\\\":\\\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\\\",\\\"farm_id\\\":\\\"farm-1\\\",\\\"mutation_id\\\":\\\"bd7ab3a8f4bbff02000fa95446232f0959a3bd5fa2753f495742ad25a7809732\\\",\\\"parent_mutation_ids\\\":[\\\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\\\"],\\\"root_mutation_id\\\":\\\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\\\",\\\"schema_version\\\":1,\\\"seller_pubkey\\\":\\\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\\\",\\\"trade_id\\\":\\\"11111111111111111111111111111111\\\"}\",\"sig\":\"60a9241287ba780bcccc8b3b5c85c102bfab40fa6898990f420d634f3eba01c4b9a76a6ad9efed64d111f1e39d5b5d923810c6cd2793f71d2bd19cd7fb21cdf3\"}" + } + }, + { + "id": "typed_trade_cancellation_016", + "kind": "authored_operations.wire", + "input": { + "contract_id": "radroots.trade.cancellation.v1", + "authoring": "typed" + }, + "expected": { + "kind": 3474, + "created_at": 1784347200, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "tags": [ + [ + "contract", + "radroots.trade.cancellation.v1" + ], + [ + "d", + "11111111111111111111111111111111" + ], + [ + "x", + "fe65c35d4a280a66a309e2834a58712ad8cf837386edb8fd25b73c946b1a273d", + "mutation" + ], + [ + "x", + "05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799", + "root" + ], + [ + "x", + "05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799", + "parent" + ], + [ + "p", + "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df" + ], + [ + "p", + "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af" + ] + ], + "content": "{\"author_pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"authored_at_unix_s\":1784347200,\"body\":{\"mutation_type\":\"cancellation\",\"reason\":\"cancelled\",\"target_candidate_id\":\"cd6471a9bc91766a455e4adb59a63c8b26881c80e2f0d96a2e882bbf75b7d533\",\"target_claim_mutation_id\":null},\"buyer_pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"contract_id\":\"radroots.trade.cancellation.v1\",\"counterparty_pubkey\":\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\",\"farm_id\":\"farm-1\",\"mutation_id\":\"fe65c35d4a280a66a309e2834a58712ad8cf837386edb8fd25b73c946b1a273d\",\"parent_mutation_ids\":[\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\"],\"root_mutation_id\":\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\",\"schema_version\":1,\"seller_pubkey\":\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\",\"trade_id\":\"11111111111111111111111111111111\"}", + "preimage": "[0,\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",1784347200,3474,[[\"contract\",\"radroots.trade.cancellation.v1\"],[\"d\",\"11111111111111111111111111111111\"],[\"x\",\"fe65c35d4a280a66a309e2834a58712ad8cf837386edb8fd25b73c946b1a273d\",\"mutation\"],[\"x\",\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\",\"root\"],[\"x\",\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\",\"parent\"],[\"p\",\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"]],\"{\\\"author_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"authored_at_unix_s\\\":1784347200,\\\"body\\\":{\\\"mutation_type\\\":\\\"cancellation\\\",\\\"reason\\\":\\\"cancelled\\\",\\\"target_candidate_id\\\":\\\"cd6471a9bc91766a455e4adb59a63c8b26881c80e2f0d96a2e882bbf75b7d533\\\",\\\"target_claim_mutation_id\\\":null},\\\"buyer_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"contract_id\\\":\\\"radroots.trade.cancellation.v1\\\",\\\"counterparty_pubkey\\\":\\\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\\\",\\\"farm_id\\\":\\\"farm-1\\\",\\\"mutation_id\\\":\\\"fe65c35d4a280a66a309e2834a58712ad8cf837386edb8fd25b73c946b1a273d\\\",\\\"parent_mutation_ids\\\":[\\\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\\\"],\\\"root_mutation_id\\\":\\\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\\\",\\\"schema_version\\\":1,\\\"seller_pubkey\\\":\\\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\\\",\\\"trade_id\\\":\\\"11111111111111111111111111111111\\\"}\"]", + "event_id": "2cb6574e48eb71825016b5fb94aed7df459948cab74b8f45f34cc1b42ca8767a", + "signature": "141c3eace0da665038626a73d336e48bc2ebc2bdee1f460bedd35034d85f199c1059f5b37c3c9b8a51730c4db0b8240223c00a0fc3fc2807cb46a7b6a0504a36", + "raw_json": "{\"id\":\"2cb6574e48eb71825016b5fb94aed7df459948cab74b8f45f34cc1b42ca8767a\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1784347200,\"kind\":3474,\"tags\":[[\"contract\",\"radroots.trade.cancellation.v1\"],[\"d\",\"11111111111111111111111111111111\"],[\"x\",\"fe65c35d4a280a66a309e2834a58712ad8cf837386edb8fd25b73c946b1a273d\",\"mutation\"],[\"x\",\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\",\"root\"],[\"x\",\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\",\"parent\"],[\"p\",\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\"],[\"p\",\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\"]],\"content\":\"{\\\"author_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"authored_at_unix_s\\\":1784347200,\\\"body\\\":{\\\"mutation_type\\\":\\\"cancellation\\\",\\\"reason\\\":\\\"cancelled\\\",\\\"target_candidate_id\\\":\\\"cd6471a9bc91766a455e4adb59a63c8b26881c80e2f0d96a2e882bbf75b7d533\\\",\\\"target_claim_mutation_id\\\":null},\\\"buyer_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"contract_id\\\":\\\"radroots.trade.cancellation.v1\\\",\\\"counterparty_pubkey\\\":\\\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\\\",\\\"farm_id\\\":\\\"farm-1\\\",\\\"mutation_id\\\":\\\"fe65c35d4a280a66a309e2834a58712ad8cf837386edb8fd25b73c946b1a273d\\\",\\\"parent_mutation_ids\\\":[\\\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\\\"],\\\"root_mutation_id\\\":\\\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\\\",\\\"schema_version\\\":1,\\\"seller_pubkey\\\":\\\"e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af\\\",\\\"trade_id\\\":\\\"11111111111111111111111111111111\\\"}\",\"sig\":\"141c3eace0da665038626a73d336e48bc2ebc2bdee1f460bedd35034d85f199c1059f5b37c3c9b8a51730c4db0b8240223c00a0fc3fc2807cb46a7b6a0504a36\"}" + } + }, + { + "id": "typed_rhi_evidence_attestation_017", + "kind": "authored_operations.wire", + "input": { + "contract_id": "radroots.rhi.evidence_attestation.v1", + "authoring": "typed" + }, + "expected": { + "kind": 3441, + "created_at": 1784347200, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "tags": [ + [ + "contract", + "radroots.rhi.evidence_attestation.v1" + ], + [ + "d", + "11111111111111111111111111111111" + ], + [ + "x", + "2222222222222222222222222222222222222222222222222222222222222222", + "claim" + ], + [ + "x", + "254686397c19fa4235eee820543e8166c0bb758af106ee998684cd9b0f548b30", + "statement" + ], + [ + "t", + "radroots:rhi-outcome:indeterminate" + ] + ], + "content": "{\"attestation_method\":\"signed_evidence_snapshot\",\"claim_mutation_id\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"contract_id\":\"radroots.rhi.evidence_attestation.v1\",\"contract_version\":1,\"evidence_manifest_digest\":\"4444444444444444444444444444444444444444444444444444444444444444\",\"evidence_policy_digest\":\"5555555555555555555555555555555555555555555555555555555555555555\",\"issuer_pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"observed_at_unix_s\":1800000000,\"outcome\":\"indeterminate\",\"projection_digest\":\"6666666666666666666666666666666666666666666666666666666666666666\",\"reason_codes\":[\"required_source_incomplete\"],\"reducer_contract_id\":\"radroots.trade.reducer.v1\",\"reducer_contract_version\":1,\"report_id\":\"254686397c19fa4235eee820543e8166c0bb758af106ee998684cd9b0f548b30\",\"statement_digest\":\"254686397c19fa4235eee820543e8166c0bb758af106ee998684cd9b0f548b30\",\"supersedes_event_id\":null,\"supersedes_report_id\":null,\"trade_generation\":7,\"trade_id\":\"11111111111111111111111111111111\"}", + "preimage": "[0,\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",1784347200,3441,[[\"contract\",\"radroots.rhi.evidence_attestation.v1\"],[\"d\",\"11111111111111111111111111111111\"],[\"x\",\"2222222222222222222222222222222222222222222222222222222222222222\",\"claim\"],[\"x\",\"254686397c19fa4235eee820543e8166c0bb758af106ee998684cd9b0f548b30\",\"statement\"],[\"t\",\"radroots:rhi-outcome:indeterminate\"]],\"{\\\"attestation_method\\\":\\\"signed_evidence_snapshot\\\",\\\"claim_mutation_id\\\":\\\"2222222222222222222222222222222222222222222222222222222222222222\\\",\\\"contract_id\\\":\\\"radroots.rhi.evidence_attestation.v1\\\",\\\"contract_version\\\":1,\\\"evidence_manifest_digest\\\":\\\"4444444444444444444444444444444444444444444444444444444444444444\\\",\\\"evidence_policy_digest\\\":\\\"5555555555555555555555555555555555555555555555555555555555555555\\\",\\\"issuer_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"observed_at_unix_s\\\":1800000000,\\\"outcome\\\":\\\"indeterminate\\\",\\\"projection_digest\\\":\\\"6666666666666666666666666666666666666666666666666666666666666666\\\",\\\"reason_codes\\\":[\\\"required_source_incomplete\\\"],\\\"reducer_contract_id\\\":\\\"radroots.trade.reducer.v1\\\",\\\"reducer_contract_version\\\":1,\\\"report_id\\\":\\\"254686397c19fa4235eee820543e8166c0bb758af106ee998684cd9b0f548b30\\\",\\\"statement_digest\\\":\\\"254686397c19fa4235eee820543e8166c0bb758af106ee998684cd9b0f548b30\\\",\\\"supersedes_event_id\\\":null,\\\"supersedes_report_id\\\":null,\\\"trade_generation\\\":7,\\\"trade_id\\\":\\\"11111111111111111111111111111111\\\"}\"]", + "event_id": "a811ca5f84414e9d817ca47b923d8fd7f61c1a03d498b32f6b0816b99d57b8ce", + "signature": "8abd1ba0b3b597629939a559e2536897b602ef4e225f1552a57ddce72dd372f98ad21891cfe10bdb1a50532f50a0dd3d9b14fd46677dccd0a4fa652cdee17063", + "raw_json": "{\"id\":\"a811ca5f84414e9d817ca47b923d8fd7f61c1a03d498b32f6b0816b99d57b8ce\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1784347200,\"kind\":3441,\"tags\":[[\"contract\",\"radroots.rhi.evidence_attestation.v1\"],[\"d\",\"11111111111111111111111111111111\"],[\"x\",\"2222222222222222222222222222222222222222222222222222222222222222\",\"claim\"],[\"x\",\"254686397c19fa4235eee820543e8166c0bb758af106ee998684cd9b0f548b30\",\"statement\"],[\"t\",\"radroots:rhi-outcome:indeterminate\"]],\"content\":\"{\\\"attestation_method\\\":\\\"signed_evidence_snapshot\\\",\\\"claim_mutation_id\\\":\\\"2222222222222222222222222222222222222222222222222222222222222222\\\",\\\"contract_id\\\":\\\"radroots.rhi.evidence_attestation.v1\\\",\\\"contract_version\\\":1,\\\"evidence_manifest_digest\\\":\\\"4444444444444444444444444444444444444444444444444444444444444444\\\",\\\"evidence_policy_digest\\\":\\\"5555555555555555555555555555555555555555555555555555555555555555\\\",\\\"issuer_pubkey\\\":\\\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\\\",\\\"observed_at_unix_s\\\":1800000000,\\\"outcome\\\":\\\"indeterminate\\\",\\\"projection_digest\\\":\\\"6666666666666666666666666666666666666666666666666666666666666666\\\",\\\"reason_codes\\\":[\\\"required_source_incomplete\\\"],\\\"reducer_contract_id\\\":\\\"radroots.trade.reducer.v1\\\",\\\"reducer_contract_version\\\":1,\\\"report_id\\\":\\\"254686397c19fa4235eee820543e8166c0bb758af106ee998684cd9b0f548b30\\\",\\\"statement_digest\\\":\\\"254686397c19fa4235eee820543e8166c0bb758af106ee998684cd9b0f548b30\\\",\\\"supersedes_event_id\\\":null,\\\"supersedes_report_id\\\":null,\\\"trade_generation\\\":7,\\\"trade_id\\\":\\\"11111111111111111111111111111111\\\"}\",\"sig\":\"8abd1ba0b3b597629939a559e2536897b602ef4e225f1552a57ddce72dd372f98ad21891cfe10bdb1a50532f50a0dd3d9b14fd46677dccd0a4fa652cdee17063\"}" + } + } + ] +} diff --git a/core/test-fixtures/tera_ffi/evidence_attestation_decision.v1.json b/core/test-fixtures/tera_ffi/evidence_attestation_decision.v1.json @@ -0,0 +1,153 @@ +{ + "suite": "rhi_evidence_attestation_decision", + "contract_version": "1.0.0", + "vectors": [ + { + "id": "rhi_evidence_attestation_current_001", + "kind": "rhi.evidence_attestation.valid", + "input": { + "statement_payload": { + "attestation_method": "signed_evidence_snapshot", + "claim_mutation_id": "2222222222222222222222222222222222222222222222222222222222222222", + "contract_id": "radroots.rhi.evidence_attestation.v1", + "contract_version": 1, + "evidence_manifest_digest": "4444444444444444444444444444444444444444444444444444444444444444", + "evidence_policy_digest": "5555555555555555555555555555555555555555555555555555555555555555", + "issuer_pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "observed_at_unix_s": 1800000000, + "outcome": "indeterminate", + "projection_digest": "6666666666666666666666666666666666666666666666666666666666666666", + "reason_codes": ["required_source_incomplete"], + "reducer_contract_id": "radroots.trade.reducer.v1", + "reducer_contract_version": 1, + "supersedes_event_id": null, + "supersedes_report_id": null, + "trade_generation": 7, + "trade_id": "11111111111111111111111111111111" + } + }, + "expected": { + "kind": 3441, + "event_class": "regular_immutable", + "canonical_statement_payload_utf8": "{\"attestation_method\":\"signed_evidence_snapshot\",\"claim_mutation_id\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"contract_id\":\"radroots.rhi.evidence_attestation.v1\",\"contract_version\":1,\"evidence_manifest_digest\":\"4444444444444444444444444444444444444444444444444444444444444444\",\"evidence_policy_digest\":\"5555555555555555555555555555555555555555555555555555555555555555\",\"issuer_pubkey\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"observed_at_unix_s\":1800000000,\"outcome\":\"indeterminate\",\"projection_digest\":\"6666666666666666666666666666666666666666666666666666666666666666\",\"reason_codes\":[\"required_source_incomplete\"],\"reducer_contract_id\":\"radroots.trade.reducer.v1\",\"reducer_contract_version\":1,\"supersedes_event_id\":null,\"supersedes_report_id\":null,\"trade_generation\":7,\"trade_id\":\"11111111111111111111111111111111\"}", + "statement_digest": "461acfea579481f6aadba47c31abc07eaa700f66abc074b8926ba41266082b44", + "report_id": "461acfea579481f6aadba47c31abc07eaa700f66abc074b8926ba41266082b44", + "canonical_event_content_utf8": "{\"attestation_method\":\"signed_evidence_snapshot\",\"claim_mutation_id\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"contract_id\":\"radroots.rhi.evidence_attestation.v1\",\"contract_version\":1,\"evidence_manifest_digest\":\"4444444444444444444444444444444444444444444444444444444444444444\",\"evidence_policy_digest\":\"5555555555555555555555555555555555555555555555555555555555555555\",\"issuer_pubkey\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"observed_at_unix_s\":1800000000,\"outcome\":\"indeterminate\",\"projection_digest\":\"6666666666666666666666666666666666666666666666666666666666666666\",\"reason_codes\":[\"required_source_incomplete\"],\"reducer_contract_id\":\"radroots.trade.reducer.v1\",\"reducer_contract_version\":1,\"report_id\":\"461acfea579481f6aadba47c31abc07eaa700f66abc074b8926ba41266082b44\",\"statement_digest\":\"461acfea579481f6aadba47c31abc07eaa700f66abc074b8926ba41266082b44\",\"supersedes_event_id\":null,\"supersedes_report_id\":null,\"trade_generation\":7,\"trade_id\":\"11111111111111111111111111111111\"}", + "tags": [ + ["contract", "radroots.rhi.evidence_attestation.v1"], + ["d", "11111111111111111111111111111111"], + ["x", "2222222222222222222222222222222222222222222222222222222222222222", "claim"], + ["x", "461acfea579481f6aadba47c31abc07eaa700f66abc074b8926ba41266082b44", "statement"], + ["t", "radroots:rhi-outcome:indeterminate"] + ] + } + }, + { + "id": "rhi_evidence_attestation_superseding_002", + "kind": "rhi.evidence_attestation.valid", + "input": { + "statement_payload": { + "attestation_method": "signed_evidence_snapshot", + "claim_mutation_id": "2222222222222222222222222222222222222222222222222222222222222222", + "contract_id": "radroots.rhi.evidence_attestation.v1", + "contract_version": 1, + "evidence_manifest_digest": "4444444444444444444444444444444444444444444444444444444444444444", + "evidence_policy_digest": "5555555555555555555555555555555555555555555555555555555555555555", + "issuer_pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "observed_at_unix_s": 1800000100, + "outcome": "valid", + "projection_digest": "6666666666666666666666666666666666666666666666666666666666666666", + "reason_codes": ["scope_satisfied"], + "reducer_contract_id": "radroots.trade.reducer.v1", + "reducer_contract_version": 1, + "supersedes_event_id": "8888888888888888888888888888888888888888888888888888888888888888", + "supersedes_report_id": "7777777777777777777777777777777777777777777777777777777777777777", + "trade_generation": 8, + "trade_id": "11111111111111111111111111111111" + } + }, + "expected": { + "canonical_statement_payload_utf8": "{\"attestation_method\":\"signed_evidence_snapshot\",\"claim_mutation_id\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"contract_id\":\"radroots.rhi.evidence_attestation.v1\",\"contract_version\":1,\"evidence_manifest_digest\":\"4444444444444444444444444444444444444444444444444444444444444444\",\"evidence_policy_digest\":\"5555555555555555555555555555555555555555555555555555555555555555\",\"issuer_pubkey\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"observed_at_unix_s\":1800000100,\"outcome\":\"valid\",\"projection_digest\":\"6666666666666666666666666666666666666666666666666666666666666666\",\"reason_codes\":[\"scope_satisfied\"],\"reducer_contract_id\":\"radroots.trade.reducer.v1\",\"reducer_contract_version\":1,\"supersedes_event_id\":\"8888888888888888888888888888888888888888888888888888888888888888\",\"supersedes_report_id\":\"7777777777777777777777777777777777777777777777777777777777777777\",\"trade_generation\":8,\"trade_id\":\"11111111111111111111111111111111\"}", + "statement_digest": "61af3caa6a3a14ff7bb026e9f294826d1d1957a5aff3d814156acee9cf0d5807", + "report_id": "61af3caa6a3a14ff7bb026e9f294826d1d1957a5aff3d814156acee9cf0d5807", + "canonical_event_content_utf8": "{\"attestation_method\":\"signed_evidence_snapshot\",\"claim_mutation_id\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"contract_id\":\"radroots.rhi.evidence_attestation.v1\",\"contract_version\":1,\"evidence_manifest_digest\":\"4444444444444444444444444444444444444444444444444444444444444444\",\"evidence_policy_digest\":\"5555555555555555555555555555555555555555555555555555555555555555\",\"issuer_pubkey\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"observed_at_unix_s\":1800000100,\"outcome\":\"valid\",\"projection_digest\":\"6666666666666666666666666666666666666666666666666666666666666666\",\"reason_codes\":[\"scope_satisfied\"],\"reducer_contract_id\":\"radroots.trade.reducer.v1\",\"reducer_contract_version\":1,\"report_id\":\"61af3caa6a3a14ff7bb026e9f294826d1d1957a5aff3d814156acee9cf0d5807\",\"statement_digest\":\"61af3caa6a3a14ff7bb026e9f294826d1d1957a5aff3d814156acee9cf0d5807\",\"supersedes_event_id\":\"8888888888888888888888888888888888888888888888888888888888888888\",\"supersedes_report_id\":\"7777777777777777777777777777777777777777777777777777777777777777\",\"trade_generation\":8,\"trade_id\":\"11111111111111111111111111111111\"}", + "tags": [ + ["contract", "radroots.rhi.evidence_attestation.v1"], + ["d", "11111111111111111111111111111111"], + ["x", "2222222222222222222222222222222222222222222222222222222222222222", "claim"], + ["x", "61af3caa6a3a14ff7bb026e9f294826d1d1957a5aff3d814156acee9cf0d5807", "statement"], + ["t", "radroots:rhi-outcome:valid"], + ["x", "7777777777777777777777777777777777777777777777777777777777777777", "supersedes_report"], + ["e", "8888888888888888888888888888888888888888888888888888888888888888"] + ], + "mutates_prior_report": false + } + }, + { + "id": "rhi_evidence_attestation_wrong_kind_003", + "kind": "rhi.evidence_attestation.invalid", + "input": { "base": "rhi_evidence_attestation_current_001", "kind": 3440 }, + "expected": { "layer": "wire", "error_code": "invalid_attestation_kind" } + }, + { + "id": "rhi_evidence_attestation_wrong_author_004", + "kind": "rhi.evidence_attestation.invalid", + "input": { "base": "rhi_evidence_attestation_current_001", "event_author": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" }, + "expected": { "layer": "wire", "error_code": "issuer_author_mismatch" } + }, + { + "id": "rhi_evidence_attestation_noncanonical_content_005", + "kind": "rhi.evidence_attestation.invalid", + "input": { "base": "rhi_evidence_attestation_current_001", "content_transform": "append_ascii_space" }, + "expected": { "layer": "wire", "error_code": "noncanonical_report_content" } + }, + { + "id": "rhi_evidence_attestation_digest_mismatch_006", + "kind": "rhi.evidence_attestation.invalid", + "input": { "base": "rhi_evidence_attestation_current_001", "statement_digest": "0000000000000000000000000000000000000000000000000000000000000000" }, + "expected": { "layer": "wire", "error_code": "statement_digest_mismatch" } + }, + { + "id": "rhi_evidence_attestation_unknown_outcome_007", + "kind": "rhi.evidence_attestation.invalid", + "input": { "base": "rhi_evidence_attestation_current_001", "outcome": "complete" }, + "expected": { "layer": "wire", "error_code": "invalid_outcome" } + }, + { + "id": "rhi_evidence_attestation_missing_claim_tag_008", + "kind": "rhi.evidence_attestation.invalid", + "input": { "base": "rhi_evidence_attestation_current_001", "remove_tag": ["x", "claim"] }, + "expected": { "layer": "wire", "error_code": "missing_claim_tag" } + }, + { + "id": "rhi_evidence_attestation_duplicate_trade_tag_009", + "kind": "rhi.evidence_attestation.invalid", + "input": { "base": "rhi_evidence_attestation_current_001", "append_tag": ["d", "99999999999999999999999999999999"] }, + "expected": { "layer": "wire", "error_code": "duplicate_trade_tag" } + }, + { + "id": "rhi_evidence_attestation_duplicate_statement_tag_010", + "kind": "rhi.evidence_attestation.invalid", + "input": { "base": "rhi_evidence_attestation_current_001", "append_tag": ["x", "9999999999999999999999999999999999999999999999999999999999999999", "statement"] }, + "expected": { "layer": "wire", "error_code": "duplicate_statement_tag" } + }, + { + "id": "rhi_evidence_attestation_incomplete_supersession_011", + "kind": "rhi.evidence_attestation.invalid", + "input": { "base": "rhi_evidence_attestation_current_001", "supersedes_report_id": "7777777777777777777777777777777777777777777777777777777777777777", "supersedes_event_id": null }, + "expected": { "layer": "wire", "error_code": "incomplete_supersession_reference" } + }, + { + "id": "rhi_evidence_attestation_stale_supersession_012", + "kind": "rhi.evidence_attestation.invalid", + "input": { "base": "rhi_evidence_attestation_superseding_002", "current_trade_generation": 8, "candidate_trade_generation": 7 }, + "expected": { "layer": "admission", "error_code": "stale_trade_generation" } + }, + { + "id": "rhi_evidence_attestation_caller_structural_tag_013", + "kind": "rhi.evidence_attestation.invalid", + "input": { "builder_extra_tags": [["d", "11111111111111111111111111111111"]] }, + "expected": { "layer": "builder", "error_code": "caller_structural_tag_forbidden" } + } + ] +} diff --git a/crates/source_lock/Cargo.toml b/crates/source_lock/Cargo.toml @@ -12,12 +12,5 @@ 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 @@ -1,201 +0,0 @@ -//! 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(), - } - } -}