fix(project-tool): address review findings on the apply foundation

Remediation of the single-app reconcile foundation after two independent
review passes. No new feature surface — closes correctness, parity, and
safety gaps in pull/plan/apply/prune.

apply engine (manager-core):
- validate_bundle reached parity with the interactive trigger API: reject
  an empty kv/docs/files collection_glob and a malformed pubsub
  topic_pattern (previously written and silently never matched), and lift
  the queue visibility floor to MIN_QUEUE_VISIBILITY_TIMEOUT_SECS (30) —
  apply had accepted a [5,29] value the dashboard refuses. Per-kind checks
  extracted into a pure, unit-tested validate_trigger_shape.
- An omitted script `description` now means "leave as-is" (matching the
  other optional fields and the function's own documented contract)
  instead of clearing the stored value.
- Doc fixes: drop stale "next milestone" notes; record the one deliberate
  plan/apply divergence (set-but-empty secret); document delete_route_tx
  as intentionally idempotent for reconcile.

CLI:
- Gate destructive `apply --prune` behind confirmation: interactive y/N,
  or `--yes` for CI; a non-interactive prune without `--yes` refuses
  rather than silently deleting. Journey tests pass `--yes`; a new test
  proves the gate refuses and deletes nothing.
- Harden pull's filename safety: reject all control characters and the
  Unicode bidi-override / zero-width chars used for terminal/filename
  spoofing, and cap length at 200 bytes (NAME_MAX headroom).

Tested: manager-core lib (incl. queue-floor + sparse-description parity)
+ CLI bins + 9 project-tool journeys green; clippy -D warnings clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-22 21:18:51 +02:00
parent 3b650a2b14
commit d9b3e9973c
8 changed files with 364 additions and 66 deletions

View File

@@ -347,8 +347,8 @@ pub enum ApplyError {
// Service // Service
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
/// Reconcile engine. Holds trait-object repos for the read/diff path; /// Reconcile engine. Holds trait-object repos for the read/diff path; the
/// the transactional write path (next milestone) reuses the same handles. /// transactional write path (`apply`) reuses the same handles plus `pool`.
#[derive(Clone)] #[derive(Clone)]
pub struct ApplyService { pub struct ApplyService {
/// Pool for the transactional write path (`apply`). /// Pool for the transactional write path (`apply`).
@@ -385,9 +385,9 @@ impl ApplyService {
Ok(compute_diff(&current, bundle)) Ok(compute_diff(&current, bundle))
} }
/// Reconcile app `app_id` to `bundle` in a single transaction. /// Reconcile app `app_id` to `bundle` in a single transaction. Creates
/// Additive: creates and updates are applied; deletions are surfaced /// and updates are always applied; deletions of resources absent from the
/// in the diff but only executed when prune is set (next milestone). /// bundle are executed only when `prune` is set (secrets are never pruned).
/// ///
/// # Errors /// # Errors
/// `Invalid` for a bad bundle; `Backend` for repo/transaction failures. /// `Invalid` for a bad bundle; `Backend` for repo/transaction failures.
@@ -471,7 +471,10 @@ impl ApplyService {
let cur = current_scripts[&key]; let cur = current_scripts[&key];
let patch = ScriptPatch { let patch = ScriptPatch {
name: None, name: None,
description: Some(bs.description.clone()), // Sparse: `None` (omitted in the manifest) leaves the
// stored description untouched; `Some(text)` sets it.
// Mirrors `script_update_reason`'s leave-as-is rule.
description: bs.description.clone().map(Some),
source: Some(bs.source.clone()), source: Some(bs.source.clone()),
timeout_seconds: bs.timeout_seconds, timeout_seconds: bs.timeout_seconds,
memory_limit_mb: bs.memory_limit_mb, memory_limit_mb: bs.memory_limit_mb,
@@ -909,49 +912,7 @@ impl ApplyService {
t.script() t.script()
))); )));
} }
match t { validate_trigger_shape(t)?;
BundleTrigger::Email {
inbound_secret_ref, ..
} if inbound_secret_ref.trim().is_empty() => {
return Err(ApplyError::Invalid(format!(
"email trigger on `{}` needs inbound_secret_ref (a secret name)",
t.script()
)));
}
BundleTrigger::Queue {
queue_name,
visibility_timeout_secs,
..
} => {
if queue_name.trim().is_empty() {
return Err(ApplyError::Invalid("queue trigger needs queue_name".into()));
}
if let Some(v) = visibility_timeout_secs {
if !(5..=3600).contains(v) {
return Err(ApplyError::Invalid(format!(
"queue `{queue_name}`: visibility_timeout_secs must be in [5, 3600]"
)));
}
}
}
BundleTrigger::Cron {
schedule, timezone, ..
} => {
crate::cron_scheduler::validate_schedule(schedule).map_err(|e| {
ApplyError::Invalid(format!(
"cron trigger on `{}`: invalid schedule: {e}",
t.script()
))
})?;
crate::cron_scheduler::validate_timezone(timezone).map_err(|e| {
ApplyError::Invalid(format!(
"cron trigger on `{}`: invalid timezone: {e}",
t.script()
))
})?;
}
_ => {}
}
let id = t.identity(); let id = t.identity();
if !trig_keys.insert(id.clone()) { if !trig_keys.insert(id.clone()) {
return Err(ApplyError::Invalid(format!("duplicate trigger `{id}`"))); return Err(ApplyError::Invalid(format!("duplicate trigger `{id}`")));
@@ -1084,8 +1045,13 @@ fn script_update_reason(cur: &Script, desired: &BundleScript) -> Option<String>
if cur.kind != desired.kind { if cur.kind != desired.kind {
return Some("kind changed".into()); return Some("kind changed".into());
} }
if cur.description != desired.description { // Sparse like the other optional fields: an omitted (`None`) description
return Some("description changed".into()); // means "leave as-is", so only an explicitly-set value that differs
// forces an update. (Clearing a description is done in the dashboard.)
if let Some(d) = &desired.description {
if cur.description.as_ref() != Some(d) {
return Some("description changed".into());
}
} }
if let Some(t) = desired.timeout_seconds { if let Some(t) = desired.timeout_seconds {
if i64::from(cur.timeout_seconds) != i64::from(t) { if i64::from(cur.timeout_seconds) != i64::from(t) {
@@ -1286,6 +1252,12 @@ fn diff_secrets(current: &CurrentState, bundle: &Bundle) -> Vec<ResourceChange>
/// Reject any email trigger whose referenced secret isn't set, so `plan` and /// Reject any email trigger whose referenced secret isn't set, so `plan` and
/// `apply` give the same answer (apply also fails in `resolve_and_seal`, but /// `apply` give the same answer (apply also fails in `resolve_and_seal`, but
/// only after taking the lock — surfacing it here keeps plan honest). /// only after taking the lock — surfacing it here keeps plan honest).
///
/// This checks the secret *reference exists*. `resolve_and_seal` additionally
/// requires the resolved value to be a non-empty string; `plan` deliberately
/// does not decrypt secrets, so a reference to a set-but-empty secret passes
/// `plan` and is caught at `apply` — the one residual plan/apply divergence,
/// and a deliberate one (plan stays read-only and crypto-free).
fn validate_email_secrets_present( fn validate_email_secrets_present(
bundle: &Bundle, bundle: &Bundle,
secret_names: &[String], secret_names: &[String],
@@ -1396,6 +1368,89 @@ fn reject_reserved_path(path: &str) -> Result<(), ApplyError> {
Ok(()) Ok(())
} }
/// Per-kind structural validation for one desired trigger — kept at parity
/// with the interactive trigger API so `apply` can't write a trigger the
/// dashboard would have rejected. Pure (no live state), so it's unit-tested
/// directly. The script-binding checks live in `validate_bundle`.
fn validate_trigger_shape(t: &BundleTrigger) -> Result<(), ApplyError> {
match t {
BundleTrigger::Email {
inbound_secret_ref, ..
} if inbound_secret_ref.trim().is_empty() => {
return Err(ApplyError::Invalid(format!(
"email trigger on `{}` needs inbound_secret_ref (a secret name)",
t.script()
)));
}
BundleTrigger::Queue {
queue_name,
visibility_timeout_secs,
..
} => {
if queue_name.trim().is_empty() {
return Err(ApplyError::Invalid("queue trigger needs queue_name".into()));
}
if let Some(v) = visibility_timeout_secs {
// Parity with the interactive API: the floor is the dispatcher
// tick/reclaim-cadence minimum (`triggers_api`), and the ceiling
// matches `trigger_repo`'s queue check. Apply previously allowed
// a [5, 29] floor the dashboard rejects.
let min = crate::triggers_api::MIN_QUEUE_VISIBILITY_TIMEOUT_SECS;
if !(min..=3600).contains(v) {
return Err(ApplyError::Invalid(format!(
"queue `{queue_name}`: visibility_timeout_secs must be in [{min}, 3600]"
)));
}
}
}
BundleTrigger::Cron {
schedule, timezone, ..
} => {
crate::cron_scheduler::validate_schedule(schedule).map_err(|e| {
ApplyError::Invalid(format!(
"cron trigger on `{}`: invalid schedule: {e}",
t.script()
))
})?;
crate::cron_scheduler::validate_timezone(timezone).map_err(|e| {
ApplyError::Invalid(format!(
"cron trigger on `{}`: invalid timezone: {e}",
t.script()
))
})?;
}
// Parity with the interactive trigger API, which rejects an empty
// collection_glob (`create_{kv,docs,files}_trigger`).
BundleTrigger::Kv {
collection_glob, ..
}
| BundleTrigger::Docs {
collection_glob, ..
}
| BundleTrigger::Files {
collection_glob, ..
} if collection_glob.trim().is_empty() => {
return Err(ApplyError::Invalid(format!(
"{} trigger on `{}` needs a non-empty collection_glob",
t.kind_str(),
t.script()
)));
}
// Parity with `create_pubsub_trigger`'s topic-pattern check — otherwise
// a malformed pattern is written and silently never matches at dispatch.
BundleTrigger::Pubsub { topic_pattern, .. } => {
picloud_shared::validate_topic_pattern(topic_pattern).map_err(|e| {
ApplyError::Invalid(format!(
"pubsub trigger on `{}`: invalid topic_pattern: {e}",
t.script()
))
})?;
}
_ => {}
}
Ok(())
}
/// Summary of what an `apply` changed. /// Summary of what an `apply` changed.
#[derive(Debug, Default, Serialize)] #[derive(Debug, Default, Serialize)]
pub struct ApplyReport { pub struct ApplyReport {
@@ -1731,6 +1786,95 @@ mod tests {
assert_eq!(compute_diff(&current, &chg2).scripts[0].op, Op::Update); assert_eq!(compute_diff(&current, &chg2).scripts[0].op, Op::Update);
} }
#[test]
fn description_is_sparse() {
// An omitted (`None`) description must mean "leave as-is", matching
// the other optional fields — not "clear it".
let mut cur = script("a", "let x = 1;");
cur.description = Some("kept".into());
let current = CurrentState {
scripts: vec![cur],
..CurrentState::default()
};
// Omitted → NoOp (leave the stored description alone).
let mut omit = empty_bundle();
omit.scripts = vec![bundle_script("a", "let x = 1;")];
assert_eq!(compute_diff(&current, &omit).scripts[0].op, Op::NoOp);
// Same explicit value → NoOp.
let mut same = empty_bundle();
let mut s = bundle_script("a", "let x = 1;");
s.description = Some("kept".into());
same.scripts = vec![s];
assert_eq!(compute_diff(&current, &same).scripts[0].op, Op::NoOp);
// Different explicit value → Update.
let mut chg = empty_bundle();
let mut s2 = bundle_script("a", "let x = 1;");
s2.description = Some("changed".into());
chg.scripts = vec![s2];
assert_eq!(compute_diff(&current, &chg).scripts[0].op, Op::Update);
}
#[test]
fn trigger_shape_validation_matches_interactive_api() {
// Empty collection_glob is rejected (kv/docs/files).
assert!(validate_trigger_shape(&BundleTrigger::Kv {
script: "h".into(),
collection_glob: " ".into(),
ops: vec![],
dispatch_mode: None,
retry_max_attempts: None,
})
.is_err());
// A non-empty glob passes.
assert!(validate_trigger_shape(&BundleTrigger::Docs {
script: "h".into(),
collection_glob: "users".into(),
ops: vec![],
dispatch_mode: None,
retry_max_attempts: None,
})
.is_ok());
// A malformed pubsub topic_pattern (mid-pattern wildcard) is rejected;
// a valid one passes.
assert!(validate_trigger_shape(&BundleTrigger::Pubsub {
script: "h".into(),
topic_pattern: "user.*.created".into(),
dispatch_mode: None,
retry_max_attempts: None,
})
.is_err());
assert!(validate_trigger_shape(&BundleTrigger::Pubsub {
script: "h".into(),
topic_pattern: "orders.created".into(),
dispatch_mode: None,
retry_max_attempts: None,
})
.is_ok());
// Queue visibility floor matches the interactive API (>= 30): a value
// below the floor is rejected; the floor itself and `None` are ok.
let queue = |vis| BundleTrigger::Queue {
script: "h".into(),
queue_name: "jobs".into(),
visibility_timeout_secs: vis,
dispatch_mode: None,
retry_max_attempts: None,
};
assert!(validate_trigger_shape(&queue(Some(10))).is_err());
assert!(validate_trigger_shape(&queue(Some(
crate::triggers_api::MIN_QUEUE_VISIBILITY_TIMEOUT_SECS
)))
.is_ok());
assert!(validate_trigger_shape(&queue(None)).is_ok());
// Empty email inbound_secret_ref is rejected.
assert!(validate_trigger_shape(&BundleTrigger::Email {
script: "h".into(),
inbound_secret_ref: " ".into(),
dispatch_mode: None,
retry_max_attempts: None,
})
.is_err());
}
#[test] #[test]
fn trigger_diff_create_noop_delete() { fn trigger_diff_create_noop_delete() {
let s = script("h", "x"); let s = script("h", "x");

View File

@@ -202,6 +202,12 @@ pub(crate) async fn insert_route_tx(
} }
/// Delete a route by id within an existing transaction. /// Delete a route by id within an existing transaction.
///
/// Unlike the non-tx [`RouteRepository::delete`], this is intentionally
/// idempotent: a missing row is not an error. The only caller is the
/// reconcile engine (`ApplyService`), where "delete a route already gone"
/// (e.g. removed out-of-band between the diff read and the write) is a
/// no-op to converge on, not a failure to roll back the whole apply.
pub(crate) async fn delete_route_tx( pub(crate) async fn delete_route_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
route_id: Uuid, route_id: Uuid,

View File

@@ -38,7 +38,7 @@ use crate::trigger_repo::{
/// in practice (the dispatcher itself ticks every 100ms; reclaim ticks /// in practice (the dispatcher itself ticks every 100ms; reclaim ticks
/// every 30s; an executor needs more wall-clock than this to do useful /// every 30s; an executor needs more wall-clock than this to do useful
/// work). Reject below this with a 422 + actionable error. /// work). Reject below this with a 422 + actionable error.
const MIN_QUEUE_VISIBILITY_TIMEOUT_SECS: u32 = 30; pub(crate) const MIN_QUEUE_VISIBILITY_TIMEOUT_SECS: u32 = 30;
/// Default soft-warning ceiling when `PICLOUD_DISPATCHER_ASYNC_EXEC_TIMEOUT_SEC` /// Default soft-warning ceiling when `PICLOUD_DISPATCHER_ASYNC_EXEC_TIMEOUT_SEC`
/// is unset. Re-exported from the dispatcher so the single source of /// is unset. Re-exported from the dispatcher so the single source of

View File

@@ -1,11 +1,12 @@
//! `pic apply [--file picloud.toml]` — reconcile the live app to the //! `pic apply [--file picloud.toml]` — reconcile the live app to the
//! manifest's desired state in one server-side transaction. Additive in //! manifest's desired state in one server-side transaction. Creates and
//! this milestone (creates + updates); pruning of stale resources lands //! updates are applied; `--prune` additionally deletes live scripts/routes/
//! next. //! triggers absent from the manifest (secrets are never pruned).
use std::io::{IsTerminal, Write};
use std::path::Path; use std::path::Path;
use anyhow::Result; use anyhow::{Context, Result};
use crate::client::Client; use crate::client::Client;
use crate::cmds::plan::build_bundle; use crate::cmds::plan::build_bundle;
@@ -13,7 +14,7 @@ use crate::config;
use crate::manifest::Manifest; use crate::manifest::Manifest;
use crate::output::{KvBlock, OutputMode}; use crate::output::{KvBlock, OutputMode};
pub async fn run(manifest_path: &Path, prune: bool, mode: OutputMode) -> Result<()> { pub async fn run(manifest_path: &Path, prune: bool, yes: bool, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?; let creds = config::resolve()?;
let client = Client::from_creds(&creds)?; let client = Client::from_creds(&creds)?;
@@ -21,6 +22,10 @@ pub async fn run(manifest_path: &Path, prune: bool, mode: OutputMode) -> Result<
let base_dir = manifest_path.parent().unwrap_or_else(|| Path::new(".")); let base_dir = manifest_path.parent().unwrap_or_else(|| Path::new("."));
let bundle = build_bundle(&manifest, base_dir)?; let bundle = build_bundle(&manifest, base_dir)?;
if prune && !yes {
confirm_prune(&manifest.app.slug)?;
}
let report = client.apply(&manifest.app.slug, &bundle, prune).await?; let report = client.apply(&manifest.app.slug, &bundle, prune).await?;
let mut block = KvBlock::new(); let mut block = KvBlock::new();
@@ -50,3 +55,30 @@ pub async fn run(manifest_path: &Path, prune: bool, mode: OutputMode) -> Result<
block.print(mode); block.print(mode);
Ok(()) Ok(())
} }
/// `--prune` deletes live scripts/routes/triggers absent from the manifest —
/// irreversible. Require an explicit go-ahead: an interactive `y`, or `--yes`
/// for non-interactive/CI use. Refuse a non-interactive prune without `--yes`
/// rather than silently deleting (review the deletions first with `pic plan`).
fn confirm_prune(slug: &str) -> Result<()> {
if !std::io::stdin().is_terminal() {
anyhow::bail!(
"refusing to `apply --prune` non-interactively without `--yes`: prune \
deletes resources absent from the manifest and cannot be undone. \
Review with `pic plan`, then re-run with `--yes`."
);
}
eprint!(
"apply --prune will DELETE live scripts/routes/triggers on `{slug}` that are \
absent from the manifest. This cannot be undone. Continue? [y/N] "
);
std::io::stderr().flush().ok();
let mut answer = String::new();
std::io::stdin()
.read_line(&mut answer)
.context("read confirmation")?;
if !matches!(answer.trim(), "y" | "Y" | "yes" | "Yes") {
anyhow::bail!("aborted");
}
Ok(())
}

View File

@@ -62,8 +62,9 @@ pub async fn run(app_ident: &str, dir: &Path, mode: OutputMode) -> Result<()> {
for s in &scripts { for s in &scripts {
if !is_safe_filename(&s.name) { if !is_safe_filename(&s.name) {
anyhow::bail!( anyhow::bail!(
"script name {:?} is not filesystem-safe (contains a path \ "script name {:?} is not filesystem-safe (a path separator, \
separator, `..`, or a leading dot); cannot pull", `..`, a leading dot, a control character, or longer than 200 \
bytes); cannot pull",
s.name s.name
); );
} }
@@ -207,14 +208,35 @@ fn trigger_count(t: &ManifestTriggers) -> usize {
} }
/// True if `name` is safe to use as a single path component in `scripts/`. /// True if `name` is safe to use as a single path component in `scripts/`.
/// Rejects empty names, path separators, `.`/`..`, and leading dots. /// Rejects empty/over-long names, path separators, `.`/`..`, leading dots,
/// and any deceptive display character — a server-returned name is otherwise
/// written verbatim as a filename and printed to the operator's terminal.
fn is_safe_filename(name: &str) -> bool { fn is_safe_filename(name: &str) -> bool {
// Leave headroom under NAME_MAX (255 bytes on common filesystems) for the
// `.rhai` suffix.
const MAX_LEN: usize = 200;
!name.is_empty() !name.is_empty()
&& name.len() <= MAX_LEN
&& !name.starts_with('.') && !name.starts_with('.')
&& !name.contains('/') && !name.contains('/')
&& !name.contains('\\') && !name.contains('\\')
&& name != ".." && name != ".."
&& !name.contains('\0') && !name.chars().any(is_deceptive_char)
}
/// Control characters (NUL, newlines, ANSI escapes) plus the Unicode
/// bidirectional-override and zero-width/format characters used to spoof how a
/// name renders in a terminal — both classes are unsafe to print verbatim.
fn is_deceptive_char(c: char) -> bool {
c.is_control()
|| matches!(c,
'\u{200B}'..='\u{200F}' // zero-width space … LTR/RTL marks
| '\u{2028}'..='\u{2029}' // line / paragraph separators
| '\u{202A}'..='\u{202E}' // bidi embeddings / overrides
| '\u{2060}' // word joiner
| '\u{2066}'..='\u{2069}' // bidi isolates
| '\u{FEFF}' // BOM / zero-width no-break space
)
} }
/// Deserialize a trigger's `details` JSON, attributing failures to the kind. /// Deserialize a trigger's `details` JSON, attributing failures to the kind.
@@ -276,6 +298,29 @@ mod tests {
} }
} }
#[test]
fn rejects_control_chars_and_overlong() {
for bad in [
"a\nb",
"a\tb",
"line\rdrop",
"esc\x1b[2Jseq",
"rtl\u{202E}gpj.exe", // bidi override (filename spoof)
"zero\u{200B}width", // zero-width space
"bom\u{FEFF}name", // BOM
] {
assert!(!is_safe_filename(bad), "expected {bad:?} to be rejected");
}
assert!(
!is_safe_filename(&"a".repeat(201)),
"expected an over-long name to be rejected"
);
assert!(
is_safe_filename(&"a".repeat(200)),
"a 200-char name is at the limit and allowed"
);
}
#[test] #[test]
fn accepts_normal_names() { fn accepts_normal_names() {
for ok in ["create-post", "nightly_digest", "Greet", "x", "a.b"] { for ok in ["create-post", "nightly_digest", "Greet", "x", "a.b"] {

View File

@@ -159,7 +159,8 @@ enum Cmd {
}, },
/// Reconcile the live app to a `picloud.toml` manifest in one /// Reconcile the live app to a `picloud.toml` manifest in one
/// transaction (additive: creates + updates). /// transaction (creates + updates; `--prune` also deletes resources
/// absent from the manifest).
Apply(ApplyArgs), Apply(ApplyArgs),
/// Diff a `picloud.toml` manifest against the live app and print the /// Diff a `picloud.toml` manifest against the live app and print the
@@ -181,6 +182,10 @@ struct ApplyArgs {
/// Secrets are never pruned. /// Secrets are never pruned.
#[arg(long)] #[arg(long)]
prune: bool, prune: bool,
/// Skip the `--prune` confirmation prompt. Required to prune
/// non-interactively (CI).
#[arg(long)]
yes: bool,
} }
#[derive(Args)] #[derive(Args)]
@@ -1086,7 +1091,7 @@ async fn main() -> ExitCode {
} }
Cmd::Logout => cmds::logout::run().await, Cmd::Logout => cmds::logout::run().await,
Cmd::Whoami => cmds::whoami::run(mode).await, Cmd::Whoami => cmds::whoami::run(mode).await,
Cmd::Apply(args) => cmds::apply::run(&args.file, args.prune, mode).await, Cmd::Apply(args) => cmds::apply::run(&args.file, args.prune, args.yes, mode).await,
Cmd::Plan(args) => cmds::plan::run(&args.file, mode).await, Cmd::Plan(args) => cmds::plan::run(&args.file, mode).await,
Cmd::Pull(args) => cmds::pull::run(&args.app, &args.dir, mode).await, Cmd::Pull(args) => cmds::pull::run(&args.app, &args.dir, mode).await,
Cmd::Apps { cmd: AppsCmd::Ls } => cmds::apps::ls(mode).await, Cmd::Apps { cmd: AppsCmd::Ls } => cmds::apps::ls(mode).await,

View File

@@ -135,7 +135,7 @@ fn prune_refuses_to_orphan_email_trigger() {
let out = common::pic_as(&env) let out = common::pic_as(&env)
.args(["apply", "--file"]) .args(["apply", "--file"])
.arg(&manifest_path) .arg(&manifest_path)
.arg("--prune") .args(["--prune", "--yes"])
.output() .output()
.expect("apply --prune"); .expect("apply --prune");
assert!( assert!(

View File

@@ -72,11 +72,12 @@ fn prune_deletes_stale_resources() {
"additive apply must not delete" "additive apply must not delete"
); );
// Prune apply removes `drop` and its route. // Prune apply removes `drop` and its route. `--yes` skips the
// confirmation prompt (this test runs non-interactively).
let out = common::pic_as(&env) let out = common::pic_as(&env)
.args(["apply", "--file"]) .args(["apply", "--file"])
.arg(&manifest_path) .arg(&manifest_path)
.arg("--prune") .args(["--prune", "--yes"])
.output() .output()
.expect("apply --prune"); .expect("apply --prune");
assert!( assert!(
@@ -109,3 +110,68 @@ fn prune_deletes_stale_resources() {
"plan should be clean after prune:\n{p}" "plan should be clean after prune:\n{p}"
); );
} }
/// The prune confirmation gate: `--prune` without `--yes`, run
/// non-interactively (no TTY, as in CI), must refuse and delete nothing.
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn prune_without_yes_refuses_noninteractively() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let slug = common::unique_slug("prune-gate");
common::pic_as(&env)
.args(["apps", "create", &slug])
.assert()
.success();
let _guard = AppGuard::new(&env.url, &env.token, &slug);
let dir = TempDir::new().unwrap();
fs::create_dir_all(dir.path().join("scripts")).unwrap();
fs::write(dir.path().join("scripts/keep.rhai"), "let x = 1; x").unwrap();
fs::write(dir.path().join("scripts/drop.rhai"), "let y = 2; y").unwrap();
let manifest_path = dir.path().join("picloud.toml");
// Deploy two scripts, then drop one from the manifest.
let v1 = format!(
"[app]\nslug = \"{slug}\"\nname = \"Gate Test\"\n\n\
[[scripts]]\nname = \"keep\"\nfile = \"scripts/keep.rhai\"\n\n\
[[scripts]]\nname = \"drop\"\nfile = \"scripts/drop.rhai\"\n"
);
fs::write(&manifest_path, &v1).unwrap();
common::pic_as(&env)
.args(["apply", "--file"])
.arg(&manifest_path)
.assert()
.success();
let v2 = format!(
"[app]\nslug = \"{slug}\"\nname = \"Gate Test\"\n\n\
[[scripts]]\nname = \"keep\"\nfile = \"scripts/keep.rhai\"\n"
);
fs::write(&manifest_path, &v2).unwrap();
// `--prune` with no `--yes` and no TTY → refuse, non-zero exit.
let out = common::pic_as(&env)
.args(["apply", "--file"])
.arg(&manifest_path)
.arg("--prune")
.output()
.expect("apply --prune");
assert!(
!out.status.success(),
"prune without --yes must refuse non-interactively"
);
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("--yes"),
"refusal should mention --yes:\n{err}"
);
// The dropped script must still be there — the gate blocked the delete.
let s = scripts_ls(&env, &slug);
assert!(
s.contains("drop"),
"refused prune must not delete anything:\n{s}"
);
}