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:
@@ -248,6 +248,15 @@ fn non_empty_env(key: &str) -> Option<String> {
|
||||
std::env::var(key).ok().filter(|v| !v.trim().is_empty())
|
||||
}
|
||||
|
||||
/// `PICLOUD_DEV_MODE=true` (case-insensitive). Matches the detection in
|
||||
/// `shared::crypto` so the dev email sink and the dev master key turn on
|
||||
/// together.
|
||||
fn dev_mode_enabled() -> bool {
|
||||
std::env::var("PICLOUD_DEV_MODE")
|
||||
.map(|v| v.trim().eq_ignore_ascii_case("true"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Internal transport seam so the service can be tested without a live
|
||||
/// SMTP server. The production impl is [`LettreEmailTransport`]; tests
|
||||
/// use a recording fake.
|
||||
@@ -299,6 +308,91 @@ impl EmailTransport for LettreEmailTransport {
|
||||
}
|
||||
}
|
||||
|
||||
/// G5: how many recently-captured dev emails the in-memory sink keeps.
|
||||
/// Old entries are evicted FIFO; this is a debugging aid, not storage.
|
||||
pub const DEV_EMAIL_CAPACITY: usize = 100;
|
||||
|
||||
/// One email captured by the dev sink instead of being relayed. Serialized
|
||||
/// straight onto the dev-only inspection endpoint.
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
pub struct CapturedEmail {
|
||||
pub captured_at: chrono::DateTime<chrono::Utc>,
|
||||
pub from: Option<String>,
|
||||
pub to: Vec<String>,
|
||||
/// The full RFC 5322 message (headers + body), exactly as it would
|
||||
/// have hit the relay — enough to eyeball subject/body in dev.
|
||||
pub raw: String,
|
||||
}
|
||||
|
||||
/// In-memory ring buffer of captured dev emails. Shared (`Arc`) between
|
||||
/// the [`DevEmailTransport`] that writes and the dev endpoint that reads.
|
||||
pub struct DevEmailSink {
|
||||
captured: std::sync::Mutex<std::collections::VecDeque<CapturedEmail>>,
|
||||
capacity: usize,
|
||||
}
|
||||
|
||||
impl DevEmailSink {
|
||||
#[must_use]
|
||||
pub fn new(capacity: usize) -> Self {
|
||||
Self {
|
||||
captured: std::sync::Mutex::new(std::collections::VecDeque::new()),
|
||||
capacity: capacity.max(1),
|
||||
}
|
||||
}
|
||||
|
||||
fn push(&self, email: CapturedEmail) {
|
||||
let mut q = self.captured.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
while q.len() >= self.capacity {
|
||||
q.pop_front();
|
||||
}
|
||||
q.push_back(email);
|
||||
}
|
||||
|
||||
/// Newest-first snapshot of the captured mail.
|
||||
#[must_use]
|
||||
pub fn snapshot(&self) -> Vec<CapturedEmail> {
|
||||
let q = self.captured.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
q.iter().rev().cloned().collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Dev transport: instead of relaying, capture the message in memory and
|
||||
/// log it. Wired only when `PICLOUD_DEV_MODE=true` and no SMTP relay is
|
||||
/// configured, so `email::send` is exercisable locally without a relay.
|
||||
/// NEVER constructed in production (no dev mode → disabled mode instead).
|
||||
pub struct DevEmailTransport {
|
||||
sink: Arc<DevEmailSink>,
|
||||
}
|
||||
|
||||
impl DevEmailTransport {
|
||||
#[must_use]
|
||||
pub fn new(sink: Arc<DevEmailSink>) -> Self {
|
||||
Self { sink }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EmailTransport for DevEmailTransport {
|
||||
async fn send(&self, message: &Message) -> Result<(), EmailError> {
|
||||
let envelope = message.envelope();
|
||||
let from = envelope.from().map(ToString::to_string);
|
||||
let to: Vec<String> = envelope.to().iter().map(ToString::to_string).collect();
|
||||
let raw = String::from_utf8_lossy(&message.formatted()).into_owned();
|
||||
tracing::info!(
|
||||
?from,
|
||||
?to,
|
||||
"email DEV CAPTURE: message captured in memory (not relayed)"
|
||||
);
|
||||
self.sink.push(CapturedEmail {
|
||||
captured_at: chrono::Utc::now(),
|
||||
from,
|
||||
to,
|
||||
raw,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct EmailServiceImpl {
|
||||
/// `None` → disabled mode (every send returns `NotConfigured`).
|
||||
transport: Option<Arc<dyn EmailTransport>>,
|
||||
@@ -328,27 +422,58 @@ impl EmailServiceImpl {
|
||||
/// — email is non-critical and must not block startup.
|
||||
#[must_use]
|
||||
pub fn from_env(authz: Arc<dyn AuthzRepo>) -> Self {
|
||||
Self::from_env_with_dev_capture(authz).0
|
||||
}
|
||||
|
||||
/// Like [`from_env`](Self::from_env), but in **dev mode with no SMTP
|
||||
/// relay** it wires a [`DevEmailTransport`] that captures mail in
|
||||
/// memory instead of returning `NotConfigured` — so `email::send` is
|
||||
/// exercisable locally (G5). Returns the sink handle (`Some`) when
|
||||
/// capture mode is active, so the caller can expose it via the
|
||||
/// dev-only inspection endpoint.
|
||||
///
|
||||
/// Production is unaffected: without `PICLOUD_DEV_MODE=true` an unset
|
||||
/// relay still yields disabled mode (`NotConfigured`), never capture.
|
||||
#[must_use]
|
||||
pub fn from_env_with_dev_capture(
|
||||
authz: Arc<dyn AuthzRepo>,
|
||||
) -> (Self, Option<Arc<DevEmailSink>>) {
|
||||
let config = EmailConfig::from_env();
|
||||
let transport: Option<Arc<dyn EmailTransport>> = match SmtpConfig::from_env() {
|
||||
match SmtpConfig::from_env() {
|
||||
Some(cfg) => {
|
||||
let transport: Option<Arc<dyn EmailTransport>> = match LettreEmailTransport::build(
|
||||
&cfg,
|
||||
) {
|
||||
Ok(t) => {
|
||||
tracing::info!(host = %cfg.host, port = cfg.port, "outbound email enabled");
|
||||
Some(Arc::new(t))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "failed to build SMTP transport; email DISABLED");
|
||||
None
|
||||
}
|
||||
};
|
||||
(Self::new(transport, authz, config), None)
|
||||
}
|
||||
None if dev_mode_enabled() => {
|
||||
tracing::warn!(
|
||||
"email DEV CAPTURE: PICLOUD_DEV_MODE=true and no SMTP relay configured — \
|
||||
email::send will SUCCEED and capture messages in memory (last {DEV_EMAIL_CAPACITY}, \
|
||||
readable at GET /api/v1/admin/dev/emails). NEVER use this in production."
|
||||
);
|
||||
let sink = Arc::new(DevEmailSink::new(DEV_EMAIL_CAPACITY));
|
||||
let transport: Arc<dyn EmailTransport> =
|
||||
Arc::new(DevEmailTransport::new(sink.clone()));
|
||||
(Self::new(Some(transport), authz, config), Some(sink))
|
||||
}
|
||||
None => {
|
||||
tracing::warn!(
|
||||
"email is DISABLED: set PICLOUD_SMTP_HOST/USER/PASSWORD to enable \
|
||||
email::send. Scripts calling email::send will get an error."
|
||||
);
|
||||
None
|
||||
(Self::new(None, authz, config), None)
|
||||
}
|
||||
Some(cfg) => match LettreEmailTransport::build(&cfg) {
|
||||
Ok(t) => {
|
||||
tracing::info!(host = %cfg.host, port = cfg.port, "outbound email enabled");
|
||||
Some(Arc::new(t))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "failed to build SMTP transport; email DISABLED");
|
||||
None
|
||||
}
|
||||
},
|
||||
};
|
||||
Self::new(transport, authz, config)
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_send(&self, cx: &SdkCallCx) -> Result<(), EmailError> {
|
||||
|
||||
Reference in New Issue
Block a user