cli

Command-line interface for Radroots
git clone https://radroots.dev/git/cli.git
Log | Files | Refs | README | LICENSE

commit 521c79432c66e79fad640dad9e44f22ba876e00d
parent dc16f1e8b149012eaaabfec1e758e1e2d849d092
Author: triesap <tyson@radroots.org>
Date:   Sat, 23 May 2026 17:40:38 +0000

cli: harden app listing export identity

- require canonical record owner pubkeys before app listing export
- prevent app body pubkeys or exportable hints from making unresolved records publishable
- prefer canonical record owner metadata when exporting app-authored listing drafts
- cover body-only and owner-over-body app listing record cases

Diffstat:
Msrc/runtime/listing.rs | 37++++++++++++++++++++++++-------------
Mtests/target_cli.rs | 140++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
2 files changed, 160 insertions(+), 17 deletions(-)

diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs @@ -72,6 +72,7 @@ const LISTING_DRAFTS_DIR: &str = "listings/drafts"; const LISTING_SELLER_ACTOR_SOURCE_FARM_CONFIG: &str = "farm_config"; const LISTING_SELLER_ACTOR_SOURCE_RESOLVED_ACCOUNT: &str = "resolved_account"; const LISTING_SELLER_ACTOR_SOURCE_REBIND: &str = "listing_rebind"; +const CANONICAL_OWNER_PUBKEY_REQUIRED_REASON: &str = "canonical hex pubkey required before export"; const APP_RECORD_LIST_LIMIT: u32 = 500; static D_TAG_COUNTER: AtomicU64 = AtomicU64::new(0); @@ -1343,10 +1344,7 @@ fn app_record_current_key(record: &LocalEventRecord) -> String { } let (listing_id, _, _) = app_listing_display_parts(record); if let (Some(owner_pubkey), Some(listing_id)) = ( - record - .owner_pubkey - .as_deref() - .and_then(canonical_hex_pubkey), + app_record_canonical_owner_pubkey(record), listing_id.filter(|value| is_d_tag_base64url(value)), ) { return format!("listing_owner:{owner_pubkey}:{listing_id}"); @@ -1385,6 +1383,13 @@ fn canonical_hex_pubkey(value: &str) -> Option<String> { } } +fn app_record_canonical_owner_pubkey(record: &LocalEventRecord) -> Option<String> { + record + .owner_pubkey + .as_deref() + .and_then(canonical_hex_pubkey) +} + fn app_listing_display_parts( record: &LocalEventRecord, ) -> (Option<String>, Option<String>, Option<String>) { @@ -1411,6 +1416,11 @@ fn app_listing_display_parts( } fn app_record_exportability_reason(record: &LocalEventRecord) -> Option<String> { + if local_record_kind(record).as_deref() == Some(DRAFT_KIND) + && app_record_canonical_owner_pubkey(record).is_none() + { + return Some(CANONICAL_OWNER_PUBKEY_REQUIRED_REASON.to_owned()); + } let exportability = record .local_work_json .as_ref() @@ -1428,7 +1438,7 @@ fn app_record_exportability_reason(record: &LocalEventRecord) -> Option<String> .unwrap_or_default(); Some(match (state, reason) { ("identity_unresolved", "canonical_hex_pubkey_required") => { - "canonical hex pubkey required before export".to_owned() + CANONICAL_OWNER_PUBKEY_REQUIRED_REASON.to_owned() } ("identity_unresolved", _) => "app record identity is unresolved".to_owned(), (_, "") => format!("app record exportability state `{state}` is not exportable"), @@ -1462,22 +1472,23 @@ fn app_listing_draft_from_record( if let Some(reason) = app_record_exportability_reason(record) { return Err(reason); } + let owner_pubkey = app_record_canonical_owner_pubkey(record) + .ok_or_else(|| CANONICAL_OWNER_PUBKEY_REQUIRED_REASON.to_owned())?; let document = payload .get("document") .cloned() .ok_or_else(|| "record local_work_json.document is missing".to_owned())?; let mut draft = serde_json::from_value::<ListingDraftDocument>(document) .map_err(|error| format!("record listing document is invalid: {error}"))?; - if draft.seller_actor.account_id.trim().is_empty() - && let Some(account_id) = record.owner_account_id.as_ref() - { - draft.seller_actor.account_id = account_id.clone(); - } - if draft.seller_actor.pubkey.trim().is_empty() - && let Some(pubkey) = record.owner_pubkey.as_ref() + if let Some(account_id) = record + .owner_account_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) { - draft.seller_actor.pubkey = pubkey.clone(); + draft.seller_actor.account_id = account_id.to_owned(); } + draft.seller_actor.pubkey = owner_pubkey; if draft.listing.farm_d_tag.trim().is_empty() && let Some(farm_id) = record.farm_id.as_ref() { diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -356,8 +356,34 @@ fn seed_app_listing_record_variant_with_listing_addr( exportability: Option<serde_json::Value>, include_listing_addr: bool, ) -> String { + seed_app_listing_record_identity_variant( + sandbox, + account_id, + seller_pubkey, + seller_pubkey, + farm_d_tag, + listing_d_tag, + record_suffix, + title, + exportability, + include_listing_addr, + ) +} + +fn seed_app_listing_record_identity_variant( + sandbox: &RadrootsCliSandbox, + account_id: &str, + document_seller_pubkey: Option<&str>, + owner_pubkey: Option<&str>, + farm_d_tag: &str, + listing_d_tag: &str, + record_suffix: &str, + title: &str, + exportability: Option<serde_json::Value>, + include_listing_addr: bool, +) -> String { let record_id = format!("app:local_work:listing:{listing_d_tag}:{record_suffix}"); - let seller_pubkey_json = seller_pubkey + let seller_pubkey_json = document_seller_pubkey .map(|value| json!(value)) .unwrap_or_else(|| json!(null)); let mut payload = json!({ @@ -416,12 +442,12 @@ fn seed_app_listing_record_variant_with_listing_addr( created_at_ms: 1_779_000_002_000, inserted_at_ms: 1_779_000_002_000, owner_account_id: Some(account_id.to_owned()), - owner_pubkey: seller_pubkey.map(str::to_owned), + owner_pubkey: owner_pubkey.map(str::to_owned), farm_id: Some(farm_d_tag.to_owned()), listing_addr: include_listing_addr - .then_some(seller_pubkey) + .then_some(owner_pubkey) .flatten() - .map(|seller_pubkey| format!("30402:{seller_pubkey}:{listing_d_tag}")), + .map(|owner_pubkey| format!("30402:{owner_pubkey}:{listing_d_tag}")), local_work_json: Some(payload), event_id: None, event_kind: None, @@ -4002,6 +4028,112 @@ fn listing_app_records_mark_unresolved_pubkey_records_non_exportable() { } #[test] +fn listing_app_records_ignore_body_pubkey_without_owner_metadata() { + let sandbox = RadrootsCliSandbox::new(); + let account_id = "acct_body_only"; + let body_pubkey = identity_public(91).public_key_hex; + let farm_d_tag = "AAAAAAAAAAAAAAAAAAAAAw"; + let listing_d_tag = "AAAAAAAAAAAAAAAAAAAAAQ"; + let record_id = seed_app_listing_record_identity_variant( + &sandbox, + account_id, + Some(body_pubkey.as_str()), + None, + farm_d_tag, + listing_d_tag, + "body-only", + "Body Only App Eggs", + Some(json!({ "state": "exportable" })), + false, + ); + + let list = sandbox.json_success(&["--format", "json", "listing", "app", "list"]); + assert_eq!(list["result"]["count"], 1); + let listing_row = &list["result"]["records"][0]; + assert_eq!(listing_row["record_id"], record_id); + assert_eq!(listing_row["title"], "Body Only App Eggs"); + assert_eq!(listing_row["exportable"], false); + assert_eq!( + listing_row["reason"], + "canonical hex pubkey required before export" + ); + assert!( + listing_row + .get("actions") + .and_then(Value::as_array) + .is_none_or(Vec::is_empty) + ); + + let export_path = sandbox.root().join("body-only-app-eggs.toml"); + let export_path_arg = export_path.to_string_lossy(); + let (output, export) = sandbox.json_output(&[ + "--format", + "json", + "listing", + "app", + "export", + record_id.as_str(), + "--output", + export_path_arg.as_ref(), + ]); + assert!(!output.status.success()); + assert_eq!(export["result"], Value::Null); + assert_eq!(export["errors"][0]["detail"]["state"], "unsupported"); + assert_eq!( + export["errors"][0]["message"], + "canonical hex pubkey required before export" + ); + assert!(!export_path.exists()); +} + +#[test] +fn listing_app_records_export_uses_record_owner_over_body_pubkey() { + let sandbox = RadrootsCliSandbox::new(); + let account = sandbox.json_success(&["--format", "json", "account", "create"]); + let account_id = account["result"]["account"]["id"] + .as_str() + .expect("account id"); + let signer = sandbox.json_success(&["--format", "json", "signer", "status", "get"]); + let owner_pubkey = signer["result"]["local"]["public_identity"]["public_key_hex"] + .as_str() + .expect("seller pubkey"); + let body_pubkey = identity_public(92).public_key_hex; + let farm_d_tag = "AAAAAAAAAAAAAAAAAAAAAw"; + let listing_d_tag = "AAAAAAAAAAAAAAAAAAAAAQ"; + let record_id = seed_app_listing_record_identity_variant( + &sandbox, + account_id, + Some(body_pubkey.as_str()), + Some(owner_pubkey), + farm_d_tag, + listing_d_tag, + "owner-wins", + "Owner Wins App Eggs", + None, + true, + ); + + let export_path = sandbox.root().join("owner-wins-app-eggs.toml"); + let export_path_arg = export_path.to_string_lossy(); + let export = sandbox.json_success(&[ + "--format", + "json", + "listing", + "app", + "export", + record_id.as_str(), + "--output", + export_path_arg.as_ref(), + ]); + assert_eq!(export["operation_id"], "listing.app.export"); + assert_eq!(export["result"]["state"], "exported"); + assert_eq!(export["result"]["seller_pubkey"], owner_pubkey); + let exported_contents = fs::read_to_string(&export_path).expect("exported listing draft"); + assert!(exported_contents.contains(format!("pubkey = \"{owner_pubkey}\"").as_str())); + assert!(!exported_contents.contains(body_pubkey.as_str())); +} + +#[test] fn farm_publish_writes_acknowledged_signed_outbox_records() { let sandbox = RadrootsCliSandbox::new(); sandbox.json_success(&["--format", "json", "account", "create"]);