cli

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

commit 06dd38368fab990b701b402eea3d603e2a8dc9f1
parent 9f334e6f420900509ca02d0562925b3277e6d6cb
Author: triesap <tyson@radroots.org>
Date:   Sun, 24 May 2026 18:24:25 +0000

cli: harden app order validation

- route app-order list/export/submit readiness through the shared supported payload validator
- fail closed for stale, unsupported, malformed, and conflicting app-order records
- cover supported and fail-closed app-order states with deterministic target CLI tests
- validate with cargo check, focused app-order tests, nix check, formatting, and diff hygiene

Diffstat:
Msrc/domain/runtime.rs | 4+++-
Msrc/runtime/order.rs | 144++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
Mtests/target_cli.rs | 251+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 334 insertions(+), 65 deletions(-)

diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -1501,7 +1501,9 @@ impl OrderAppRecordExportView { pub fn disposition(&self) -> CommandDisposition { match self.state.as_str() { "missing" => CommandDisposition::NotFound, - "invalid" | "stale" | "unsupported" => CommandDisposition::ValidationFailed, + "conflict" | "invalid" | "stale" | "unsupported" => { + CommandDisposition::ValidationFailed + } "error" => CommandDisposition::InternalError, _ => CommandDisposition::Success, } diff --git a/src/runtime/order.rs b/src/runtime/order.rs @@ -49,7 +49,7 @@ use radroots_events_codec::trade::{ use radroots_events_codec::wire::WireEventParts; use radroots_local_events::{ BUYER_ORDER_REQUEST_LOCAL_WORK_RECORD_KIND, LocalEventRecord, LocalRecordFamily, - LocalRecordStatus, SourceRuntime, validate_buyer_order_request_local_work_payload, + LocalRecordStatus, SourceRuntime, validate_supported_buyer_order_request_local_work_payload, }; use radroots_nostr::prelude::{ RadrootsNostrEvent, RadrootsNostrFilter, radroots_event_from_nostr, radroots_nostr_filter_tag, @@ -714,47 +714,6 @@ pub fn app_record_export( }); }; - if let Some(current_record) = current_app_order_record_for(config, &record)? - && current_record.record_id != record.record_id - { - let order_id = app_order_record_order_id(&record); - return Ok(OrderAppRecordExportView { - state: "stale".to_owned(), - source: ORDER_APP_RECORD_SOURCE.to_owned(), - record_id: args.record_id.clone(), - dry_run: config.output.dry_run, - file: args - .output - .as_ref() - .map(|path| path.display().to_string()) - .unwrap_or_default(), - valid: false, - order_id: order_id.clone(), - listing_addr: record.listing_addr.clone(), - listing_event_id: None, - buyer_account_id: record.owner_account_id.clone(), - buyer_pubkey: record.owner_pubkey.clone(), - buyer_actor_source: None, - seller_pubkey: None, - issues: vec![issue_with_code( - "app_order_stale", - "record_id", - format!( - "app-authored local order record `{}` was superseded by `{}`", - record.record_id, current_record.record_id - ), - )], - reason: Some(format!( - "app-authored local order record `{}` was superseded by current record `{}`", - record.record_id, current_record.record_id - )), - actions: vec![ - format!("radroots order app export {}", current_record.record_id), - "radroots order app list".to_owned(), - ], - }); - } - let app_order = load_app_order_record_from_record(config, record)?; let mut issues = source_and_document_issues(config, &app_order)?; if !issues.is_empty() { @@ -9499,6 +9458,25 @@ fn current_app_order_record_for( })) } +fn app_order_conflicting_record_ids_for( + config: &RuntimeConfig, + record: &LocalEventRecord, +) -> Result<Vec<String>, RuntimeError> { + if app_order_record_order_id(record).is_none() { + return Ok(Vec::new()); + } + let key = app_order_record_current_key(record); + let mut record_ids = app_order_local_records(config)? + .into_iter() + .filter(|candidate| candidate.record_id != record.record_id) + .filter(|candidate| app_order_record_current_key(candidate) == key) + .map(|candidate| candidate.record_id) + .collect::<Vec<_>>(); + record_ids.sort(); + record_ids.dedup(); + Ok(record_ids) +} + fn load_app_order_record_for_lookup( config: &RuntimeConfig, lookup: &str, @@ -9599,28 +9577,44 @@ fn app_order_record_source_issues( "app-authored order record is not marked current", )); } - if current && let Err(error) = validate_buyer_order_request_local_work_payload(payload) { + if payload["currentness"]["record_id"].as_str() != Some(record.record_id.as_str()) { issues.push(issue_with_code( "invalid_app_order_record", - "local_work_json", - error.to_string(), - )); - } - if payload["support_status"]["state"].as_str() != Some("supported") { - issues.push(issue_with_code( - "app_order_unsupported", - "support_status.state", - "app-authored order record is not marked supported", + "currentness.record_id", + "app-authored order record currentness id does not match the shared record id", )); } - if let Some(support_issues) = payload["support_status"]["issues"].as_array() { - for support_issue in support_issues { - if let Some(support_issue) = support_issue.as_str() { - issues.push(issue_with_code( - "app_order_unsupported", - "support_status.issues", - format!("app order support issue: {support_issue}"), - )); + if current { + match validate_supported_buyer_order_request_local_work_payload(payload) { + Ok(_) => {} + Err(error) => { + let support_state = payload["support_status"]["state"].as_str(); + let support_issues = payload["support_status"]["issues"] + .as_array() + .cloned() + .unwrap_or_default(); + if support_state == Some("unsupported") { + issues.push(issue_with_code( + "app_order_unsupported", + "support_status.state", + "app-authored order record is not marked supported", + )); + for support_issue in support_issues { + if let Some(support_issue) = support_issue.as_str() { + issues.push(issue_with_code( + "app_order_unsupported", + "support_status.issues", + format!("app order support issue: {support_issue}"), + )); + } + } + } else { + issues.push(issue_with_code( + "invalid_app_order_record", + "local_work_json", + error.to_string(), + )); + } } } } @@ -9636,6 +9630,17 @@ fn app_order_record_source_issues( ), )); } + let conflicting_record_ids = app_order_conflicting_record_ids_for(config, record)?; + if !conflicting_record_ids.is_empty() { + issues.push(issue_with_code( + "app_order_conflict", + "order_id", + format!( + "app-authored order id conflicts with other shared records: {}", + conflicting_record_ids.join(", ") + ), + )); + } Ok(issues) } @@ -9738,11 +9743,22 @@ fn placeholder_app_order_document(record: &LocalEventRecord) -> OrderDraftDocume } fn app_order_export_failure_state(issues: &[OrderIssueView]) -> &'static str { - if issues.iter().any(|issue| issue.code == "app_order_stale") { + if issues + .iter() + .any(|issue| issue.code == "app_order_conflict") + { + "conflict" + } else if issues.iter().any(|issue| issue.code == "app_order_stale") { "stale" - } else if issues.iter().any(|issue| { - issue.code == "app_order_unsupported" || issue.code == "invalid_app_order_record" - }) { + } else if issues + .iter() + .any(|issue| issue.code == "invalid_app_order_record") + { + "invalid" + } else if issues + .iter() + .any(|issue| issue.code == "app_order_unsupported") + { "unsupported" } else { "invalid" diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -514,6 +514,34 @@ fn seed_app_order_record_variant( support_issues: Vec<&str>, ) -> String { let record_id = format!("app:local_work:order_request:{order_id}"); + seed_app_order_record_variant_with_record_id( + sandbox, + account_id, + buyer_pubkey, + seller_pubkey, + order_id, + listing_addr, + listing_event_id, + record_id, + current, + support_state, + support_issues, + ) +} + +fn seed_app_order_record_variant_with_record_id( + sandbox: &RadrootsCliSandbox, + account_id: &str, + buyer_pubkey: &str, + seller_pubkey: &str, + order_id: &str, + listing_addr: &str, + listing_event_id: &str, + record_id: String, + current: bool, + support_state: &str, + support_issues: Vec<&str>, +) -> String { let support_issues = support_issues .into_iter() .map(|issue| Value::String(issue.to_owned())) @@ -4508,6 +4536,229 @@ fn order_app_records_fail_closed_when_not_current_or_supported() { } #[test] +fn order_app_records_fail_closed_when_unsupported() { + 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 buyer_pubkey = signer["result"]["local"]["public_identity"]["public_key_hex"] + .as_str() + .expect("buyer pubkey"); + let seller_pubkey = identity_public(75).public_key_hex; + let listing_addr = format!("30402:{seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAQ"); + let listing_event_id = "3".repeat(64); + let order_id = "018f47a8-7b2c-7000-8000-000000000013"; + let record_id = seed_app_order_record_variant( + &sandbox, + account_id, + buyer_pubkey, + seller_pubkey.as_str(), + order_id, + listing_addr.as_str(), + listing_event_id.as_str(), + true, + "unsupported", + vec!["seller_pubkey_required"], + ); + + let app_list = sandbox.json_success(&["--format", "json", "order", "app", "list"]); + assert_eq!(app_list["result"]["records"][0]["record_id"], record_id); + assert_eq!(app_list["result"]["records"][0]["ready_for_submit"], false); + assert_eq!(app_list["result"]["records"][0]["exportable"], false); + assert!( + app_list["result"]["records"][0]["reason"] + .as_str() + .expect("unsupported reason") + .contains("not marked supported") + ); + + let export_path = sandbox.root().join("unsupported-app-order.toml"); + let export_path_arg = export_path.to_string_lossy(); + let (export_output, export) = sandbox.json_output(&[ + "--format", + "json", + "order", + "app", + "export", + record_id.as_str(), + "--output", + export_path_arg.as_ref(), + ]); + assert!(!export_output.status.success()); + assert_eq!(export["operation_id"], "order.app.export"); + assert_eq!(export["errors"][0]["detail"]["state"], "unsupported"); + assert_eq!( + export["errors"][0]["detail"]["issues"][0]["code"], + "app_order_unsupported" + ); + assert!(!export_path.exists()); + + let (submit_output, submit) = + sandbox.json_output(&["--format", "json", "--dry-run", "order", "submit", order_id]); + assert!(!submit_output.status.success()); + assert_eq!( + submit["errors"][0]["detail"]["issues"][0]["code"], + "app_order_unsupported" + ); +} + +#[test] +fn order_app_records_fail_closed_when_supported_record_is_malformed() { + 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 buyer_pubkey = signer["result"]["local"]["public_identity"]["public_key_hex"] + .as_str() + .expect("buyer pubkey"); + let seller_pubkey = identity_public(75).public_key_hex; + let listing_addr = format!("30402:{seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAQ"); + let listing_event_id = "3".repeat(64); + let order_id = "018f47a8-7b2c-7000-8000-000000000014"; + let record_id = seed_app_order_record_variant( + &sandbox, + account_id, + buyer_pubkey, + seller_pubkey.as_str(), + order_id, + listing_addr.as_str(), + listing_event_id.as_str(), + true, + "supported", + vec!["unit_price_required"], + ); + + let app_list = sandbox.json_success(&["--format", "json", "order", "app", "list"]); + assert_eq!(app_list["result"]["records"][0]["record_id"], record_id); + assert_eq!(app_list["result"]["records"][0]["ready_for_submit"], false); + assert_eq!(app_list["result"]["records"][0]["exportable"], false); + assert!( + app_list["result"]["records"][0]["reason"] + .as_str() + .expect("malformed reason") + .contains("support_status.issues") + ); + + let export_path = sandbox.root().join("malformed-app-order.toml"); + let export_path_arg = export_path.to_string_lossy(); + let (export_output, export) = sandbox.json_output(&[ + "--format", + "json", + "order", + "app", + "export", + record_id.as_str(), + "--output", + export_path_arg.as_ref(), + ]); + assert!(!export_output.status.success()); + assert_eq!(export["operation_id"], "order.app.export"); + assert_eq!(export["errors"][0]["detail"]["state"], "invalid"); + assert_eq!( + export["errors"][0]["detail"]["issues"][0]["code"], + "invalid_app_order_record" + ); + assert!(!export_path.exists()); + + let (submit_output, submit) = + sandbox.json_output(&["--format", "json", "--dry-run", "order", "submit", order_id]); + assert!(!submit_output.status.success()); + assert_eq!(submit["operation_id"], "order.submit"); + assert_eq!( + submit["errors"][0]["detail"]["issues"][0]["code"], + "invalid_app_order_record" + ); +} + +#[test] +fn order_app_records_fail_closed_when_order_id_conflicts() { + 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 buyer_pubkey = signer["result"]["local"]["public_identity"]["public_key_hex"] + .as_str() + .expect("buyer pubkey"); + let seller_pubkey = identity_public(76).public_key_hex; + let listing_addr = format!("30402:{seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAQ"); + let listing_event_id = "4".repeat(64); + let order_id = "018f47a8-7b2c-7000-8000-000000000015"; + let first_record_id = seed_app_order_record( + &sandbox, + account_id, + buyer_pubkey, + seller_pubkey.as_str(), + order_id, + listing_addr.as_str(), + listing_event_id.as_str(), + ); + let conflicting_record_id = format!("app:local_work:order_request:{order_id}:conflict"); + seed_app_order_record_variant_with_record_id( + &sandbox, + account_id, + buyer_pubkey, + seller_pubkey.as_str(), + order_id, + listing_addr.as_str(), + listing_event_id.as_str(), + conflicting_record_id.clone(), + true, + "supported", + Vec::new(), + ); + + let app_list = sandbox.json_success(&["--format", "json", "order", "app", "list"]); + assert_eq!(app_list["result"]["count"], 1); + assert_eq!( + app_list["result"]["records"][0]["record_id"], + conflicting_record_id + ); + assert_eq!(app_list["result"]["records"][0]["ready_for_submit"], false); + assert_eq!(app_list["result"]["records"][0]["exportable"], false); + assert!( + app_list["result"]["records"][0]["reason"] + .as_str() + .expect("conflict reason") + .contains(first_record_id.as_str()) + ); + + let export_path = sandbox.root().join("conflicting-app-order.toml"); + let export_path_arg = export_path.to_string_lossy(); + let (export_output, export) = sandbox.json_output(&[ + "--format", + "json", + "order", + "app", + "export", + conflicting_record_id.as_str(), + "--output", + export_path_arg.as_ref(), + ]); + assert!(!export_output.status.success()); + assert_eq!(export["operation_id"], "order.app.export"); + assert_eq!(export["errors"][0]["detail"]["state"], "conflict"); + assert_eq!( + export["errors"][0]["detail"]["issues"][0]["code"], + "app_order_conflict" + ); + assert!(!export_path.exists()); + + let (submit_output, submit) = + sandbox.json_output(&["--format", "json", "--dry-run", "order", "submit", order_id]); + assert!(!submit_output.status.success()); + assert_eq!( + submit["errors"][0]["detail"]["issues"][0]["code"], + "app_order_conflict" + ); +} + +#[test] fn farm_publish_writes_acknowledged_signed_outbox_records() { let sandbox = RadrootsCliSandbox::new(); sandbox.json_success(&["--format", "json", "account", "create"]);