nyx/src/dynamic/stubs/mod.rs

382 lines
14 KiB
Rust

//! Per-cap stub providers (Phase 10 — Track D.3).
//!
//! A *stub* is a tiny in-process service that pretends to be the real
//! boundary a sink crosses — a SQL server, an HTTP origin, a Redis
//! cache, a writable filesystem root — so a sink that talks to that
//! boundary can fire under test without depending on a live external
//! service. Each stub exposes:
//!
//! 1. [`StubProvider::start`] — spin the service up. The constructor of
//! each concrete stub plays this role (e.g. [`SqlStub::start`]); the
//! trait method just hands back the kind for type-erased
//! introspection.
//! 2. [`StubProvider::endpoint`] — the connection string the harness
//! should use (a SQLite DB path, `http://127.0.0.1:port`, a
//! filesystem root, etc.).
//! 3. [`StubProvider::drain_events`] — read every event observed since
//! the last drain. The oracle's
//! [`crate::dynamic::oracle::ProbePredicate::StubEventMatches`]
//! walks these to decide whether a stub-observed effect satisfies
//! a payload's predicate set.
//! 4. `Drop` — tear the service down. The runner relies on the
//! `Arc<dyn StubProvider>` drop to release the listening socket /
//! delete the temp filesystem root.
//!
//! # Lifecycle
//!
//! [`StubHarness::start`] spawns exactly the stubs in `kinds` (it does
//! *not* spawn the full set — the performance invariant is that a
//! harness with `stubs_required: []` boots in under 500 ms, so a
//! verifier that needs no stubs touches none of this module). The
//! harness keeps the stubs alive for the duration of a verify run and
//! drops them on scope exit; the runner does not have to know about
//! individual stub types.
//!
//! # Wiring
//!
//! - [`crate::dynamic::spec::HarnessSpec::stubs_required`] is populated
//! at spec-derivation time from [`StubKind::for_cap`]; a SQL sink
//! pulls in [`StubKind::Sql`], an SSRF sink pulls in
//! [`StubKind::Http`], a path-traversal sink pulls in
//! [`StubKind::Filesystem`]. Stubs whose presence is purely
//! opportunistic (e.g. [`StubKind::Redis`]) are not auto-derived from
//! any cap and must be added explicitly by a caller that knows it
//! needs them.
//! - [`crate::dynamic::verify::verify_finding`] starts the required
//! stubs *after* spec derivation and *before* spawning the sandbox,
//! then injects each stub's endpoint into the sandbox env via the
//! well-known [`StubKind::env_var`] name.
//! - Stub events are drained per-payload by the verifier (after each
//! sandbox run) and passed into
//! [`crate::dynamic::oracle::oracle_fired_with_stubs`] so the
//! `StubEventMatches` predicate can satisfy a payload.
pub mod filesystem;
pub mod http;
pub mod redis;
pub mod sql;
pub use filesystem::FilesystemStub;
pub use http::HttpStub;
pub use redis::RedisStub;
pub use sql::SqlStub;
use crate::labels::Cap;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::Path;
use std::sync::Arc;
/// Which kind of stub a sink needs to fire under test.
///
/// Stored on [`crate::dynamic::spec::HarnessSpec::stubs_required`] as a
/// `Vec<StubKind>` so the spec serialises stably across versions even
/// when new stub kinds land in a future phase.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum StubKind {
/// In-memory SQLite-backed SQL stub. Endpoint is a DB file path.
Sql,
/// Localhost HTTP listener. Endpoint is `http://127.0.0.1:{port}`.
Http,
/// Minimal RESP-speaking Redis stub. Endpoint is `127.0.0.1:{port}`.
Redis,
/// Sandbox-local fake filesystem root. Endpoint is an absolute
/// directory path that the harness is expected to use as its root.
Filesystem,
}
impl StubKind {
/// Env-var name the verifier sets on the sandbox process to hand
/// the stub's endpoint to the harness. Stable: harnesses read these
/// names directly; bumping requires a coordinated lang-emitter
/// update.
pub const fn env_var(self) -> &'static str {
match self {
StubKind::Sql => "NYX_SQL_ENDPOINT",
StubKind::Http => "NYX_HTTP_ENDPOINT",
StubKind::Redis => "NYX_REDIS_ENDPOINT",
StubKind::Filesystem => "NYX_FS_ROOT",
}
}
/// Stable string tag used in [`StubEvent::kind`] serialisation and
/// the oracle's `StubEventMatches` predicate. Lower-case, stable
/// across versions.
pub const fn tag(self) -> &'static str {
match self {
StubKind::Sql => "sql",
StubKind::Http => "http",
StubKind::Redis => "redis",
StubKind::Filesystem => "filesystem",
}
}
/// Derive the set of stubs a payload targeting `cap` needs spawned.
///
/// The mapping is deliberately conservative: only caps whose sinks
/// *cannot* fire in-process without a real boundary auto-derive a
/// stub. Caps like `Cap::CODE_EXEC` or `Cap::FMT_STRING` execute
/// purely inside the harness process and need no stub.
pub fn for_cap(cap: Cap) -> Vec<StubKind> {
let mut out = Vec::new();
if cap.contains(Cap::SQL_QUERY) {
out.push(StubKind::Sql);
}
if cap.contains(Cap::SSRF) || cap.contains(Cap::HEADER_INJECTION) {
out.push(StubKind::Http);
}
if cap.contains(Cap::FILE_IO) {
out.push(StubKind::Filesystem);
}
out
}
}
/// One observation captured by a stub.
///
/// The contents are deliberately type-erased onto strings so all four
/// stub kinds share a single event schema. The `detail` map carries
/// per-kind structured fields (e.g. `method`/`path` for HTTP,
/// `command`/`args` for Redis) that an oracle predicate can dig into
/// without forking the schema by kind.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct StubEvent {
/// Which stub recorded the event.
pub kind: StubKind,
/// Monotonic-ish nanosecond timestamp at capture time. Ordering
/// across stubs is best-effort; absolute value is meaningless.
pub captured_at_ns: u64,
/// One-line human-readable summary. For SQL this is the executed
/// query; for HTTP, the request line; for Redis, the command +
/// args; for filesystem, the absolute path + op kind.
pub summary: String,
/// Per-kind structured fields. Empty when the stub captured only a
/// summary.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub detail: BTreeMap<String, String>,
}
impl StubEvent {
/// Construct a `StubEvent` stamped with the current monotonic
/// timestamp. Tests pin `captured_at_ns` explicitly for
/// determinism; production stubs use this constructor.
pub fn new(kind: StubKind, summary: impl Into<String>) -> Self {
Self {
kind,
captured_at_ns: monotonic_ns(),
summary: summary.into(),
detail: BTreeMap::new(),
}
}
/// Attach a `detail` field, builder-style.
pub fn with_detail(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.detail.insert(key.into(), value.into());
self
}
}
/// Common operations on a running stub.
///
/// The trait is intentionally minimal so a future stub kind (e.g.
/// gRPC, Kafka) plugs in without touching the runner or the oracle.
pub trait StubProvider: Send + Sync + std::fmt::Debug {
/// Discriminator for type-erased dispatch.
fn kind(&self) -> StubKind;
/// Connection string handed to the harness via
/// [`StubKind::env_var`].
fn endpoint(&self) -> String;
/// Drain every event observed since the last drain. Always returns
/// the events in insertion order; on a poisoned mutex returns an
/// empty vec (the oracle treats "no events" as "stub was not
/// touched").
fn drain_events(&self) -> Vec<StubEvent>;
}
/// Aggregate handle the verifier owns for the lifetime of one
/// `verify_finding` call.
///
/// Holds an `Arc<dyn StubProvider>` per requested kind so individual
/// stubs are dropped exactly when the harness goes out of scope. The
/// runner threads `StubHarness::endpoints()` into the sandbox env and
/// calls [`StubHarness::drain_all`] after each payload run.
#[derive(Debug, Default)]
pub struct StubHarness {
stubs: Vec<Arc<dyn StubProvider>>,
}
impl StubHarness {
/// Start the stubs in `kinds`. Each stub roots itself under
/// `workdir` when it needs disk-backed state (SqlStub's DB file,
/// FilesystemStub's fake root); network stubs ignore `workdir` and
/// bind a random loopback port.
///
/// Returns the first I/O error any stub raises during start. A
/// partial start is *not* exposed: stubs that started before the
/// failing one are dropped immediately so callers cannot observe
/// a half-spawned harness.
pub fn start(kinds: &[StubKind], workdir: &Path) -> std::io::Result<Self> {
let mut stubs: Vec<Arc<dyn StubProvider>> = Vec::with_capacity(kinds.len());
// Deduplicate kinds so repeated entries in spec.stubs_required
// (e.g. cap = SQL_QUERY | SSRF | SQL_QUERY) don't double-spawn.
let mut seen = Vec::with_capacity(kinds.len());
for &k in kinds {
if seen.contains(&k) {
continue;
}
seen.push(k);
let stub: Arc<dyn StubProvider> = match k {
StubKind::Sql => Arc::new(SqlStub::start(workdir)?),
StubKind::Http => Arc::new(HttpStub::start()?),
StubKind::Redis => Arc::new(RedisStub::start()?),
StubKind::Filesystem => Arc::new(FilesystemStub::start(workdir)?),
};
stubs.push(stub);
}
Ok(Self { stubs })
}
/// `(env_var_name, endpoint_value)` pairs the verifier merges into
/// the sandbox env. The order matches `StubHarness::start`'s kinds
/// argument so later entries override earlier ones if a harness is
/// re-used with conflicting requests (it currently never is).
pub fn endpoints(&self) -> Vec<(&'static str, String)> {
self.stubs
.iter()
.map(|s| (s.kind().env_var(), s.endpoint()))
.collect()
}
/// Borrow the underlying stub list (for tests and oracle wiring).
pub fn stubs(&self) -> &[Arc<dyn StubProvider>] {
&self.stubs
}
/// Drain events from every stub, tagging each with the stub kind.
/// Returned in stub-spawn order; within a stub, events keep
/// insertion order.
pub fn drain_all(&self) -> Vec<StubEvent> {
let mut all = Vec::new();
for s in &self.stubs {
all.extend(s.drain_events());
}
all
}
/// True when no stubs were spawned. The 500 ms boot budget in
/// Phase 10's acceptance criteria covers exactly this case.
pub fn is_empty(&self) -> bool {
self.stubs.is_empty()
}
/// Number of spawned stubs (test helper).
pub fn len(&self) -> usize {
self.stubs.len()
}
}
/// Monotonic-ish nanoseconds since boot. Used to timestamp `StubEvent`s
/// so a per-stub event log keeps insertion order even when multiple
/// stubs interleave writes.
pub(crate) fn monotonic_ns() -> u64 {
use std::time::Instant;
use std::sync::OnceLock;
static ORIGIN: OnceLock<Instant> = OnceLock::new();
let origin = *ORIGIN.get_or_init(Instant::now);
origin.elapsed().as_nanos() as u64
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn stub_kind_env_vars_are_distinct() {
let names: Vec<&str> = [
StubKind::Sql,
StubKind::Http,
StubKind::Redis,
StubKind::Filesystem,
]
.iter()
.map(|k| k.env_var())
.collect();
let mut sorted = names.clone();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(sorted.len(), names.len(), "env vars must be unique");
}
#[test]
fn for_cap_sql_query_picks_sql() {
assert_eq!(StubKind::for_cap(Cap::SQL_QUERY), vec![StubKind::Sql]);
}
#[test]
fn for_cap_ssrf_picks_http() {
assert_eq!(StubKind::for_cap(Cap::SSRF), vec![StubKind::Http]);
}
#[test]
fn for_cap_file_io_picks_filesystem() {
assert_eq!(StubKind::for_cap(Cap::FILE_IO), vec![StubKind::Filesystem]);
}
#[test]
fn for_cap_unrelated_cap_picks_nothing() {
assert!(StubKind::for_cap(Cap::CODE_EXEC).is_empty());
}
#[test]
fn for_cap_unions_multi_bit_caps() {
let caps = Cap::SQL_QUERY | Cap::SSRF;
let stubs = StubKind::for_cap(caps);
assert!(stubs.contains(&StubKind::Sql));
assert!(stubs.contains(&StubKind::Http));
assert_eq!(stubs.len(), 2);
}
#[test]
fn empty_kinds_starts_in_under_500ms() {
// The "harness with `stubs_required: []` boots in under 500ms"
// acceptance bullet specifically targets this case — when no
// stubs are requested, StubHarness::start must be a no-op.
let dir = TempDir::new().unwrap();
let start = std::time::Instant::now();
let h = StubHarness::start(&[], dir.path()).unwrap();
let elapsed = start.elapsed();
assert!(h.is_empty(), "empty kinds must spawn nothing");
assert!(
elapsed < std::time::Duration::from_millis(500),
"empty stubs_required must boot in <500ms (was {elapsed:?})"
);
}
#[test]
fn dedup_repeated_kinds_during_start() {
let dir = TempDir::new().unwrap();
let h = StubHarness::start(
&[StubKind::Sql, StubKind::Sql, StubKind::Sql],
dir.path(),
)
.unwrap();
assert_eq!(h.len(), 1, "repeated kinds must be deduped");
}
#[test]
fn endpoints_carries_stub_specific_env_var_names() {
let dir = TempDir::new().unwrap();
let h = StubHarness::start(
&[StubKind::Sql, StubKind::Http, StubKind::Filesystem],
dir.path(),
)
.unwrap();
let names: Vec<&str> = h.endpoints().iter().map(|(n, _)| *n).collect();
assert!(names.contains(&"NYX_SQL_ENDPOINT"));
assert!(names.contains(&"NYX_HTTP_ENDPOINT"));
assert!(names.contains(&"NYX_FS_ROOT"));
}
}