commit 2b38ca7345e0e59e5d29ce3e37f27cf01a8fa256
parent a5eec5bd9ecbffcc462ac0fa9c395d380cb00b15
Author: triesap <tyson@radroots.org>
Date: Mon, 27 Apr 2026 10:37:18 +0000
cli: harden seller dry run preflight
- preflight farm create and update dry runs through farm runtime validation
- preflight listing create dry runs with output collision checks
- expose farm update values through the target cli contract
- verify target cli and signer runtime mode integration tests
Diffstat:
7 files changed, 348 insertions(+), 77 deletions(-)
diff --git a/src/operation_adapter.rs b/src/operation_adapter.rs
@@ -956,7 +956,8 @@ fn value_to_data(value: Value) -> OperationData {
fn target_operation_input(command: &crate::target_cli::TargetCommand) -> OperationData {
use crate::target_cli::{
AccountCommand, AccountSelectionCommand, BasketCommand, BasketItemCommand,
- BasketQuoteCommand, FarmCommand, ListingCommand, MarketCommand, MarketListingCommand,
+ BasketQuoteCommand, FarmCommand, FarmFulfillmentCommand, FarmLocationCommand,
+ FarmProfileCommand, ListingCommand, MarketCommand, MarketListingCommand,
MarketProductCommand, OrderCommand, OrderEventCommand, TargetCommand,
};
@@ -994,12 +995,24 @@ fn target_operation_input(command: &crate::target_cli::TargetCommand) -> Operati
insert_string(&mut input, "country", &args.country);
insert_string(&mut input, "delivery_method", &args.delivery_method);
}
- FarmCommand::Get
- | FarmCommand::Profile(_)
- | FarmCommand::Location(_)
- | FarmCommand::Fulfillment(_)
- | FarmCommand::Readiness(_)
- | FarmCommand::Publish => {}
+ FarmCommand::Profile(args) => match &args.command {
+ FarmProfileCommand::Update(args) => {
+ insert_string(&mut input, "field", &args.field);
+ insert_string(&mut input, "value", &args.value);
+ }
+ },
+ FarmCommand::Location(args) => match &args.command {
+ FarmLocationCommand::Update(args) => {
+ insert_string(&mut input, "field", &args.field);
+ insert_string(&mut input, "value", &args.value);
+ }
+ },
+ FarmCommand::Fulfillment(args) => match &args.command {
+ FarmFulfillmentCommand::Update(args) => {
+ insert_string(&mut input, "value", &args.value);
+ }
+ },
+ FarmCommand::Get | FarmCommand::Readiness(_) | FarmCommand::Publish => {}
},
TargetCommand::Listing(args) => match &args.command {
ListingCommand::Create(args) => {
diff --git a/src/operation_farm.rs b/src/operation_farm.rs
@@ -1,5 +1,5 @@
use serde::Serialize;
-use serde_json::{Value, json};
+use serde_json::Value;
use crate::domain::runtime::{CommandDisposition, FarmPublishView};
use crate::operation_adapter::{
@@ -49,12 +49,8 @@ impl OperationService<FarmCreateRequest> for FarmOperationService<'_> {
delivery_method: string_input(&request, "delivery_method"),
};
if request.context.dry_run {
- return json_operation_result::<FarmCreateResult>(json!({
- "state": "dry_run",
- "scope": args.scope.map(scope_name),
- "name": args.name,
- "location": args.location,
- }));
+ let view = map_runtime(crate::runtime::farm::init_preflight(self.config, &args))?;
+ return serialized_operation_result::<FarmCreateResult, _>(&view);
}
let view = map_runtime(crate::runtime::farm::init(self.config, &args))?;
@@ -171,11 +167,8 @@ where
value: vec![value.clone()],
};
if request.context.dry_run {
- return json_operation_result::<R>(json!({
- "state": "dry_run",
- "field": field_name(field),
- "value": value,
- }));
+ let view = map_runtime(crate::runtime::farm::set_preflight(config, &args))?;
+ return serialized_operation_result::<R, _>(&view);
}
let view = map_runtime(crate::runtime::farm::set(config, &args))?;
@@ -243,13 +236,6 @@ fn farm_publish_result(
}
}
-fn json_operation_result<R>(value: Value) -> Result<OperationResult<R>, OperationAdapterError>
-where
- R: OperationResultData,
-{
- OperationResult::new(R::from_value(value))
-}
-
fn map_runtime<T>(result: Result<T, RuntimeError>) -> Result<T, OperationAdapterError> {
result.map_err(|error| OperationAdapterError::Runtime(error.to_string()))
}
@@ -312,29 +298,6 @@ fn invalid_input(operation_id: &str, message: String) -> OperationAdapterError {
}
}
-fn scope_name(scope: FarmScopeArg) -> &'static str {
- match scope {
- FarmScopeArg::User => "user",
- FarmScopeArg::Workspace => "workspace",
- }
-}
-
-fn field_name(field: FarmFieldArg) -> &'static str {
- match field {
- FarmFieldArg::Name => "name",
- FarmFieldArg::DisplayName => "display_name",
- FarmFieldArg::About => "about",
- FarmFieldArg::Website => "website",
- FarmFieldArg::Picture => "picture",
- FarmFieldArg::Banner => "banner",
- FarmFieldArg::Location => "location",
- FarmFieldArg::City => "city",
- FarmFieldArg::Region => "region",
- FarmFieldArg::Country => "country",
- FarmFieldArg::Delivery => "delivery",
- }
-}
-
#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};
@@ -394,7 +357,7 @@ mod tests {
assert_eq!(envelope.operation_id, "farm.create");
assert_eq!(envelope.dry_run, true);
- assert_eq!(envelope.result["state"], "dry_run");
+ assert_eq!(envelope.result["state"], "unconfigured");
let readiness = OperationRequest::new(
OperationContext::default(),
diff --git a/src/operation_listing.rs b/src/operation_listing.rs
@@ -1,7 +1,7 @@
use std::path::PathBuf;
use serde::Serialize;
-use serde_json::{Value, json};
+use serde_json::Value;
use crate::domain::runtime::{CommandDisposition, ListingMutationView};
use crate::operation_adapter::{
@@ -52,12 +52,11 @@ impl OperationService<ListingCreateRequest> for ListingOperationService<'_> {
label: string_input(&request, "label"),
};
if request.context.dry_run {
- return json_operation_result::<ListingCreateResult>(json!({
- "state": "dry_run",
- "output": args.output.as_ref().map(|path| path.display().to_string()),
- "key": args.key,
- "title": args.title,
- }));
+ let view = map_runtime(
+ request.operation_id(),
+ crate::runtime::listing::scaffold_preflight(self.config, &args),
+ )?;
+ return serialized_operation_result::<ListingCreateResult, _>(&view);
}
let view = map_runtime(
@@ -246,13 +245,6 @@ where
}
}
-fn json_operation_result<R>(value: Value) -> Result<OperationResult<R>, OperationAdapterError>
-where
- R: OperationResultData,
-{
- OperationResult::new(R::from_value(value))
-}
-
fn map_runtime<T>(
operation_id: &str,
result: Result<T, RuntimeError>,
diff --git a/src/runtime/farm.rs b/src/runtime/farm.rs
@@ -50,6 +50,38 @@ pub fn init(config: &RuntimeConfig, args: &FarmCreateArgs) -> Result<FarmSetupVi
)
}
+pub fn init_preflight(
+ config: &RuntimeConfig,
+ args: &FarmCreateArgs,
+) -> Result<FarmSetupView, RuntimeError> {
+ let scope = scope_from_arg(args.scope);
+ let resolved_scope = farm_config::resolve_scope(&config.paths, scope)?;
+ let Some(selected_account) = selected_account_for_draft(config)? else {
+ return Ok(missing_selected_account_setup_view());
+ };
+ let existing = farm_config::load(config, Some(resolved_scope))?;
+ let document = init_document(resolved_scope, &selected_account, existing.as_ref(), args)?;
+ let path = farm_config::config_path(&config.paths, resolved_scope)?;
+ Ok(FarmSetupView {
+ state: "dry_run".to_owned(),
+ source: FARM_CONFIG_SOURCE.to_owned(),
+ config: Some(summary_view(
+ resolved_scope,
+ path.display().to_string(),
+ &document,
+ Some(
+ selected_account
+ .record
+ .public_identity
+ .public_key_hex
+ .as_str(),
+ ),
+ )),
+ reason: Some("dry run requested; farm draft was not written".to_owned()),
+ actions: farm_setup_actions(&document),
+ })
+}
+
pub fn set(config: &RuntimeConfig, args: &FarmUpdateArgs) -> Result<FarmSetView, RuntimeError> {
let scope = scope_from_arg(args.scope);
let resolved_scope = farm_config::resolve_scope(&config.paths, scope)?;
@@ -91,6 +123,49 @@ pub fn set(config: &RuntimeConfig, args: &FarmUpdateArgs) -> Result<FarmSetView,
})
}
+pub fn set_preflight(
+ config: &RuntimeConfig,
+ args: &FarmUpdateArgs,
+) -> Result<FarmSetView, RuntimeError> {
+ let scope = scope_from_arg(args.scope);
+ let resolved_scope = farm_config::resolve_scope(&config.paths, scope)?;
+ let path = farm_config::config_path(&config.paths, resolved_scope)?;
+ let Some(mut resolved) = farm_config::load(config, Some(resolved_scope))? else {
+ return Ok(FarmSetView {
+ state: "unconfigured".to_owned(),
+ source: FARM_CONFIG_SOURCE.to_owned(),
+ field: human_field_name(args.field).to_owned(),
+ value: human_field_value(args.field, args.value.join(" ").trim()).to_owned(),
+ config: None,
+ reason: Some(format!("no farm draft found at {}", path.display())),
+ actions: vec!["radroots farm create".to_owned()],
+ });
+ };
+
+ let raw_value = args.value.join(" ");
+ let field_value = required_text(raw_value.as_str(), "farm set value")?;
+ apply_field_update(&mut resolved.document, args.field, field_value.as_str())?;
+ let configured_account = configured_account(config, &resolved.document.selection.account)?;
+ let account_pubkey = configured_account
+ .as_ref()
+ .map(|account| account.record.public_identity.public_key_hex.as_str());
+
+ Ok(FarmSetView {
+ state: "dry_run".to_owned(),
+ source: FARM_CONFIG_SOURCE.to_owned(),
+ field: human_field_name(args.field).to_owned(),
+ value: human_field_value(args.field, field_value.as_str()).to_owned(),
+ config: Some(summary_view(
+ resolved.scope,
+ path.display().to_string(),
+ &resolved.document,
+ account_pubkey,
+ )),
+ reason: Some("dry run requested; farm draft was not written".to_owned()),
+ actions: vec!["radroots farm readiness check".to_owned()],
+ })
+}
+
pub fn status(
config: &RuntimeConfig,
args: &FarmScopedArgs,
diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs
@@ -222,6 +222,40 @@ pub fn scaffold(
})
}
+pub fn scaffold_preflight(
+ config: &RuntimeConfig,
+ args: &ListingCreateArgs,
+) -> Result<ListingNewView, RuntimeError> {
+ let (draft, defaults) = build_listing_draft(config, args)?;
+ let output_path = listing_output_path(config, args.output.as_ref(), &draft.listing.d_tag)?;
+ validate_listing_output_target(&output_path)?;
+
+ let mut actions = vec![format!(
+ "radroots listing validate {}",
+ output_path.display()
+ )];
+ if defaults.selected_account_pubkey.is_none() {
+ actions.push("radroots account create".to_owned());
+ }
+ if let Some(action) = &defaults.farm_next_action {
+ actions.push(action.clone());
+ }
+
+ Ok(ListingNewView {
+ state: "dry_run".to_owned(),
+ source: LISTING_SOURCE.to_owned(),
+ file: output_path.display().to_string(),
+ listing_id: draft.listing.d_tag,
+ selected_account_id: defaults.selected_account_id,
+ seller_pubkey: defaults.selected_account_pubkey,
+ farm_d_tag: defaults.selected_farm_d_tag,
+ delivery_method: non_empty(draft.delivery.method.clone()),
+ location_primary: non_empty(draft.location.primary.clone()),
+ reason: Some("dry run requested; listing draft was not written".to_owned()),
+ actions,
+ })
+}
+
fn build_listing_draft(
config: &RuntimeConfig,
args: &ListingCreateArgs,
@@ -305,16 +339,31 @@ fn write_listing_draft(
draft: &ListingDraftDocument,
overwrite: bool,
) -> Result<(), RuntimeError> {
- if output_path.exists() && !overwrite {
+ if !overwrite {
+ validate_listing_output_target(output_path)?;
+ }
+ if let Some(parent) = output_path.parent() {
+ fs::create_dir_all(parent)?;
+ }
+ fs::write(output_path, scaffold_contents(draft)?)?;
+ Ok(())
+}
+
+fn validate_listing_output_target(output_path: &Path) -> Result<(), RuntimeError> {
+ if output_path.exists() {
return Err(RuntimeError::Config(format!(
- "listing draft output {} already exists",
+ "listing draft output {} must not already exist",
output_path.display()
)));
}
if let Some(parent) = output_path.parent() {
- fs::create_dir_all(parent)?;
+ if parent.exists() && !parent.is_dir() {
+ return Err(RuntimeError::Config(format!(
+ "listing draft parent {} is not a directory",
+ parent.display()
+ )));
+ }
}
- fs::write(output_path, scaffold_contents(draft)?)?;
Ok(())
}
diff --git a/src/target_cli.rs b/src/target_cli.rs
@@ -144,13 +144,13 @@ impl TargetCommand {
FarmCommand::Create(_) => "farm.create",
FarmCommand::Get => "farm.get",
FarmCommand::Profile(profile) => match profile.command {
- FarmProfileCommand::Update => "farm.profile.update",
+ FarmProfileCommand::Update(_) => "farm.profile.update",
},
FarmCommand::Location(location) => match location.command {
- FarmLocationCommand::Update => "farm.location.update",
+ FarmLocationCommand::Update(_) => "farm.location.update",
},
FarmCommand::Fulfillment(fulfillment) => match fulfillment.command {
- FarmFulfillmentCommand::Update => "farm.fulfillment.update",
+ FarmFulfillmentCommand::Update(_) => "farm.fulfillment.update",
},
FarmCommand::Readiness(readiness) => match readiness.command {
FarmReadinessCommand::Check => "farm.readiness.check",
@@ -512,9 +512,17 @@ pub struct FarmProfileArgs {
pub command: FarmProfileCommand,
}
-#[derive(Debug, Clone, Copy, Subcommand)]
+#[derive(Debug, Clone, Subcommand)]
pub enum FarmProfileCommand {
- Update,
+ Update(FarmProfileUpdateArgs),
+}
+
+#[derive(Debug, Clone, Args)]
+pub struct FarmProfileUpdateArgs {
+ #[arg(long)]
+ pub field: Option<String>,
+ #[arg(long)]
+ pub value: Option<String>,
}
#[derive(Debug, Clone, Args)]
@@ -523,9 +531,17 @@ pub struct FarmLocationArgs {
pub command: FarmLocationCommand,
}
-#[derive(Debug, Clone, Copy, Subcommand)]
+#[derive(Debug, Clone, Subcommand)]
pub enum FarmLocationCommand {
- Update,
+ Update(FarmLocationUpdateArgs),
+}
+
+#[derive(Debug, Clone, Args)]
+pub struct FarmLocationUpdateArgs {
+ #[arg(long)]
+ pub field: Option<String>,
+ #[arg(long)]
+ pub value: Option<String>,
}
#[derive(Debug, Clone, Args)]
@@ -534,9 +550,15 @@ pub struct FarmFulfillmentArgs {
pub command: FarmFulfillmentCommand,
}
-#[derive(Debug, Clone, Copy, Subcommand)]
+#[derive(Debug, Clone, Subcommand)]
pub enum FarmFulfillmentCommand {
- Update,
+ Update(FarmFulfillmentUpdateArgs),
+}
+
+#[derive(Debug, Clone, Args)]
+pub struct FarmFulfillmentUpdateArgs {
+ #[arg(long)]
+ pub value: Option<String>,
}
#[derive(Debug, Clone, Args)]
diff --git a/tests/target_cli.rs b/tests/target_cli.rs
@@ -619,6 +619,163 @@ fn runtime_lifecycle_dry_runs_inspect_without_changing_runtime_status() {
}
#[test]
+fn seller_dry_runs_preflight_without_mutating_farm_or_listing_files() {
+ let sandbox = RadrootsCliSandbox::new();
+ sandbox.json_success(&["--format", "json", "account", "create"]);
+
+ let farm_dry_run = sandbox.json_success(&[
+ "--format",
+ "json",
+ "--dry-run",
+ "farm",
+ "create",
+ "--name",
+ "Green Farm",
+ "--location",
+ "farmstand",
+ "--country",
+ "US",
+ "--delivery-method",
+ "pickup",
+ ]);
+ let farm_path = farm_dry_run["result"]["config"]["path"]
+ .as_str()
+ .expect("farm path");
+ assert_eq!(farm_dry_run["operation_id"], "farm.create");
+ assert_eq!(farm_dry_run["result"]["state"], "dry_run");
+ assert!(!Path::new(farm_path).exists());
+
+ let missing_update = sandbox.json_success(&[
+ "--format",
+ "json",
+ "--dry-run",
+ "farm",
+ "profile",
+ "update",
+ "--value",
+ "Dry Name",
+ ]);
+ assert_eq!(missing_update["operation_id"], "farm.profile.update");
+ assert_eq!(missing_update["result"]["state"], "unconfigured");
+ assert!(!Path::new(farm_path).exists());
+
+ let farm = sandbox.json_success(&[
+ "--format",
+ "json",
+ "farm",
+ "create",
+ "--name",
+ "Green Farm",
+ "--location",
+ "farmstand",
+ "--country",
+ "US",
+ "--delivery-method",
+ "pickup",
+ ]);
+ let farm_path = farm["result"]["config"]["path"]
+ .as_str()
+ .expect("farm path");
+ let farm_before = fs::read_to_string(farm_path).expect("farm before");
+ let farm_update = sandbox.json_success(&[
+ "--format",
+ "json",
+ "--dry-run",
+ "farm",
+ "profile",
+ "update",
+ "--value",
+ "Dry Name",
+ ]);
+ assert_eq!(farm_update["operation_id"], "farm.profile.update");
+ assert_eq!(farm_update["result"]["state"], "dry_run");
+ assert_eq!(farm_update["result"]["config"]["name"], "Dry Name");
+ assert_eq!(
+ fs::read_to_string(farm_path).expect("farm after dry-run"),
+ farm_before
+ );
+
+ let listing_path = sandbox.root().join("dry-listing.toml");
+ let listing_path_arg = listing_path.to_string_lossy();
+ let listing_dry_run = sandbox.json_success(&[
+ "--format",
+ "json",
+ "--dry-run",
+ "listing",
+ "create",
+ "--output",
+ listing_path_arg.as_ref(),
+ "--key",
+ "eggs",
+ "--title",
+ "Eggs",
+ "--category",
+ "eggs",
+ "--summary",
+ "Fresh eggs",
+ "--bin-id",
+ "bin-1",
+ "--quantity-amount",
+ "1",
+ "--quantity-unit",
+ "each",
+ "--price-amount",
+ "6",
+ "--price-currency",
+ "USD",
+ "--price-per-amount",
+ "1",
+ "--price-per-unit",
+ "each",
+ "--available",
+ "10",
+ ]);
+ assert_eq!(listing_dry_run["operation_id"], "listing.create");
+ assert_eq!(listing_dry_run["result"]["state"], "dry_run");
+ assert_eq!(listing_dry_run["result"]["file"], listing_path_arg.as_ref());
+ assert!(!listing_path.exists());
+
+ fs::write(&listing_path, "existing").expect("existing listing path");
+ let (collision_output, collision) = sandbox.json_output(&[
+ "--format",
+ "json",
+ "--dry-run",
+ "listing",
+ "create",
+ "--output",
+ listing_path_arg.as_ref(),
+ "--key",
+ "eggs",
+ ]);
+ assert!(!collision_output.status.success());
+ assert_eq!(collision["operation_id"], "listing.create");
+ assert_eq!(collision["errors"][0]["code"], "validation_failed");
+
+ let listing_file = create_listing_draft(&sandbox, "seller-dry-run");
+ make_listing_publishable(
+ &listing_file,
+ farm["result"]["config"]["farm_d_tag"]
+ .as_str()
+ .expect("farm d tag"),
+ );
+ let listing_before = fs::read_to_string(&listing_file).expect("listing before");
+ let listing_update = sandbox.json_success(&[
+ "--format",
+ "json",
+ "--dry-run",
+ "listing",
+ "update",
+ listing_file.to_string_lossy().as_ref(),
+ ]);
+ assert_eq!(listing_update["operation_id"], "listing.update");
+ assert_eq!(listing_update["result"]["state"], "dry_run");
+ assert_eq!(
+ fs::read_to_string(&listing_file).expect("listing after dry-run"),
+ listing_before
+ );
+}
+
+#[test]
fn required_approval_token_rejects_absent_empty_and_whitespace_values() {
let sandbox = RadrootsCliSandbox::new();
let public_identity = identity_public(61);