cli

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

commit a5eec5bd9ecbffcc462ac0fa9c395d380cb00b15
parent ca8e5db0964e411c0d5bb59ae6b7bdbffdc4401a
Author: triesap <tyson@radroots.org>
Date:   Mon, 27 Apr 2026 10:30:54 +0000

cli: harden core dry run preflight

- preflight workspace and store initialization without creating local files
- validate account create and selection clear dry runs without account mutation
- keep runtime lifecycle dry runs on inspection paths
- verify target cli and signer runtime mode integration tests

Diffstat:
Msrc/operation_core.rs | 34++++++++++++++++++++++++++--------
Msrc/operation_runtime.rs | 1+
Msrc/runtime/local.rs | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/runtime/management.rs | 3++-
Mtests/target_cli.rs | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 161 insertions(+), 9 deletions(-)

diff --git a/src/operation_core.rs b/src/operation_core.rs @@ -22,7 +22,8 @@ use crate::runtime::accounts::{ account_resolution_view, account_summary_view, clear_default_account, create_or_migrate_default_account, import_public_identity, preview_account_removal, preview_public_identity_import, remove_account, resolve_account_resolution, - resolve_account_selector, select_account, snapshot, unresolved_account_reason, + resolve_account_selector, secret_backend_status, select_account, snapshot, + unresolved_account_reason, }; use crate::runtime::config::RuntimeConfig; use crate::runtime::logging::LoggingState; @@ -47,11 +48,11 @@ impl OperationService<WorkspaceInitRequest> for CoreOperationService<'_> { request: OperationRequest<WorkspaceInitRequest>, ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { if request.context.dry_run { + let local = map_runtime(crate::runtime::local::init_preflight(self.config))?; return json_operation_result::<WorkspaceInitResult>(json!({ - "state": "dry_run", + "state": local.state, "profile": self.config.paths.profile, - "local_root": self.config.local.root.display().to_string(), - "replica_db_path": self.config.local.replica_db_path.display().to_string(), + "local": local, })); } @@ -195,9 +196,24 @@ impl OperationService<AccountCreateRequest> for CoreOperationService<'_> { request: OperationRequest<AccountCreateRequest>, ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { if request.context.dry_run { + let secret_backend = secret_backend_status(self.config); + if secret_backend.state != "ready" { + return Err(OperationAdapterError::OperationUnavailable { + operation_id: request.operation_id().to_owned(), + message: secret_backend + .reason + .unwrap_or_else(|| "account secret backend is not available".to_owned()), + }); + } return json_operation_result::<AccountCreateResult>(json!({ "state": "dry_run", "store_path": self.config.account.store_path.display().to_string(), + "secrets_dir": self.config.account.secrets_dir.display().to_string(), + "secret_backend": { + "state": secret_backend.state, + "active_backend": secret_backend.active_backend, + "used_fallback": secret_backend.used_fallback, + }, })); } @@ -390,8 +406,12 @@ impl OperationService<AccountSelectionClearRequest> for CoreOperationService<'_> request: OperationRequest<AccountSelectionClearRequest>, ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { if request.context.dry_run { + let resolution = map_runtime(resolve_account_resolution(self.config))?; + let account_snapshot = map_runtime(snapshot(self.config))?; return json_operation_result::<AccountSelectionClearResult>(json!({ "state": "dry_run", + "cleared_account": resolution.default_account.as_ref().map(account_summary_view), + "remaining_account_count": account_snapshot.accounts.len(), })); } @@ -412,10 +432,8 @@ impl OperationService<StoreInitRequest> for CoreOperationService<'_> { request: OperationRequest<StoreInitRequest>, ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { if request.context.dry_run { - return json_operation_result::<StoreInitResult>(json!({ - "state": "dry_run", - "path": self.config.local.replica_db_path.display().to_string(), - })); + let view = map_runtime(crate::runtime::local::init_preflight(self.config))?; + return serialized_operation_result::<StoreInitResult, _>(&view); } let view = map_runtime(crate::runtime::local::init(self.config))?; diff --git a/src/operation_runtime.rs b/src/operation_runtime.rs @@ -307,6 +307,7 @@ where target.runtime_id.as_str(), target.instance_id.as_deref(), action, + request.context.dry_run, ))?; serialized_operation_result::<R, _>(&inspection.view) } diff --git a/src/runtime/local.rs b/src/runtime/local.rs @@ -40,6 +40,33 @@ pub fn init(config: &RuntimeConfig) -> Result<LocalInitView, RuntimeError> { }) } +pub fn init_preflight(config: &RuntimeConfig) -> Result<LocalInitView, RuntimeError> { + validate_local_roots(config)?; + if config.local.replica_db_path.exists() { + let executor = SqliteExecutor::open(&config.local.replica_db_path)?; + let manifest = export_manifest(&executor)?; + return Ok(LocalInitView { + state: "ready".to_owned(), + source: LOCAL_SOURCE.to_owned(), + local_root: config.local.root.display().to_string(), + replica_db: "ready".to_owned(), + path: config.local.replica_db_path.display().to_string(), + replica_db_version: manifest.replica_db_version, + backup_format_version: manifest.backup_format_version, + }); + } + + Ok(LocalInitView { + state: "dry_run".to_owned(), + source: LOCAL_SOURCE.to_owned(), + local_root: config.local.root.display().to_string(), + replica_db: "missing".to_owned(), + path: config.local.replica_db_path.display().to_string(), + replica_db_version: String::new(), + backup_format_version: String::new(), + }) +} + pub fn status(config: &RuntimeConfig) -> Result<LocalStatusView, RuntimeError> { if !config.local.replica_db_path.exists() { return Ok(LocalStatusView { @@ -239,6 +266,34 @@ fn ensure_local_roots(config: &RuntimeConfig) -> Result<(), RuntimeError> { Ok(()) } +fn validate_local_roots(config: &RuntimeConfig) -> Result<(), RuntimeError> { + validate_directory_target(&config.local.root)?; + validate_directory_target(&config.local.backups_dir)?; + validate_directory_target(&config.local.exports_dir)?; + Ok(()) +} + +fn validate_directory_target(path: &Path) -> Result<(), RuntimeError> { + let mut candidate = path.to_path_buf(); + loop { + if candidate.exists() { + if candidate.is_dir() { + return Ok(()); + } + return Err(RuntimeError::Config(format!( + "path {} is not a directory", + candidate.display() + ))); + } + if !candidate.pop() { + return Err(RuntimeError::Config(format!( + "path {} has no existing parent directory", + path.display() + ))); + } + } +} + fn missing_backup_view(output: &Path) -> LocalBackupView { LocalBackupView { state: "unconfigured".to_owned(), diff --git a/src/runtime/management.rs b/src/runtime/management.rs @@ -160,10 +160,11 @@ pub fn inspect_action( runtime_id: &str, instance_id: Option<&str>, action: RuntimeLifecycleAction, + dry_run: bool, ) -> Result<RuntimeInspection<RuntimeActionView>, RuntimeError> { let mut context = load_management_context(config)?; let target = resolve_runtime_target(&context, runtime_id, instance_id); - if target.runtime_group == RuntimeGroup::ActiveManagedTarget { + if target.runtime_group == RuntimeGroup::ActiveManagedTarget && !dry_run { return execute_action(config, &mut context, target, action); } let inspection = inspect_target_action(&target, action, None); diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -542,6 +542,83 @@ fn store_backup_dry_run_preflights_initialized_store_without_writing_file() { } #[test] +fn core_account_store_dry_runs_preflight_without_mutating_local_state() { + let sandbox = RadrootsCliSandbox::new(); + + let workspace = sandbox.json_success(&["--format", "json", "--dry-run", "workspace", "init"]); + let workspace_db = workspace["result"]["local"]["path"] + .as_str() + .expect("workspace db path"); + assert_eq!(workspace["operation_id"], "workspace.init"); + assert_eq!(workspace["dry_run"], true); + assert_eq!(workspace["result"]["state"], "dry_run"); + assert_eq!(workspace["result"]["local"]["replica_db"], "missing"); + assert!(!Path::new(workspace_db).exists()); + + let store = sandbox.json_success(&["--format", "json", "--dry-run", "store", "init"]); + let store_db = store["result"]["path"].as_str().expect("store db path"); + assert_eq!(store["operation_id"], "store.init"); + assert_eq!(store["dry_run"], true); + assert_eq!(store["result"]["state"], "dry_run"); + assert_eq!(store["result"]["replica_db"], "missing"); + assert!(!Path::new(store_db).exists()); + + let account_create = + sandbox.json_success(&["--format", "json", "--dry-run", "account", "create"]); + assert_eq!(account_create["operation_id"], "account.create"); + assert_eq!(account_create["dry_run"], true); + assert_eq!(account_create["result"]["state"], "dry_run"); + assert_eq!(account_create["result"]["secret_backend"]["state"], "ready"); + + let account_list = sandbox.json_success(&["--format", "json", "account", "list"]); + assert_eq!(account_list["result"]["count"], 0); + + let created = sandbox.json_success(&["--format", "json", "account", "create"]); + let account_id = created["result"]["account"]["id"] + .as_str() + .expect("account id"); + let clear = sandbox.json_success(&[ + "--format", + "json", + "--dry-run", + "account", + "selection", + "clear", + ]); + assert_eq!(clear["operation_id"], "account.selection.clear"); + assert_eq!(clear["result"]["state"], "dry_run"); + assert_eq!(clear["result"]["cleared_account"]["id"], account_id); + assert_eq!(clear["result"]["remaining_account_count"], 1); + + let selection = sandbox.json_success(&["--format", "json", "account", "selection", "get"]); + assert_eq!( + selection["result"]["account_resolution"]["default_account"]["id"], + account_id + ); +} + +#[test] +fn runtime_lifecycle_dry_runs_inspect_without_changing_runtime_status() { + let sandbox = RadrootsCliSandbox::new(); + let before = sandbox.json_success(&["--format", "json", "runtime", "status", "get"]); + + for (command, operation_id, action) in [ + ("start", "runtime.start", "start"), + ("stop", "runtime.stop", "stop"), + ("restart", "runtime.restart", "restart"), + ] { + let value = sandbox.json_success(&["--format", "json", "--dry-run", "runtime", command]); + assert_eq!(value["operation_id"], operation_id); + assert_eq!(value["dry_run"], true); + assert_eq!(value["result"]["action"], action); + assert_eq!(value["result"]["runtime_id"], "radrootsd"); + } + + let after = sandbox.json_success(&["--format", "json", "runtime", "status", "get"]); + assert_eq!(after["result"], before["result"]); +} + +#[test] fn required_approval_token_rejects_absent_empty_and_whitespace_values() { let sandbox = RadrootsCliSandbox::new(); let public_identity = identity_public(61);