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

@@ -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!(

View File

@@ -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}"
);
}