KeiSeiKit-1.0/_primitives/_rust/kei-provision/src/exec.rs
Parfii-bot 0be354a920 KeiSeiKit-public — clean state
Single-commit clean baseline after security scrub of niche-tells,
project codenames, internal jargon, and contributor-email leaks.

Contents:
- 100 Rust crates (_primitives/_rust/)
- 37 agent manifests (_manifests/) + generated specs (_generated/)
- 67 user-invocable skills (skills/)
- 33 hooks (hooks/)
- Composition blocks (_blocks/)
- Documentation (docs/, README.md)
- TS adapter packages (_ts_packages/)
- Assembler (_assembler/)
- Roles (_roles/)
- Templates (_templates/)
- Forgejo CI (.forgejo/)

Author: Denis Parfionovich <info@greendragon.info>

License: see LICENSE.
2026-05-01 12:09:03 +08:00

134 lines
4.6 KiB
Rust

//! Shared subprocess helper for backend adapters.
//!
//! Centralises `std::process::Command` so both Hetzner and Vultr backends
//! have a single JSON-exec path. Makes test-time PATH injection uniform.
//!
//! DO NOT pass secrets as CLI args — env-only per RULE 0.8. Error
//! messages redact argv to `<bin> <N args>` and truncate stderr to 200
//! chars to avoid info-disclosure in logs (future-proofing against
//! accidental `--api-key $X` refactors + vultr-cli stderr leaking
//! request URL query params).
use anyhow::{anyhow, Context, Result};
use std::process::Command;
/// Max stderr length retained in error messages before truncation.
const STDERR_MAX: usize = 200;
/// Redact CLI args to `<bin> <N args>` — never echo full argv.
/// Protects against future secret-in-arg refactors (RULE 0.8).
fn redact_args(bin: &str, args: &[&str]) -> String {
format!("{bin} <{} args>", args.len())
}
/// Truncate stderr to `STDERR_MAX` chars, UTF-8 safe (char-boundary aware).
/// Vultr-cli stderr may echo request URLs with enumeration-useful query
/// params; truncation limits leakage into Claude logs / CI artefacts.
fn truncate_stderr(s: &str) -> String {
let s = s.trim();
if s.chars().count() <= STDERR_MAX {
return s.to_string();
}
let mut out = String::with_capacity(STDERR_MAX + 20);
for (i, ch) in s.chars().enumerate() {
if i >= STDERR_MAX {
break;
}
out.push(ch);
}
out.push_str("... (truncated)");
out
}
/// Run `bin args…` and return parsed JSON on exit code 0.
/// Returns `Ok(None)` when the child exits non-zero (caller decides if
/// that's an error or an "absent" signal).
pub fn run_json(bin: &str, args: &[&str]) -> Result<Option<serde_json::Value>> {
let output = Command::new(bin)
.args(args)
.output()
.with_context(|| format!("failed to spawn `{bin}`"))?;
if !output.status.success() {
return Ok(None);
}
let stdout = String::from_utf8(output.stdout)
.with_context(|| format!("`{bin}` stdout not utf-8"))?;
if stdout.trim().is_empty() {
return Ok(None);
}
let v: serde_json::Value = serde_json::from_str(&stdout)
.with_context(|| format!("`{bin}` did not emit valid JSON"))?;
Ok(Some(v))
}
/// Run `bin args…` and fail loudly on non-zero (create/delete paths).
/// Returns the parsed JSON or `None` for empty output.
pub fn run_json_strict(bin: &str, args: &[&str]) -> Result<Option<serde_json::Value>> {
let output = Command::new(bin)
.args(args)
.output()
.with_context(|| format!("failed to spawn `{bin}`"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!(
"`{}` failed (code {:?}): {}",
redact_args(bin, args),
output.status.code(),
truncate_stderr(&stderr)
));
}
let stdout = String::from_utf8(output.stdout)
.with_context(|| format!("`{bin}` stdout not utf-8"))?;
if stdout.trim().is_empty() {
return Ok(None);
}
let v: serde_json::Value = serde_json::from_str(&stdout)
.with_context(|| format!("`{bin}` did not emit valid JSON"))?;
Ok(Some(v))
}
/// Plain void run — success = ok, failure = err with stderr context.
pub fn run_void(bin: &str, args: &[&str]) -> Result<()> {
let output = Command::new(bin)
.args(args)
.output()
.with_context(|| format!("failed to spawn `{bin}`"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!(
"`{}` failed (code {:?}): {}",
redact_args(bin, args),
output.status.code(),
truncate_stderr(&stderr)
));
}
Ok(())
}
/// Assert a CLI binary is on PATH (friendly error).
pub fn require_cli(bin: &str, install_hint: &str) -> Result<()> {
which(bin).map(|_| ()).ok_or_else(|| {
anyhow!("`{bin}` not found on PATH. Install: {install_hint}")
})
}
fn which(bin: &str) -> Option<std::path::PathBuf> {
let path = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path) {
let candidate = dir.join(bin);
if candidate.is_file() {
return Some(candidate);
}
}
None
}
/// Assert an env var is set + non-empty (friendly error).
pub fn require_env(var: &str) -> Result<String> {
match std::env::var(var) {
Ok(v) if !v.is_empty() => Ok(v),
_ => Err(anyhow!(
"env {var} not set. Source ~/.claude/secrets/.env first (RULE 0.8)."
)),
}
}