feat: E2E #2 (Stash) gap remediation + S6 hardening
Closes the gaps and the one security finding from the second end-to-end
CLI test (E2E_STASH_REPORT.md), plus the H1 boot-regression found while
re-reviewing those fixes.
Security
- S6: reserved-path validation (`check_reserved`) now case-folds before
comparing, so `/API/v2/x`, `/HEALTHZ`, `/Admin/x` are rejected like
their lowercase forms. Request-time matching stays case-sensitive.
- S10: "public route != public data" callout in sdk-shape.md (script_gate
skips authz when the principal is anonymous).
Observability / features
- G1: trigger executions now write `execution_logs`. Migration 0043 adds
a `source` column (CHECK mirrors ExecutionSource/OutboxSourceKind,
DEFAULT 'http' backfills history); a shared `build_execution_log` helper
in executor-core; dispatcher logging for outbox triggers + queue
consumers (skips sync-HTTP rows the orchestrator already logs). `pic
logs` gains a source column + `--source` filter.
- G5: dev-only in-memory email capture under PICLOUD_DEV_MODE with no SMTP
(email::send succeeds locally), readable at GET /api/v1/admin/dev/emails
(Owner/Admin only; route mounted only in capture mode).
- G6: generalized the Rhai in-place-mutation footgun note (trim/replace/
make_upper/make_lower/crop/truncate/pad return ()).
- G2/G3/G4 (CLI): `pic members`, `pic files`, `pic queues`, read-only
`pic kv` (+ new kv_api.rs); `pic deploy --timeout/--memory/--kind/
--sandbox`; first-class `pic triggers create-{docs,files,pubsub,queue,
email}` wrappers. All new client path segments percent-encoded via seg().
H1 regression fix (found in re-review)
- The S6 change also runs in `compile_routes`, which compiles every stored
route at boot and on each route CRUD. A single stored route the new
validation rejects (creatable while the S6 gap existed) made the whole
compile Err and aborted startup. `compile_routes` is now lenient: it
skips an un-compilable row with a warning instead of bricking boot
(route creation still validates separately). Migration 0044 sweeps
pre-existing reserved-path routes on upgrade (WHERE mirrors
check_reserved exactly). Added regression tests for both.
Verified: cargo fmt, clippy --all-targets --all-features -D warnings, the
schema_snapshot test, and the new S6/lenient-compile unit tests all pass;
boot-resilience and G1/G5 confirmed live.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -128,6 +128,138 @@ enum Cmd {
|
||||
#[command(subcommand)]
|
||||
cmd: SecretsCmd,
|
||||
},
|
||||
|
||||
/// App membership — list members and grant / change / revoke their
|
||||
/// per-app role (app_admin | editor | viewer).
|
||||
Members {
|
||||
#[command(subcommand)]
|
||||
cmd: MembersCmd,
|
||||
},
|
||||
|
||||
/// Files inspection — list a collection's blobs, download bytes, or
|
||||
/// delete a file. Read + delete only; writes go through scripts.
|
||||
Files {
|
||||
#[command(subcommand)]
|
||||
cmd: FilesCmd,
|
||||
},
|
||||
|
||||
/// Queue inspection — list queues with depth counts, or drill into
|
||||
/// one queue's stats + registered consumer. Read-only.
|
||||
Queues {
|
||||
#[command(subcommand)]
|
||||
cmd: QueuesCmd,
|
||||
},
|
||||
|
||||
/// KV inspection — list keys in a collection or fetch one value.
|
||||
/// Read-only; writes go through `kv::set` in scripts.
|
||||
Kv {
|
||||
#[command(subcommand)]
|
||||
cmd: KvCmd,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum KvCmd {
|
||||
/// List keys in a collection.
|
||||
Ls {
|
||||
#[arg(long)]
|
||||
app: String,
|
||||
#[arg(long)]
|
||||
collection: String,
|
||||
#[arg(long, default_value_t = 100)]
|
||||
limit: u32,
|
||||
},
|
||||
/// Fetch one key's value (printed as JSON).
|
||||
Get {
|
||||
#[arg(long)]
|
||||
app: String,
|
||||
#[arg(long)]
|
||||
collection: String,
|
||||
key: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum MembersCmd {
|
||||
/// List app members.
|
||||
Ls {
|
||||
#[arg(long)]
|
||||
app: String,
|
||||
},
|
||||
/// Grant a user a role on the app.
|
||||
Add {
|
||||
#[arg(long)]
|
||||
app: String,
|
||||
#[arg(long = "user")]
|
||||
user_id: String,
|
||||
/// `app_admin` | `editor` | `viewer`.
|
||||
#[arg(long)]
|
||||
role: String,
|
||||
},
|
||||
/// Change an existing member's role.
|
||||
Set {
|
||||
#[arg(long)]
|
||||
app: String,
|
||||
#[arg(long = "user")]
|
||||
user_id: String,
|
||||
#[arg(long)]
|
||||
role: String,
|
||||
},
|
||||
/// Remove a member.
|
||||
Rm {
|
||||
#[arg(long)]
|
||||
app: String,
|
||||
#[arg(long = "user")]
|
||||
user_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum FilesCmd {
|
||||
/// List files in a collection.
|
||||
Ls {
|
||||
#[arg(long)]
|
||||
app: String,
|
||||
#[arg(long)]
|
||||
collection: String,
|
||||
#[arg(long, default_value_t = 100)]
|
||||
limit: u32,
|
||||
},
|
||||
/// Download a file's bytes (to `--out <path>` or stdout).
|
||||
Get {
|
||||
#[arg(long)]
|
||||
app: String,
|
||||
#[arg(long)]
|
||||
collection: String,
|
||||
#[arg(long = "id")]
|
||||
file_id: String,
|
||||
#[arg(long)]
|
||||
out: Option<PathBuf>,
|
||||
},
|
||||
/// Delete a file.
|
||||
Rm {
|
||||
#[arg(long)]
|
||||
app: String,
|
||||
#[arg(long)]
|
||||
collection: String,
|
||||
#[arg(long = "id")]
|
||||
file_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum QueuesCmd {
|
||||
/// List queues with depth counts.
|
||||
Ls {
|
||||
#[arg(long)]
|
||||
app: String,
|
||||
},
|
||||
/// Show one queue's stats + consumer.
|
||||
Show {
|
||||
#[arg(long)]
|
||||
app: String,
|
||||
queue_name: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
@@ -234,6 +366,82 @@ struct DeployArgs {
|
||||
name: Option<String>,
|
||||
#[arg(long)]
|
||||
description: Option<String>,
|
||||
/// Per-script wall-clock timeout in seconds (overrides the instance
|
||||
/// default; still clamped by `PICLOUD_SANDBOX_MAX_*` ceilings).
|
||||
#[arg(long)]
|
||||
timeout: Option<i32>,
|
||||
/// Per-script memory ceiling in MB.
|
||||
#[arg(long)]
|
||||
memory: Option<i32>,
|
||||
/// Script kind: `endpoint` (default) or `module` (importable, no route).
|
||||
#[arg(long, value_enum)]
|
||||
kind: Option<ScriptKindArg>,
|
||||
/// Sandbox override as `key=value`, repeatable. Keys: `max_operations`,
|
||||
/// `max_string_size`, `max_array_size`, `max_map_size`,
|
||||
/// `max_call_levels`, `max_expr_depth`. E.g. `--sandbox max_operations=500000`.
|
||||
#[arg(long = "sandbox", value_name = "KEY=VALUE")]
|
||||
sandbox: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, clap::ValueEnum)]
|
||||
enum ScriptKindArg {
|
||||
Endpoint,
|
||||
Module,
|
||||
}
|
||||
|
||||
impl From<ScriptKindArg> for picloud_shared::ScriptKind {
|
||||
fn from(v: ScriptKindArg) -> Self {
|
||||
match v {
|
||||
ScriptKindArg::Endpoint => Self::Endpoint,
|
||||
ScriptKindArg::Module => Self::Module,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DeployArgs {
|
||||
/// Fold the runtime-config flags into a `ScriptConfig`, parsing and
|
||||
/// validating the `--sandbox key=value` pairs.
|
||||
fn script_config(&self) -> anyhow::Result<client::ScriptConfig> {
|
||||
let sandbox = parse_sandbox_overrides(&self.sandbox)?;
|
||||
Ok(client::ScriptConfig {
|
||||
timeout_seconds: self.timeout,
|
||||
memory_limit_mb: self.memory,
|
||||
kind: self.kind.map(Into::into),
|
||||
sandbox,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse repeatable `--sandbox key=value` flags into a `ScriptSandbox`.
|
||||
/// Unknown keys and non-integer values are hard errors so a typo doesn't
|
||||
/// silently deploy an unrestricted script.
|
||||
fn parse_sandbox_overrides(
|
||||
pairs: &[String],
|
||||
) -> anyhow::Result<Option<picloud_shared::ScriptSandbox>> {
|
||||
use anyhow::{anyhow, Context};
|
||||
if pairs.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut sb = picloud_shared::ScriptSandbox::default();
|
||||
for raw in pairs {
|
||||
let (key, val) = raw
|
||||
.split_once('=')
|
||||
.ok_or_else(|| anyhow!("--sandbox expects key=value, got {raw:?}"))?;
|
||||
let n: u64 = val
|
||||
.trim()
|
||||
.parse()
|
||||
.with_context(|| format!("--sandbox {key}: {val:?} is not a non-negative integer"))?;
|
||||
match key.trim() {
|
||||
"max_operations" => sb.max_operations = Some(n),
|
||||
"max_string_size" => sb.max_string_size = Some(n),
|
||||
"max_array_size" => sb.max_array_size = Some(n),
|
||||
"max_map_size" => sb.max_map_size = Some(n),
|
||||
"max_call_levels" => sb.max_call_levels = Some(n),
|
||||
"max_expr_depth" => sb.max_expr_depth = Some(n),
|
||||
other => return Err(anyhow!("unknown --sandbox key: {other:?}")),
|
||||
}
|
||||
}
|
||||
Ok(Some(sb))
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
@@ -275,6 +483,11 @@ struct LogsArgs {
|
||||
script_id: String,
|
||||
#[arg(long, default_value_t = 50)]
|
||||
limit: u32,
|
||||
/// Filter by execution origin: `http`, `kv`, `cron`, `queue`,
|
||||
/// `invoke`, `dead_letter`, `docs`, `files`, `pubsub`, `email`.
|
||||
/// Omit (or `all`) to show every source.
|
||||
#[arg(long)]
|
||||
source: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, ValueEnum)]
|
||||
@@ -498,10 +711,89 @@ enum TriggersCmd {
|
||||
dispatch: DispatchModeArg,
|
||||
},
|
||||
|
||||
/// Create a docs trigger — fires on document mutations in matching
|
||||
/// collections.
|
||||
#[command(name = "create-docs")]
|
||||
CreateDocs {
|
||||
#[arg(long)]
|
||||
app: String,
|
||||
#[arg(long)]
|
||||
script: String,
|
||||
/// Glob over collection names (`*`, `posts`, `events_*`, …).
|
||||
#[arg(long)]
|
||||
collection: String,
|
||||
/// Repeat to filter ops: `--op insert --op delete`. Empty fires on any.
|
||||
#[arg(long = "op")]
|
||||
ops: Vec<String>,
|
||||
#[arg(long, value_enum, default_value_t = DispatchModeArg::Async)]
|
||||
dispatch: DispatchModeArg,
|
||||
},
|
||||
|
||||
/// Create a files trigger — fires on blob create/update/delete in
|
||||
/// matching collections.
|
||||
#[command(name = "create-files")]
|
||||
CreateFiles {
|
||||
#[arg(long)]
|
||||
app: String,
|
||||
#[arg(long)]
|
||||
script: String,
|
||||
#[arg(long)]
|
||||
collection: String,
|
||||
#[arg(long = "op")]
|
||||
ops: Vec<String>,
|
||||
#[arg(long, value_enum, default_value_t = DispatchModeArg::Async)]
|
||||
dispatch: DispatchModeArg,
|
||||
},
|
||||
|
||||
/// Create a pub/sub trigger — fires on messages published to topics
|
||||
/// matching the pattern.
|
||||
#[command(name = "create-pubsub")]
|
||||
CreatePubsub {
|
||||
#[arg(long)]
|
||||
app: String,
|
||||
#[arg(long)]
|
||||
script: String,
|
||||
/// Topic glob (`*`, `orders.*`, `user.signup`, …).
|
||||
#[arg(long = "topic")]
|
||||
topic_pattern: String,
|
||||
#[arg(long, value_enum, default_value_t = DispatchModeArg::Async)]
|
||||
dispatch: DispatchModeArg,
|
||||
},
|
||||
|
||||
/// Create a queue consumer trigger — fires per message claimed off
|
||||
/// the named queue.
|
||||
#[command(name = "create-queue")]
|
||||
CreateQueue {
|
||||
#[arg(long)]
|
||||
app: String,
|
||||
#[arg(long)]
|
||||
script: String,
|
||||
#[arg(long = "queue")]
|
||||
queue_name: String,
|
||||
/// Per-message visibility timeout in seconds (claim lease).
|
||||
#[arg(long = "visibility-timeout")]
|
||||
visibility_timeout_secs: Option<u32>,
|
||||
#[arg(long, value_enum, default_value_t = DispatchModeArg::Async)]
|
||||
dispatch: DispatchModeArg,
|
||||
},
|
||||
|
||||
/// Create an email trigger — fires on inbound mail POSTed to the
|
||||
/// webhook receiver. No dispatch mode (inbound webhook only).
|
||||
#[command(name = "create-email")]
|
||||
CreateEmail {
|
||||
#[arg(long)]
|
||||
app: String,
|
||||
#[arg(long)]
|
||||
script: String,
|
||||
/// Shared HMAC secret the provider signs inbound POSTs with.
|
||||
/// Omit to accept unsigned POSTs.
|
||||
#[arg(long = "inbound-secret")]
|
||||
inbound_secret: Option<String>,
|
||||
},
|
||||
|
||||
/// Create a trigger of any kind from a raw JSON body — escape
|
||||
/// hatch for kinds the CLI doesn't expose per-kind wrappers for
|
||||
/// (docs/files/pubsub/email/queue) and for advanced retry/dispatch
|
||||
/// settings beyond the per-kind defaults.
|
||||
/// hatch for advanced retry/dispatch settings beyond the per-kind
|
||||
/// wrappers' defaults.
|
||||
#[command(name = "create-from-json")]
|
||||
CreateFromJson {
|
||||
#[arg(long)]
|
||||
@@ -790,16 +1082,20 @@ async fn main() -> ExitCode {
|
||||
} => cmds::scripts::ls(app.as_deref(), mode).await,
|
||||
Cmd::Scripts {
|
||||
cmd: ScriptsCmd::Deploy(args),
|
||||
} => {
|
||||
cmds::scripts::deploy(
|
||||
&args.file,
|
||||
&args.app,
|
||||
args.name.as_deref(),
|
||||
args.description.as_deref(),
|
||||
mode,
|
||||
)
|
||||
.await
|
||||
}
|
||||
} => match args.script_config() {
|
||||
Ok(cfg) => {
|
||||
cmds::scripts::deploy(
|
||||
&args.file,
|
||||
&args.app,
|
||||
args.name.as_deref(),
|
||||
args.description.as_deref(),
|
||||
&cfg,
|
||||
mode,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
},
|
||||
Cmd::Scripts {
|
||||
cmd: ScriptsCmd::Invoke(args),
|
||||
} => cmds::scripts::invoke(&args.id, args.body.as_deref(), &args.headers).await,
|
||||
@@ -821,20 +1117,28 @@ async fn main() -> ExitCode {
|
||||
Cmd::ApiKeys {
|
||||
cmd: ApiKeysCmd::Rm { id },
|
||||
} => cmds::api_keys::rm(&id).await,
|
||||
Cmd::Logs(LogsArgs { script_id, limit }) => cmds::logs::run(&script_id, limit, mode).await,
|
||||
Cmd::Logs(LogsArgs {
|
||||
script_id,
|
||||
limit,
|
||||
source,
|
||||
}) => cmds::logs::run(&script_id, limit, source.as_deref(), mode).await,
|
||||
Cmd::Invoke(args) => {
|
||||
cmds::scripts::invoke(&args.id, args.body.as_deref(), &args.headers).await
|
||||
}
|
||||
Cmd::Deploy(args) => {
|
||||
cmds::scripts::deploy(
|
||||
&args.file,
|
||||
&args.app,
|
||||
args.name.as_deref(),
|
||||
args.description.as_deref(),
|
||||
mode,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Cmd::Deploy(args) => match args.script_config() {
|
||||
Ok(cfg) => {
|
||||
cmds::scripts::deploy(
|
||||
&args.file,
|
||||
&args.app,
|
||||
args.name.as_deref(),
|
||||
args.description.as_deref(),
|
||||
&cfg,
|
||||
mode,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
},
|
||||
Cmd::Routes {
|
||||
cmd: RoutesCmd::Ls { script_id },
|
||||
} => cmds::routes::ls(&script_id, mode).await,
|
||||
@@ -1004,6 +1308,92 @@ async fn main() -> ExitCode {
|
||||
)
|
||||
.await
|
||||
}
|
||||
Cmd::Triggers {
|
||||
cmd:
|
||||
TriggersCmd::CreateDocs {
|
||||
app,
|
||||
script,
|
||||
collection,
|
||||
ops,
|
||||
dispatch,
|
||||
},
|
||||
} => {
|
||||
cmds::triggers::create_docs(
|
||||
&app,
|
||||
&script,
|
||||
&collection,
|
||||
&ops,
|
||||
dispatch_wire(dispatch),
|
||||
mode,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Cmd::Triggers {
|
||||
cmd:
|
||||
TriggersCmd::CreateFiles {
|
||||
app,
|
||||
script,
|
||||
collection,
|
||||
ops,
|
||||
dispatch,
|
||||
},
|
||||
} => {
|
||||
cmds::triggers::create_files(
|
||||
&app,
|
||||
&script,
|
||||
&collection,
|
||||
&ops,
|
||||
dispatch_wire(dispatch),
|
||||
mode,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Cmd::Triggers {
|
||||
cmd:
|
||||
TriggersCmd::CreatePubsub {
|
||||
app,
|
||||
script,
|
||||
topic_pattern,
|
||||
dispatch,
|
||||
},
|
||||
} => {
|
||||
cmds::triggers::create_pubsub(
|
||||
&app,
|
||||
&script,
|
||||
&topic_pattern,
|
||||
dispatch_wire(dispatch),
|
||||
mode,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Cmd::Triggers {
|
||||
cmd:
|
||||
TriggersCmd::CreateQueue {
|
||||
app,
|
||||
script,
|
||||
queue_name,
|
||||
visibility_timeout_secs,
|
||||
dispatch,
|
||||
},
|
||||
} => {
|
||||
cmds::triggers::create_queue(
|
||||
&app,
|
||||
&script,
|
||||
&queue_name,
|
||||
visibility_timeout_secs,
|
||||
dispatch_wire(dispatch),
|
||||
mode,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Cmd::Triggers {
|
||||
cmd:
|
||||
TriggersCmd::CreateEmail {
|
||||
app,
|
||||
script,
|
||||
inbound_secret,
|
||||
},
|
||||
} => cmds::triggers::create_email(&app, &script, inbound_secret.as_deref(), mode).await,
|
||||
Cmd::Triggers {
|
||||
cmd: TriggersCmd::CreateFromJson { app, kind, body },
|
||||
} => cmds::triggers::create_from_json(&app, &kind, &body, mode).await,
|
||||
@@ -1074,6 +1464,65 @@ async fn main() -> ExitCode {
|
||||
Cmd::Secrets {
|
||||
cmd: SecretsCmd::Rm { app, name },
|
||||
} => cmds::secrets::rm(&app, &name).await,
|
||||
Cmd::Members {
|
||||
cmd: MembersCmd::Ls { app },
|
||||
} => cmds::members::ls(&app, mode).await,
|
||||
Cmd::Members {
|
||||
cmd: MembersCmd::Add { app, user_id, role },
|
||||
} => cmds::members::add(&app, &user_id, &role, mode).await,
|
||||
Cmd::Members {
|
||||
cmd: MembersCmd::Set { app, user_id, role },
|
||||
} => cmds::members::set(&app, &user_id, &role, mode).await,
|
||||
Cmd::Members {
|
||||
cmd: MembersCmd::Rm { app, user_id },
|
||||
} => cmds::members::rm(&app, &user_id).await,
|
||||
Cmd::Files {
|
||||
cmd:
|
||||
FilesCmd::Ls {
|
||||
app,
|
||||
collection,
|
||||
limit,
|
||||
},
|
||||
} => cmds::files::ls(&app, &collection, limit, mode).await,
|
||||
Cmd::Files {
|
||||
cmd:
|
||||
FilesCmd::Get {
|
||||
app,
|
||||
collection,
|
||||
file_id,
|
||||
out,
|
||||
},
|
||||
} => cmds::files::get(&app, &collection, &file_id, out.as_deref()).await,
|
||||
Cmd::Files {
|
||||
cmd:
|
||||
FilesCmd::Rm {
|
||||
app,
|
||||
collection,
|
||||
file_id,
|
||||
},
|
||||
} => cmds::files::rm(&app, &collection, &file_id).await,
|
||||
Cmd::Queues {
|
||||
cmd: QueuesCmd::Ls { app },
|
||||
} => cmds::queues::ls(&app, mode).await,
|
||||
Cmd::Queues {
|
||||
cmd: QueuesCmd::Show { app, queue_name },
|
||||
} => cmds::queues::show(&app, &queue_name, mode).await,
|
||||
Cmd::Kv {
|
||||
cmd:
|
||||
KvCmd::Ls {
|
||||
app,
|
||||
collection,
|
||||
limit,
|
||||
},
|
||||
} => cmds::kv::ls(&app, &collection, limit, mode).await,
|
||||
Cmd::Kv {
|
||||
cmd:
|
||||
KvCmd::Get {
|
||||
app,
|
||||
collection,
|
||||
key,
|
||||
},
|
||||
} => cmds::kv::get(&app, &collection, &key).await,
|
||||
};
|
||||
|
||||
match result {
|
||||
|
||||
Reference in New Issue
Block a user