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:
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");