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:
@@ -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(¤t, bundle))
|
Ok(compute_diff(¤t, 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(¤t, &chg2).scripts[0].op, Op::Update);
|
assert_eq!(compute_diff(¤t, &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(¤t, &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(¤t, &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(¤t, &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");
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"] {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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!(
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user