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:
@@ -1,11 +1,12 @@
|
||||
//! `pic apply [--file picloud.toml]` — reconcile the live app to the
|
||||
//! manifest's desired state in one server-side transaction. Additive in
|
||||
//! this milestone (creates + updates); pruning of stale resources lands
|
||||
//! next.
|
||||
//! manifest's desired state in one server-side transaction. Creates and
|
||||
//! updates are applied; `--prune` additionally deletes live scripts/routes/
|
||||
//! triggers absent from the manifest (secrets are never pruned).
|
||||
|
||||
use std::io::{IsTerminal, Write};
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::cmds::plan::build_bundle;
|
||||
@@ -13,7 +14,7 @@ use crate::config;
|
||||
use crate::manifest::Manifest;
|
||||
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 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 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 mut block = KvBlock::new();
|
||||
@@ -50,3 +55,30 @@ pub async fn run(manifest_path: &Path, prune: bool, mode: OutputMode) -> Result<
|
||||
block.print(mode);
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -62,8 +62,9 @@ pub async fn run(app_ident: &str, dir: &Path, mode: OutputMode) -> Result<()> {
|
||||
for s in &scripts {
|
||||
if !is_safe_filename(&s.name) {
|
||||
anyhow::bail!(
|
||||
"script name {:?} is not filesystem-safe (contains a path \
|
||||
separator, `..`, or a leading dot); cannot pull",
|
||||
"script name {:?} is not filesystem-safe (a path separator, \
|
||||
`..`, a leading dot, a control character, or longer than 200 \
|
||||
bytes); cannot pull",
|
||||
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/`.
|
||||
/// 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 {
|
||||
// Leave headroom under NAME_MAX (255 bytes on common filesystems) for the
|
||||
// `.rhai` suffix.
|
||||
const MAX_LEN: usize = 200;
|
||||
!name.is_empty()
|
||||
&& name.len() <= MAX_LEN
|
||||
&& !name.starts_with('.')
|
||||
&& !name.contains('/')
|
||||
&& !name.contains('\\')
|
||||
&& 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.
|
||||
@@ -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]
|
||||
fn accepts_normal_names() {
|
||||
for ok in ["create-post", "nightly_digest", "Greet", "x", "a.b"] {
|
||||
|
||||
@@ -159,7 +159,8 @@ enum Cmd {
|
||||
},
|
||||
|
||||
/// 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),
|
||||
|
||||
/// Diff a `picloud.toml` manifest against the live app and print the
|
||||
@@ -181,6 +182,10 @@ struct ApplyArgs {
|
||||
/// Secrets are never pruned.
|
||||
#[arg(long)]
|
||||
prune: bool,
|
||||
/// Skip the `--prune` confirmation prompt. Required to prune
|
||||
/// non-interactively (CI).
|
||||
#[arg(long)]
|
||||
yes: bool,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
@@ -1086,7 +1091,7 @@ async fn main() -> ExitCode {
|
||||
}
|
||||
Cmd::Logout => cmds::logout::run().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::Pull(args) => cmds::pull::run(&args.app, &args.dir, mode).await,
|
||||
Cmd::Apps { cmd: AppsCmd::Ls } => cmds::apps::ls(mode).await,
|
||||
|
||||
@@ -135,7 +135,7 @@ fn prune_refuses_to_orphan_email_trigger() {
|
||||
let out = common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.arg("--prune")
|
||||
.args(["--prune", "--yes"])
|
||||
.output()
|
||||
.expect("apply --prune");
|
||||
assert!(
|
||||
|
||||
@@ -72,11 +72,12 @@ fn prune_deletes_stale_resources() {
|
||||
"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)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.arg("--prune")
|
||||
.args(["--prune", "--yes"])
|
||||
.output()
|
||||
.expect("apply --prune");
|
||||
assert!(
|
||||
@@ -109,3 +110,68 @@ fn prune_deletes_stale_resources() {
|
||||
"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}"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user