[pitboss] phase 01: Track L.0 — FrameworkAdapter trait + per-lang dispatch table

This commit is contained in:
pitboss 2026-05-17 14:29:14 -05:00
parent 4a2acf1bf9
commit 16834a6e7c
30 changed files with 456 additions and 0 deletions

View file

@ -0,0 +1,280 @@
//! Framework adapter abstraction (Track L.0).
//!
//! Replaces the ad-hoc per-language route / `main` detection that was
//! scattered across [`crate::dynamic::lang`] sub-modules with a single
//! dispatching trait. Every later phase in Track L plugs a concrete
//! adapter (Flask, Spring, Express, axum, …) into this trait.
//!
//! # Determinism
//!
//! [`detect_binding`] iterates the per-language adapter slice returned
//! by [`registry::adapters_for`] in registration order and returns the
//! first non-`None` match. The registration order is fixed at
//! compile time and kept sorted by [`FrameworkAdapter::name`] so a
//! phase that adds a new adapter cannot silently re-order an existing
//! match.
pub mod registry;
use crate::evidence::EntryKind;
use crate::summary::FuncSummary;
use crate::symbol::Lang;
use serde::{Deserialize, Serialize};
/// HTTP method recognised by route bindings. Mirrors
/// [`crate::entry_points::HttpMethod`] but is re-declared here so the
/// framework module does not pull in the static-analysis entry-point
/// types in callers that only need the dynamic-side shape.
pub use crate::entry_points::HttpMethod;
/// HTTP route shape extracted from a framework binding (path +
/// method). Only populated when [`FrameworkBinding::kind`] is
/// [`EntryKind::HttpRoute`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RouteShape {
/// HTTP verb (`GET`, `POST`, …).
pub method: HttpMethod,
/// Route path template as registered with the framework (e.g.
/// `"/users/{id}"`). Adapter-specific placeholder syntax is
/// preserved verbatim.
pub path: String,
}
/// Where on the external surface a function formal originates from.
///
/// Adapters classify each declared parameter into one of these
/// buckets so downstream harness emitters know which request field
/// carries the payload.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ParamSource {
/// URL path placeholder (e.g. `/users/{id}` → `id`).
PathSegment(String),
/// URL query string parameter.
QueryParam(String),
/// HTTP request header.
Header(String),
/// JSON request body (deserialised whole).
JsonBody,
/// HTML form field.
FormField(String),
/// HTTP cookie.
Cookie(String),
/// Implicit context object (e.g. `*gin.Context`, `HttpRequest`).
/// Not adversary-controlled directly; included so the binding
/// captures every formal position.
Implicit,
}
/// Binding between a function formal and its external request slot.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ParamBinding {
/// 0-based position in [`FuncSummary::param_names`].
pub index: usize,
/// Declared parameter name (mirrors
/// `summary.param_names[index]`).
pub name: String,
/// External slot this parameter is wired to.
pub source: ParamSource,
}
/// Shape of how the handler writes a response. Track L plans to use
/// this to pick the right oracle (HTML render → XSS, JSON → no-op,
/// redirect → open-redirect).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResponseShape {
/// Response media kind.
pub kind: ResponseKind,
}
/// Coarse classification of a response writer's output.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ResponseKind {
Json,
Html,
Text,
Redirect,
Stream,
}
/// Middleware attached to a route (auth filter, CSRF guard,
/// before-action, decorator chain, …). Adapters record the name so
/// later phases can classify it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MiddlewareShape {
/// Adapter-local middleware identifier (e.g. `"login_required"`,
/// `"@PreAuthorize"`, `"csrf"`).
pub name: String,
}
/// Full framework binding for a function: every detail about how an
/// external surface reaches the function body.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FrameworkBinding {
/// Stable id of the adapter that produced this binding. Equal to
/// the originating [`FrameworkAdapter::name`]. Persisted into
/// trace details verbatim.
pub adapter: String,
/// Entry-surface taxonomy bucket this function falls into.
pub kind: EntryKind,
/// HTTP route shape when [`Self::kind`] is
/// [`EntryKind::HttpRoute`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub route: Option<RouteShape>,
/// Per-formal external-slot classification. May be empty if the
/// adapter does not yet model parameter shapes (e.g. a Phase-01
/// stub).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub request_params: Vec<ParamBinding>,
/// Response writer shape, when the adapter can determine it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub response_writer: Option<ResponseShape>,
/// Middleware chain attached to the route, in declaration order.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub middleware: Vec<MiddlewareShape>,
}
/// Per-framework adapter trait. Each implementation inspects a
/// function (via its [`FuncSummary`] and the file's AST root) and
/// decides whether the function is bound to an external entry
/// surface.
///
/// Implementations live next to the per-language harness emitters in
/// [`crate::dynamic::lang`] and register into [`registry::adapters_for`]
/// in subsequent Track-L phases. Phase 01 ships the trait and an
/// empty registry per language.
pub trait FrameworkAdapter: Sync {
/// Stable adapter id (e.g. `"flask"`, `"spring-mvc"`, `"axum"`).
/// Used for deterministic ordering inside the registry and for
/// the trace-event detail string emitted by the verifier.
fn name(&self) -> &'static str;
/// Language this adapter targets.
fn lang(&self) -> Lang;
/// Inspect a function and return its [`FrameworkBinding`] when
/// the function is driven by this adapter, otherwise `None`.
///
/// `ast` is the file's tree-sitter root node and `file_bytes` is
/// the raw source so adapters can re-walk for decorators,
/// routing macros, or registration sites that the
/// [`FuncSummary`] alone does not preserve.
fn detect(
&self,
summary: &FuncSummary,
ast: tree_sitter::Node<'_>,
file_bytes: &[u8],
) -> Option<FrameworkBinding>;
}
/// Walk every adapter registered for `lang` in registration order
/// and return the first non-`None` binding. Returns `None` when no
/// adapter matches or when no adapters are registered for `lang`.
pub fn detect_binding(
summary: &FuncSummary,
ast: tree_sitter::Node<'_>,
file_bytes: &[u8],
lang: Lang,
) -> Option<FrameworkBinding> {
for adapter in registry::adapters_for(lang) {
debug_assert_eq!(
adapter.lang(),
lang,
"adapter '{}' registered under wrong lang",
adapter.name()
);
if let Some(binding) = adapter.detect(summary, ast, file_bytes) {
return Some(binding);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::summary::FuncSummary;
fn synth_summary(name: &str, lang: &str) -> FuncSummary {
FuncSummary {
name: name.into(),
file_path: "tests/synthetic.rs".into(),
lang: lang.into(),
..Default::default()
}
}
fn parse_python(src: &[u8]) -> tree_sitter::Tree {
let mut parser = tree_sitter::Parser::new();
let lang = tree_sitter::Language::from(tree_sitter_python::LANGUAGE);
parser.set_language(&lang).unwrap();
parser.parse(src, None).unwrap()
}
#[test]
fn registry_is_empty_for_every_lang_phase_01() {
// Regression guard: Phase 01 ships the trait + dispatch
// machinery but registers zero adapters. Subsequent Track-L
// phases register concrete adapters per language; this test
// documents the starting baseline so accidental re-ordering
// is caught by `tests/determinism_audit.rs`.
for lang in [
Lang::Rust,
Lang::C,
Lang::Cpp,
Lang::Java,
Lang::Go,
Lang::Php,
Lang::Python,
Lang::Ruby,
Lang::TypeScript,
Lang::JavaScript,
] {
assert!(
registry::adapters_for(lang).is_empty(),
"{:?} starts with zero registered adapters",
lang
);
}
}
#[test]
fn detect_binding_returns_none_with_empty_registry() {
// Empty registry means `detect_binding` short-circuits to
// `None` for every input regardless of summary content.
let summary = synth_summary("handler", "python");
let src: &[u8] = b"def handler():\n pass\n";
let tree = parse_python(src);
let binding = detect_binding(&summary, tree.root_node(), src, Lang::Python);
assert!(binding.is_none());
}
#[test]
fn framework_binding_round_trips_through_serde() {
// The binding is persisted into repro bundles; ensure every
// field round-trips.
let original = FrameworkBinding {
adapter: "flask".into(),
kind: EntryKind::HttpRoute,
route: Some(RouteShape {
method: HttpMethod::POST,
path: "/users/{id}".into(),
}),
request_params: vec![ParamBinding {
index: 0,
name: "id".into(),
source: ParamSource::PathSegment("id".into()),
}],
response_writer: Some(ResponseShape {
kind: ResponseKind::Json,
}),
middleware: vec![MiddlewareShape {
name: "login_required".into(),
}],
};
let json = serde_json::to_string(&original).unwrap();
let parsed: FrameworkBinding = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, original);
}
}

View file

@ -0,0 +1,53 @@
//! Per-language [`super::FrameworkAdapter`] dispatch table.
//!
//! Phase 01 (Track L.0) ships an empty table for every language; the
//! [`super::FrameworkAdapter`] trait, [`super::FrameworkBinding`] data
//! shape, and the [`super::detect_binding`] dispatcher are wired
//! through so subsequent Track-L phases only need to register a
//! concrete adapter here.
//!
//! # Ordering contract
//!
//! Within each `static` slice, adapters must be listed in alphabetical
//! order of [`super::FrameworkAdapter::name`]. The lexical ordering
//! gives a deterministic first-match result that survives merges /
//! rebases without subtle re-ordering bugs. A `framework` unit test
//! ([`super::tests::registry_is_empty_for_every_lang_phase_01`])
//! captures the Phase-01 starting baseline so a phase that registers
//! its first adapter is forced to update both the slice *and* the
//! regression guard in the same change.
use super::FrameworkAdapter;
use crate::symbol::Lang;
/// Adapters registered for `lang`, returned in deterministic
/// first-match order. Returns an empty slice for languages that have
/// no adapters registered yet.
pub fn adapters_for(lang: Lang) -> &'static [&'static dyn FrameworkAdapter] {
match lang {
Lang::Rust => RUST,
Lang::C => C,
Lang::Cpp => CPP,
Lang::Java => JAVA,
Lang::Go => GO,
Lang::Php => PHP,
Lang::Python => PYTHON,
Lang::Ruby => RUBY,
Lang::TypeScript => TYPESCRIPT,
Lang::JavaScript => JAVASCRIPT,
}
}
// All slices intentionally empty in Phase 01. Later Track-L phases
// register concrete adapters (Flask, Spring, axum, Express, …) into
// the appropriate language slice.
static RUST: &[&dyn FrameworkAdapter] = &[];
static C: &[&dyn FrameworkAdapter] = &[];
static CPP: &[&dyn FrameworkAdapter] = &[];
static JAVA: &[&dyn FrameworkAdapter] = &[];
static GO: &[&dyn FrameworkAdapter] = &[];
static PHP: &[&dyn FrameworkAdapter] = &[];
static PYTHON: &[&dyn FrameworkAdapter] = &[];
static RUBY: &[&dyn FrameworkAdapter] = &[];
static TYPESCRIPT: &[&dyn FrameworkAdapter] = &[];
static JAVASCRIPT: &[&dyn FrameworkAdapter] = &[];