cli

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

commit f2b8a487403e2a020e205400fb637d449a7ea1fe
parent ce55a8592556ad3fe4f522232482f3cba029cc36
Author: triesap <tyson@radroots.org>
Date:   Mon, 27 Apr 2026 09:05:25 +0000

cli: enforce signed dry-run authority

- route farm publish dry-runs through runtime preflight
- classify dry-run account authority failures consistently
- validate listing archive and order submit local signer authority
- add process coverage for signed-write dry-run checks

Diffstat:
Msrc/operation_adapter.rs | 29+++++++++++++++++++++++++++++
Msrc/operation_farm.rs | 50+++++++++++++++++++++++++++++++++++++++-----------
Msrc/operation_order.rs | 2+-
Msrc/runtime/farm.rs | 65+++++++++++++++++++++++++++++++++++++++++++++++------------------
Msrc/runtime/listing.rs | 5++++-
Msrc/runtime/order.rs | 33+++++++++++++++++++++++++++++++--
Mtests/signer_runtime_modes.rs | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/target_cli.rs | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 317 insertions(+), 33 deletions(-)

diff --git a/src/operation_adapter.rs b/src/operation_adapter.rs @@ -412,6 +412,23 @@ impl OperationAdapterError { operation_id: operation_id.to_owned(), message, }, + RuntimeError::Config(_) + if contains_any( + &lowered, + &[ + "no local account", + "watch_only", + "not secret-backed", + "selected local account", + ], + ) => + { + classify_runtime_failure( + operation_id, + message, + RuntimeFailureAvailability::Unconfigured, + ) + } RuntimeError::Config(_) if looks_like_validation_failure(&lowered) => { Self::ValidationFailed { operation_id: operation_id.to_owned(), @@ -1358,6 +1375,18 @@ mod tests { "validation", 10, ), + ( + OperationAdapterError::runtime_failure( + "listing.archive", + RuntimeError::Config( + "selected local account pubkey `b` cannot sign listing seller_pubkey `a`" + .to_owned(), + ), + ), + "account_mismatch", + "account", + 5, + ), ]; for (error, code, class, exit_code) in cases { diff --git a/src/operation_farm.rs b/src/operation_farm.rs @@ -1,6 +1,7 @@ use serde::Serialize; use serde_json::{Value, json}; +use crate::domain::runtime::{CommandDisposition, FarmPublishView}; use crate::operation_adapter::{ FarmCreateRequest, FarmCreateResult, FarmFulfillmentUpdateRequest, FarmFulfillmentUpdateResult, FarmGetRequest, FarmGetResult, FarmLocationUpdateRequest, FarmLocationUpdateResult, @@ -142,22 +143,16 @@ impl OperationService<FarmPublishRequest> for FarmOperationService<'_> { print_job: bool_input(&request, "print_job").unwrap_or(false), print_event: bool_input(&request, "print_event").unwrap_or(false), }; - if request.context.dry_run { - return json_operation_result::<FarmPublishResult>(json!({ - "state": "dry_run", - "scope": args.scope.map(scope_name), - "idempotency_key": args.idempotency_key, - "signer_session_id": args.signer_session_id, - })); - } - if request.context.approval_token.is_none() { + if !request.context.dry_run && request.context.approval_token.is_none() { return Err(OperationAdapterError::approval_required( request.operation_id(), )); } - let view = map_runtime(crate::runtime::farm::publish(self.config, &args))?; - serialized_operation_result::<FarmPublishResult, _>(&view) + let view = crate::runtime::farm::publish(self.config, &args).map_err(|error| { + OperationAdapterError::runtime_failure(request.operation_id(), error) + })?; + farm_publish_result(request.operation_id(), &view) } } @@ -227,6 +222,39 @@ where OperationResult::new(R::from_serializable(value)?) } +fn farm_publish_result( + operation_id: &str, + view: &FarmPublishView, +) -> Result<OperationResult<FarmPublishResult>, OperationAdapterError> { + match view.disposition() { + CommandDisposition::Success => serialized_operation_result::<FarmPublishResult, _>(view), + CommandDisposition::Unconfigured => Err(OperationAdapterError::unconfigured( + operation_id, + view.reason + .clone() + .unwrap_or_else(|| "farm publish is unconfigured".to_owned()), + )), + CommandDisposition::ExternalUnavailable => Err(OperationAdapterError::unavailable( + operation_id, + view.reason + .clone() + .unwrap_or_else(|| "farm publish is unavailable".to_owned()), + )), + CommandDisposition::Unsupported => Err(OperationAdapterError::InvalidInput { + operation_id: operation_id.to_owned(), + message: view + .reason + .clone() + .unwrap_or_else(|| "farm publish is unsupported".to_owned()), + }), + CommandDisposition::InternalError => Err(OperationAdapterError::Runtime( + view.reason + .clone() + .unwrap_or_else(|| "farm publish failed".to_owned()), + )), + } +} + fn json_operation_result<R>(value: Value) -> Result<OperationResult<R>, OperationAdapterError> where R: OperationResultData, diff --git a/src/operation_order.rs b/src/operation_order.rs @@ -50,7 +50,7 @@ impl OperationService<OrderSubmitRequest> for OrderOperationService<'_> { config.output.dry_run = true; } let view = map_runtime(crate::runtime::order::submit(&config, &args))?; - if request.context.dry_run { + if request.context.dry_run && view.state == "unconfigured" && !view.issues.is_empty() { serialized_target_result::<OrderSubmitResult, _>(&view) } else { submit_result::<OrderSubmitResult>(request.operation_id(), &view) diff --git a/src/runtime/farm.rs b/src/runtime/farm.rs @@ -223,6 +223,7 @@ pub fn publish( format!("no farm config found at {}", path.display()), vec!["Farm draft".to_owned()], vec!["radroots farm create".to_owned()], + config.output.dry_run, false, String::new(), String::new(), @@ -241,6 +242,7 @@ pub fn publish( ), vec!["Selected account".to_owned()], vec!["radroots account create".to_owned()], + config.output.dry_run, true, resolved.document.selection.account.clone(), String::new(), @@ -256,6 +258,7 @@ pub fn publish( "farm draft is missing required fields".to_owned(), missing_field_labels(draft_missing.as_slice()), missing_field_actions(draft_missing.as_slice()), + config.output.dry_run, true, resolved.document.selection.account.clone(), account.record.public_identity.public_key_hex.clone(), @@ -267,6 +270,22 @@ pub fn publish( let profile_idempotency_key = component_idempotency_key(args, "profile")?; let farm_idempotency_key = component_idempotency_key(args, "farm")?; + let signer_authority = match resolve_farm_write_authority(config, account_pubkey.as_str()) { + Ok(authority) => authority, + Err(error) => { + return Ok(binding_error_publish_view( + config, + args, + &resolved, + &account_pubkey, + previews, + profile_idempotency_key, + farm_idempotency_key, + error, + )); + } + }; + if config.output.dry_run { return Ok(base_publish_view( "dry_run", @@ -295,23 +314,6 @@ pub fn publish( )], )); } - - let signer_authority = - match resolve_actor_write_authority(config, "farm", account_pubkey.as_str()) { - Ok(authority) => authority, - Err(error) => { - return Ok(binding_error_publish_view( - config, - args, - &resolved, - &account_pubkey, - previews, - profile_idempotency_key, - farm_idempotency_key, - error, - )); - } - }; let profile_signer_session_id = match daemon::resolve_signer_session_id( config, "farm profile", @@ -470,6 +472,7 @@ fn missing_publish_view( reason: String, missing: Vec<String>, actions: Vec<String>, + dry_run: bool, config_present: bool, selected_account_id: String, selected_account_pubkey: String, @@ -481,7 +484,7 @@ fn missing_publish_view( scope: scope.as_str().to_owned(), path, config_present, - dry_run: false, + dry_run, selected_account_id, selected_account_pubkey, farm_d_tag, @@ -494,6 +497,32 @@ fn missing_publish_view( } } +fn resolve_farm_write_authority( + config: &RuntimeConfig, + account_pubkey: &str, +) -> Result<Option<crate::runtime::signer::ActorWriteSignerAuthority>, ActorWriteBindingError> { + if !matches!( + config.signer.backend, + crate::runtime::config::SignerBackend::Local + ) { + return resolve_actor_write_authority(config, "farm", account_pubkey); + } + let signing = accounts::resolve_local_signing_identity(config) + .map_err(|error| ActorWriteBindingError::Unconfigured(error.to_string()))?; + let selected_pubkey = signing + .account + .record + .public_identity + .public_key_hex + .as_str(); + if !selected_pubkey.eq_ignore_ascii_case(account_pubkey) { + return Err(ActorWriteBindingError::Unconfigured(format!( + "selected local account pubkey `{selected_pubkey}` cannot sign farm pubkey `{account_pubkey}`" + ))); + } + Ok(None) +} + fn base_publish_view( state: &str, config: &RuntimeConfig, diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs @@ -545,7 +545,10 @@ fn mutate( let (event_preview, listing_addr) = build_listing_event_preview(&canonical)?; if config.output.dry_run - && matches!(operation, ListingMutationOperation::Publish) + && matches!( + operation, + ListingMutationOperation::Publish | ListingMutationOperation::Archive + ) && matches!(config.signer.backend, SignerBackend::Local) { validate_local_listing_signer(config, &canonical)?; diff --git a/src/runtime/order.rs b/src/runtime/order.rs @@ -31,7 +31,7 @@ use crate::domain::runtime::{ use crate::runtime::RuntimeError; use crate::runtime::accounts; use crate::runtime::config::{ - CapabilityBindingTargetKind, RuntimeConfig, WORKFLOW_TRADE_CAPABILITY, + CapabilityBindingTargetKind, RuntimeConfig, SignerBackend, WORKFLOW_TRADE_CAPABILITY, }; use crate::runtime::daemon::{self, DaemonRpcError}; use crate::runtime::signer::{ActorWriteBindingError, resolve_actor_write_authority}; @@ -461,6 +461,12 @@ pub fn submit( } if config.output.dry_run { + if let Err(error) = validate_local_order_write_authority( + config, + loaded.document.order.buyer_pubkey.as_str(), + ) { + return Ok(order_binding_error_view(config, &loaded, args, error)); + } return Ok(OrderSubmitView { state: "dry_run".to_owned(), source: ORDER_LIFECYCLE_SOURCE.to_owned(), @@ -1425,7 +1431,7 @@ fn order_binding_error_view( buyer_account_id: loaded.document.buyer_account_id.clone(), buyer_pubkey: non_empty_string(loaded.document.order.buyer_pubkey.clone()), seller_pubkey: non_empty_string(loaded.document.order.seller_pubkey.clone()), - dry_run: false, + dry_run: config.output.dry_run, deduplicated: false, idempotency_key: args.idempotency_key.clone(), signer_mode: Some(config.signer.backend.as_str().to_owned()), @@ -1438,6 +1444,29 @@ fn order_binding_error_view( } } +fn validate_local_order_write_authority( + config: &RuntimeConfig, + buyer_pubkey: &str, +) -> Result<(), ActorWriteBindingError> { + if !matches!(config.signer.backend, SignerBackend::Local) { + return Ok(()); + } + let signing = accounts::resolve_local_signing_identity(config) + .map_err(|error| ActorWriteBindingError::Unconfigured(error.to_string()))?; + let selected_pubkey = signing + .account + .record + .public_identity + .public_key_hex + .as_str(); + if !selected_pubkey.eq_ignore_ascii_case(buyer_pubkey) { + return Err(ActorWriteBindingError::Unconfigured(format!( + "selected local account pubkey `{selected_pubkey}` cannot sign order buyer_pubkey `{buyer_pubkey}`" + ))); + } + Ok(()) +} + fn order_watch_error_view( loaded: &LoadedOrderDraft, args: &OrderWatchArgs, diff --git a/tests/signer_runtime_modes.rs b/tests/signer_runtime_modes.rs @@ -391,6 +391,34 @@ fn local_listing_publish_dry_run_does_not_sign_matching_listing() { } #[test] +fn local_listing_archive_dry_run_validates_local_account_authority() { + let sandbox = RadrootsCliSandbox::new(); + sandbox.json_success(&["--format", "json", "account", "create"]); + let listing_file = create_listing_draft(&sandbox, "local-archive-mismatch"); + make_listing_publishable(&listing_file, "AAAAAAAAAAAAAAAAAAAAAw"); + let second = sandbox.json_success(&["--format", "json", "account", "create"]); + let second_account_id = second["result"]["account"]["id"] + .as_str() + .expect("second account id"); + + let (output, value) = sandbox.json_output(&[ + "--format", + "json", + "--account-id", + second_account_id, + "--dry-run", + "listing", + "archive", + listing_file.to_string_lossy().as_ref(), + ]); + + assert!(!output.status.success()); + assert_eq!(value["operation_id"], "listing.archive"); + assert_eq!(value["errors"][0]["code"], "account_mismatch"); + assert_eq!(value["errors"][0]["detail"]["class"], "account"); +} + +#[test] fn local_listing_publish_fails_when_selected_account_does_not_match_seller() { let sandbox = RadrootsCliSandbox::new(); let first = sandbox.json_success(&["--format", "json", "account", "create"]); @@ -431,6 +459,73 @@ fn local_listing_publish_fails_when_selected_account_does_not_match_seller() { } #[test] +fn local_farm_publish_dry_run_validates_secret_backed_account() { + let sandbox = RadrootsCliSandbox::new(); + sandbox.json_success(&["--format", "json", "account", "create"]); + sandbox.json_success(&[ + "--format", + "json", + "farm", + "create", + "--name", + "Green Farm", + "--location", + "farmstand", + "--country", + "US", + "--delivery-method", + "pickup", + ]); + + let value = sandbox.json_success(&["--format", "json", "--dry-run", "farm", "publish"]); + + assert_eq!(value["operation_id"], "farm.publish"); + assert_eq!(value["dry_run"], true); + assert_eq!(value["result"]["state"], "dry_run"); + assert_eq!(value["result"]["dry_run"], true); +} + +#[test] +fn watch_only_farm_publish_dry_run_fails_as_account_watch_only() { + let sandbox = RadrootsCliSandbox::new(); + let public_identity = identity_public(13); + let public_identity_file = + write_public_identity_profile(&sandbox, "watch-only-farm", &public_identity); + sandbox.json_success(&[ + "--format", + "json", + "--approval-token", + "approve", + "account", + "import", + "--default", + public_identity_file.to_string_lossy().as_ref(), + ]); + sandbox.json_success(&[ + "--format", + "json", + "farm", + "create", + "--name", + "Green Farm", + "--location", + "farmstand", + "--country", + "US", + "--delivery-method", + "pickup", + ]); + + let (output, value) = + sandbox.json_output(&["--format", "json", "--dry-run", "farm", "publish"]); + + assert!(!output.status.success()); + assert_eq!(value["operation_id"], "farm.publish"); + assert_eq!(value["errors"][0]["code"], "account_watch_only"); + assert_eq!(value["errors"][0]["detail"]["class"], "account"); +} + +#[test] fn watch_only_listing_publish_fails_as_account_watch_only() { let sandbox = RadrootsCliSandbox::new(); let public_identity = identity_public(12); diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -499,6 +499,77 @@ fn buyer_target_flow_acceptance_uses_target_operations() { } #[test] +fn ready_order_submit_dry_run_validates_local_buyer_authority() { + let sandbox = RadrootsCliSandbox::new(); + let first = sandbox.json_success(&["--format", "json", "account", "create"]); + let first_account_id = first["result"]["account"]["id"] + .as_str() + .expect("first account id"); + sandbox.json_success(&["--format", "json", "basket", "create", "ready_order"]); + sandbox.json_success(&[ + "--format", + "json", + "basket", + "item", + "add", + "ready_order", + "--listing-addr", + LISTING_ADDR, + "--bin-id", + "bin-1", + "--quantity", + "2", + ]); + let quote = sandbox.json_success(&[ + "--format", + "json", + "basket", + "quote", + "create", + "ready_order", + ]); + let order_id = quote["result"]["quote"]["order_id"] + .as_str() + .expect("order id"); + assert_eq!(quote["result"]["quote"]["ready_for_submit"], true); + assert_eq!( + quote["result"]["order"]["buyer_account_id"], + first_account_id + ); + + let dry_run = + sandbox.json_success(&["--format", "json", "--dry-run", "order", "submit", order_id]); + + assert_eq!(dry_run["operation_id"], "order.submit"); + assert_eq!(dry_run["dry_run"], true); + assert_eq!(dry_run["result"]["state"], "dry_run"); + assert_eq!(dry_run["result"]["dry_run"], true); + assert_eq!(dry_run["result"]["buyer_account_id"], first_account_id); + + let second = sandbox.json_success(&["--format", "json", "account", "create"]); + let second_account_id = second["result"]["account"]["id"] + .as_str() + .expect("second account id"); + let (output, mismatch) = sandbox.json_output(&[ + "--format", + "json", + "--account-id", + second_account_id, + "--dry-run", + "order", + "submit", + order_id, + ]); + + assert!(!output.status.success()); + assert_eq!(output.status.code(), Some(5)); + assert_eq!(mismatch["operation_id"], "order.submit"); + assert_eq!(mismatch["errors"][0]["code"], "account_mismatch"); + assert_eq!(mismatch["errors"][0]["detail"]["class"], "account"); + assert_no_removed_command_reference(&mismatch, &["order", "submit", "--dry-run"]); +} + +#[test] fn seller_target_flow_acceptance_uses_target_operations() { let sandbox = RadrootsCliSandbox::new(); let listing_file = sandbox.root().join("listing.toml");