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:
@@ -373,27 +373,56 @@ async fn refresh_table<RR: RouteRepository, SR: ScriptRepository>(
|
||||
state: &RouteAdminState<RR, SR>,
|
||||
) -> Result<(), RouteApiError> {
|
||||
let rows = state.routes.list_all().await?;
|
||||
let compiled = compile_routes(&rows)?;
|
||||
let compiled = compile_routes(&rows);
|
||||
state.table.replace_all(compiled);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn compile_routes(rows: &[Route]) -> Result<Vec<CompiledRoute>, pattern::ParseError> {
|
||||
/// Compile stored route rows into the in-memory match table.
|
||||
///
|
||||
/// **Lenient by design (H1).** A row that fails to parse is *skipped with
|
||||
/// a warning*, not propagated as an error. The motivating case: a path
|
||||
/// that was valid when created but became reserved under a later, stricter
|
||||
/// validation (e.g. the case-insensitive reserved-prefix check) — but this
|
||||
/// also covers any other parse failure. A single un-compilable legacy row
|
||||
/// must never take down the entire data plane: this function runs at
|
||||
/// startup (where a hard error aborts boot) and on every table rebuild
|
||||
/// after a route edit (where it would fail an unrelated CRUD op). A skipped
|
||||
/// route simply doesn't match; the warning tells the operator to delete or
|
||||
/// fix it (and migration 0044 sweeps the reserved-path offenders on
|
||||
/// upgrade).
|
||||
#[must_use]
|
||||
pub fn compile_routes(rows: &[Route]) -> Vec<CompiledRoute> {
|
||||
rows.iter()
|
||||
.map(|r| {
|
||||
Ok(CompiledRoute {
|
||||
route_id: r.id,
|
||||
app_id: r.app_id,
|
||||
script_id: r.script_id,
|
||||
host: pattern::parse_host(r.host_kind, &r.host, r.host_param_name.as_deref())?,
|
||||
path: pattern::parse_path(r.path_kind, &r.path)?,
|
||||
method: r.method.clone(),
|
||||
dispatch_mode: r.dispatch_mode,
|
||||
})
|
||||
.filter_map(|r| match compile_route(r) {
|
||||
Ok(compiled) => Some(compiled),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
route_id = %r.id,
|
||||
app_id = %r.app_id,
|
||||
path = %r.path,
|
||||
error = %e,
|
||||
"skipping un-compilable stored route — it will not match; \
|
||||
delete or fix it"
|
||||
);
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn compile_route(r: &Route) -> Result<CompiledRoute, pattern::ParseError> {
|
||||
Ok(CompiledRoute {
|
||||
route_id: r.id,
|
||||
app_id: r.app_id,
|
||||
script_id: r.script_id,
|
||||
host: pattern::parse_host(r.host_kind, &r.host, r.host_param_name.as_deref())?,
|
||||
path: pattern::parse_path(r.path_kind, &r.path)?,
|
||||
method: r.method.clone(),
|
||||
dispatch_mode: r.dispatch_mode,
|
||||
})
|
||||
}
|
||||
|
||||
/// Validate that a new route's (host_kind, host) is consistent with at
|
||||
/// least one of the parent app's domain claims. `HostKind::Any` is
|
||||
/// always permitted — it catches every host the app already owns.
|
||||
@@ -577,3 +606,49 @@ impl IntoResponse for RouteApiError {
|
||||
(status, Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use picloud_shared::DispatchMode;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn route_with_path(path: &str) -> Route {
|
||||
Route {
|
||||
id: Uuid::new_v4(),
|
||||
app_id: AppId::from(Uuid::new_v4()),
|
||||
script_id: ScriptId::from(Uuid::new_v4()),
|
||||
host_kind: HostKind::Any,
|
||||
host: String::new(),
|
||||
host_param_name: None,
|
||||
path_kind: PathKind::Exact,
|
||||
path: path.to_string(),
|
||||
method: None,
|
||||
dispatch_mode: DispatchMode::default(),
|
||||
created_at: chrono::Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compile_routes_skips_uncompilable_rows_instead_of_failing() {
|
||||
// H1 regression guard: a stored route whose path is now reserved
|
||||
// (creatable before the case-insensitive reserved-prefix fix) must
|
||||
// be skipped, not abort the whole compile — otherwise one legacy
|
||||
// row bricks startup (`compile_routes` runs in `build_app`).
|
||||
let good_a = route_with_path("/ok");
|
||||
let bad = route_with_path("/API/v2/x"); // now reserved, case-insensitive
|
||||
let good_b = route_with_path("/items");
|
||||
let rows = vec![good_a.clone(), bad.clone(), good_b.clone()];
|
||||
|
||||
let compiled = compile_routes(&rows);
|
||||
|
||||
let ids: Vec<Uuid> = compiled.iter().map(|c| c.route_id).collect();
|
||||
assert_eq!(compiled.len(), 2, "the reserved row must be dropped");
|
||||
assert!(ids.contains(&good_a.id));
|
||||
assert!(ids.contains(&good_b.id));
|
||||
assert!(
|
||||
!ids.contains(&bad.id),
|
||||
"a reserved-path route must be skipped, never abort the compile"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user