2026-05-17 14:29:14 -05:00
|
|
|
|
//! 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.
|
|
|
|
|
|
|
2026-05-17 16:37:20 -05:00
|
|
|
|
pub mod adapters;
|
2026-05-21 20:53:21 -05:00
|
|
|
|
pub mod auth_markers;
|
2026-05-17 14:29:14 -05:00
|
|
|
|
pub mod registry;
|
|
|
|
|
|
|
|
|
|
|
|
use crate::evidence::EntryKind;
|
|
|
|
|
|
use crate::summary::FuncSummary;
|
2026-05-22 02:52:00 -05:00
|
|
|
|
use crate::summary::ssa_summary::SsaFuncSummary;
|
2026-05-17 14:29:14 -05:00
|
|
|
|
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>;
|
2026-05-22 02:52:00 -05:00
|
|
|
|
|
|
|
|
|
|
/// Detection variant that also receives the function's
|
|
|
|
|
|
/// [`SsaFuncSummary`] when one is available on the caller side.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// The SSA summary carries per-call-site receiver-type info via
|
|
|
|
|
|
/// [`SsaFuncSummary::typed_call_receivers`], which adapters can
|
|
|
|
|
|
/// use to discriminate permissive callee-name matches (e.g.
|
|
|
|
|
|
/// distinguishing `gin.Engine::Get` from `cache.Get`). The
|
|
|
|
|
|
/// default implementation ignores the SSA input and delegates to
|
|
|
|
|
|
/// [`Self::detect`], so existing adapters keep working unchanged.
|
|
|
|
|
|
/// Adapters that want receiver-type-aware FP narrowing override
|
|
|
|
|
|
/// this method and consult the SSA summary directly.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Callers without an SSA summary in hand (most test paths,
|
|
|
|
|
|
/// pre-pass-1 callers) pass `None` here.
|
|
|
|
|
|
fn detect_with_context(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
summary: &FuncSummary,
|
|
|
|
|
|
_ssa_summary: Option<&SsaFuncSummary>,
|
|
|
|
|
|
ast: tree_sitter::Node<'_>,
|
|
|
|
|
|
file_bytes: &[u8],
|
|
|
|
|
|
) -> Option<FrameworkBinding> {
|
|
|
|
|
|
self.detect(summary, ast, file_bytes)
|
|
|
|
|
|
}
|
2026-05-17 14:29:14 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 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,
|
2026-05-22 02:52:00 -05:00
|
|
|
|
) -> Option<FrameworkBinding> {
|
|
|
|
|
|
detect_binding_with_context(summary, None, ast, file_bytes, lang)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// SSA-aware sibling of [`detect_binding`].
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Threads an `Option<&SsaFuncSummary>` through to every adapter's
|
|
|
|
|
|
/// [`FrameworkAdapter::detect_with_context`] so adapters can
|
|
|
|
|
|
/// consume receiver-type facts when available. Callers without an
|
|
|
|
|
|
/// SSA summary in hand pass `None`, at which point this function is
|
|
|
|
|
|
/// behaviourally identical to [`detect_binding`] (adapters' default
|
|
|
|
|
|
/// `detect_with_context` delegates to `detect`).
|
|
|
|
|
|
pub fn detect_binding_with_context(
|
|
|
|
|
|
summary: &FuncSummary,
|
|
|
|
|
|
ssa_summary: Option<&SsaFuncSummary>,
|
|
|
|
|
|
ast: tree_sitter::Node<'_>,
|
|
|
|
|
|
file_bytes: &[u8],
|
|
|
|
|
|
lang: Lang,
|
2026-05-17 14:29:14 -05:00
|
|
|
|
) -> Option<FrameworkBinding> {
|
|
|
|
|
|
for adapter in registry::adapters_for(lang) {
|
|
|
|
|
|
debug_assert_eq!(
|
|
|
|
|
|
adapter.lang(),
|
|
|
|
|
|
lang,
|
|
|
|
|
|
"adapter '{}' registered under wrong lang",
|
|
|
|
|
|
adapter.name()
|
|
|
|
|
|
);
|
2026-05-22 02:52:00 -05:00
|
|
|
|
if let Some(binding) = adapter.detect_with_context(summary, ssa_summary, ast, file_bytes) {
|
2026-05-17 14:29:14 -05:00
|
|
|
|
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]
|
2026-05-20 18:05:31 -05:00
|
|
|
|
fn registry_baseline_after_phase_21() {
|
|
|
|
|
|
// Phase 21 (Track M.3) adds the remaining five `EntryKind`
|
|
|
|
|
|
// variants — `ScheduledJob` / `GraphQLResolver` / `WebSocket`
|
|
|
|
|
|
// / `Middleware` / `Migration` — distributed across the
|
|
|
|
|
|
// language slices. Per-lang deltas vs the Phase 20 baseline:
|
|
|
|
|
|
// Java: +2 (ScheduledQuartz, MiddlewareSpring) 14 → 16
|
2026-05-21 17:18:25 -05:00
|
|
|
|
// +1 follow-up (MigrationFlyway) 16 → 17
|
2026-05-20 18:05:31 -05:00
|
|
|
|
// Php: +2 (MiddlewareLaravel, MigrationLaravel) 10 → 12
|
|
|
|
|
|
// Python: +7 (GraphqlGraphene, MiddlewareDjango,
|
|
|
|
|
|
// MigrationDjango, MigrationFlask,
|
|
|
|
|
|
// ScheduledCelery, WebsocketChannels,
|
|
|
|
|
|
// WebsocketSocketIo) 15 → 22
|
|
|
|
|
|
// Ruby: +4 (MiddlewareRails, MigrationRails,
|
|
|
|
|
|
// ScheduledSidekiq, WebsocketActionCable) 8 → 12
|
|
|
|
|
|
// JavaScript: +7 (GraphqlApollo, GraphqlRelay,
|
|
|
|
|
|
// MiddlewareExpress, MigrationPrisma,
|
|
|
|
|
|
// MigrationSequelize, ScheduledCron,
|
|
|
|
|
|
// WebsocketWs) 12 → 19
|
|
|
|
|
|
// Go: +1 (GraphqlGqlgen) 9 → 10
|
|
|
|
|
|
// Rust: +1 (GraphqlJuniper) 6 → 7
|
|
|
|
|
|
// TypeScript / C / Cpp stay unchanged.
|
2026-05-22 17:17:50 -05:00
|
|
|
|
//
|
|
|
|
|
|
// Track L.9 starter slice (Phase 11 follow-up): adds per-cap
|
|
|
|
|
|
// adapters for `Cap::CRYPTO` (Python / Java / JavaScript)
|
|
|
|
|
|
// and `Cap::DATA_EXFIL` (Python / JavaScript / Go).
|
|
|
|
|
|
// Java: +1 (CryptoJava) 18 → 19
|
|
|
|
|
|
// Python: +2 (CryptoPython, DataExfilPython) 22 → 24
|
|
|
|
|
|
// JavaScript: +2 (CryptoJs, DataExfilJs) 20 → 22
|
|
|
|
|
|
// Go: +1 (DataExfilGo) 11 → 12
|
2026-05-22 18:03:08 -05:00
|
|
|
|
// Track L.9 follow-up slice (session-0015 of run 7d60):
|
|
|
|
|
|
// CRYPTO × {Php, Ruby} + DATA_EXFIL × Ruby.
|
|
|
|
|
|
// Php: +1 (CryptoPhp) 12 → 13
|
|
|
|
|
|
// Ruby: +2 (CryptoRuby, DataExfilRuby) 12 → 14
|
2026-05-18 13:46:43 -05:00
|
|
|
|
let java_registered = registry::adapters_for(Lang::Java);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
java_registered.len(),
|
2026-05-22 17:17:50 -05:00
|
|
|
|
19,
|
|
|
|
|
|
"Java must have Phase 21 baseline (18) + Track L.9 CryptoJava (1)",
|
2026-05-18 13:46:43 -05:00
|
|
|
|
);
|
|
|
|
|
|
for adapter in java_registered {
|
|
|
|
|
|
assert_eq!(adapter.lang(), Lang::Java);
|
|
|
|
|
|
}
|
|
|
|
|
|
let php_registered = registry::adapters_for(Lang::Php);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
php_registered.len(),
|
2026-05-22 18:03:08 -05:00
|
|
|
|
13,
|
|
|
|
|
|
"Php must have Phase 20 baseline (10) + M.3 Laravel middleware+migration (2) + Track L.9 CryptoPhp (1)",
|
2026-05-18 13:46:43 -05:00
|
|
|
|
);
|
|
|
|
|
|
for adapter in php_registered {
|
|
|
|
|
|
assert_eq!(adapter.lang(), Lang::Php);
|
2026-05-17 16:37:20 -05:00
|
|
|
|
}
|
2026-05-18 11:02:46 -05:00
|
|
|
|
let python_registered = registry::adapters_for(Lang::Python);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
python_registered.len(),
|
2026-05-22 17:17:50 -05:00
|
|
|
|
24,
|
|
|
|
|
|
"Python must have Phase 21 baseline (22) + Track L.9 (CryptoPython, DataExfilPython)",
|
2026-05-18 11:02:46 -05:00
|
|
|
|
);
|
|
|
|
|
|
for adapter in python_registered {
|
|
|
|
|
|
assert_eq!(adapter.lang(), Lang::Python);
|
|
|
|
|
|
}
|
2026-05-17 22:32:44 -05:00
|
|
|
|
let ruby_registered = registry::adapters_for(Lang::Ruby);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
ruby_registered.len(),
|
2026-05-22 18:03:08 -05:00
|
|
|
|
14,
|
|
|
|
|
|
"Ruby must have Phase 20 baseline (8) + M.3 Phase-21 (4) + Track L.9 (CryptoRuby, DataExfilRuby)",
|
2026-05-17 22:32:44 -05:00
|
|
|
|
);
|
|
|
|
|
|
for adapter in ruby_registered {
|
|
|
|
|
|
assert_eq!(adapter.lang(), Lang::Ruby);
|
|
|
|
|
|
}
|
2026-05-17 18:51:13 -05:00
|
|
|
|
let js_registered = registry::adapters_for(Lang::JavaScript);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
js_registered.len(),
|
2026-05-22 17:17:50 -05:00
|
|
|
|
22,
|
|
|
|
|
|
"JavaScript must have Phase 21 baseline (20) + Track L.9 (CryptoJs, DataExfilJs)",
|
2026-05-17 18:51:13 -05:00
|
|
|
|
);
|
2026-05-17 23:47:12 -05:00
|
|
|
|
for adapter in js_registered {
|
|
|
|
|
|
assert_eq!(adapter.lang(), Lang::JavaScript);
|
|
|
|
|
|
}
|
2026-05-18 08:02:10 -05:00
|
|
|
|
let ts_registered = registry::adapters_for(Lang::TypeScript);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
ts_registered.len(),
|
2026-05-18 12:14:53 -05:00
|
|
|
|
4,
|
2026-05-20 18:05:31 -05:00
|
|
|
|
"TypeScript stays at Phase 20 baseline (4)",
|
2026-05-18 08:02:10 -05:00
|
|
|
|
);
|
|
|
|
|
|
for adapter in ts_registered {
|
|
|
|
|
|
assert_eq!(adapter.lang(), Lang::TypeScript);
|
|
|
|
|
|
}
|
2026-05-17 20:39:12 -05:00
|
|
|
|
let go_registered = registry::adapters_for(Lang::Go);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
go_registered.len(),
|
2026-05-22 17:17:50 -05:00
|
|
|
|
12,
|
|
|
|
|
|
"Go must have Phase 21 baseline (11) + Track L.9 DataExfilGo (1)",
|
2026-05-18 01:08:32 -05:00
|
|
|
|
);
|
|
|
|
|
|
for adapter in go_registered {
|
|
|
|
|
|
assert_eq!(adapter.lang(), Lang::Go);
|
|
|
|
|
|
}
|
|
|
|
|
|
let rust_registered = registry::adapters_for(Lang::Rust);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
rust_registered.len(),
|
2026-05-21 17:48:11 -05:00
|
|
|
|
8,
|
|
|
|
|
|
"Rust must have Phase 20 baseline (6) + M.3 juniper (1) + refinery (1)",
|
2026-05-17 20:39:12 -05:00
|
|
|
|
);
|
2026-05-18 02:32:13 -05:00
|
|
|
|
for adapter in rust_registered {
|
|
|
|
|
|
assert_eq!(adapter.lang(), Lang::Rust);
|
|
|
|
|
|
}
|
2026-05-18 08:02:10 -05:00
|
|
|
|
for lang in [Lang::C, Lang::Cpp] {
|
2026-05-17 14:29:14 -05:00
|
|
|
|
assert!(
|
|
|
|
|
|
registry::adapters_for(lang).is_empty(),
|
2026-05-17 16:37:20 -05:00
|
|
|
|
"{:?} should still have zero adapters before its Track-L phase",
|
|
|
|
|
|
lang,
|
2026-05-17 14:29:14 -05:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[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());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 02:52:00 -05:00
|
|
|
|
/// Adapter that overrides the SSA-aware variant only. Returns a
|
|
|
|
|
|
/// binding whose `adapter` field encodes whether the SSA summary
|
|
|
|
|
|
/// was visible (`"with-ssa"` vs `"no-ssa"`).
|
|
|
|
|
|
struct SsaProbingAdapter;
|
|
|
|
|
|
impl FrameworkAdapter for SsaProbingAdapter {
|
|
|
|
|
|
fn name(&self) -> &'static str {
|
|
|
|
|
|
"ssa-probe"
|
|
|
|
|
|
}
|
|
|
|
|
|
fn lang(&self) -> Lang {
|
|
|
|
|
|
Lang::Python
|
|
|
|
|
|
}
|
|
|
|
|
|
fn detect(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
_summary: &FuncSummary,
|
|
|
|
|
|
_ast: tree_sitter::Node<'_>,
|
|
|
|
|
|
_file_bytes: &[u8],
|
|
|
|
|
|
) -> Option<FrameworkBinding> {
|
|
|
|
|
|
None
|
|
|
|
|
|
}
|
|
|
|
|
|
fn detect_with_context(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
_summary: &FuncSummary,
|
|
|
|
|
|
ssa: Option<&SsaFuncSummary>,
|
|
|
|
|
|
_ast: tree_sitter::Node<'_>,
|
|
|
|
|
|
_file_bytes: &[u8],
|
|
|
|
|
|
) -> Option<FrameworkBinding> {
|
|
|
|
|
|
let tag = if ssa.is_some() { "with-ssa" } else { "no-ssa" };
|
|
|
|
|
|
Some(FrameworkBinding {
|
|
|
|
|
|
adapter: tag.into(),
|
|
|
|
|
|
kind: EntryKind::HttpRoute,
|
|
|
|
|
|
route: None,
|
|
|
|
|
|
request_params: vec![],
|
|
|
|
|
|
response_writer: None,
|
|
|
|
|
|
middleware: vec![],
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Adapter that only overrides `detect` and relies on the
|
|
|
|
|
|
/// trait's default `detect_with_context` to delegate. Used to
|
|
|
|
|
|
/// pin the additive-by-default contract: callers passing an SSA
|
|
|
|
|
|
/// summary still reach the legacy `detect` path on adapters that
|
|
|
|
|
|
/// have not been upgraded.
|
|
|
|
|
|
struct LegacyDetectOnlyAdapter;
|
|
|
|
|
|
impl FrameworkAdapter for LegacyDetectOnlyAdapter {
|
|
|
|
|
|
fn name(&self) -> &'static str {
|
|
|
|
|
|
"legacy"
|
|
|
|
|
|
}
|
|
|
|
|
|
fn lang(&self) -> Lang {
|
|
|
|
|
|
Lang::Python
|
|
|
|
|
|
}
|
|
|
|
|
|
fn detect(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
summary: &FuncSummary,
|
|
|
|
|
|
_ast: tree_sitter::Node<'_>,
|
|
|
|
|
|
_file_bytes: &[u8],
|
|
|
|
|
|
) -> Option<FrameworkBinding> {
|
|
|
|
|
|
Some(FrameworkBinding {
|
|
|
|
|
|
adapter: format!("legacy:{}", summary.name),
|
|
|
|
|
|
kind: EntryKind::HttpRoute,
|
|
|
|
|
|
route: None,
|
|
|
|
|
|
request_params: vec![],
|
|
|
|
|
|
response_writer: None,
|
|
|
|
|
|
middleware: vec![],
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn detect_with_context_default_impl_delegates_to_detect() {
|
|
|
|
|
|
// A legacy adapter that only implements `detect` must still
|
|
|
|
|
|
// produce a binding when reached via the SSA-aware entry
|
|
|
|
|
|
// point, with or without an SSA summary in hand.
|
|
|
|
|
|
let summary = synth_summary("handler", "python");
|
|
|
|
|
|
let src: &[u8] = b"def handler():\n pass\n";
|
|
|
|
|
|
let tree = parse_python(src);
|
|
|
|
|
|
let adapter = LegacyDetectOnlyAdapter;
|
|
|
|
|
|
|
|
|
|
|
|
let no_ssa = adapter.detect_with_context(&summary, None, tree.root_node(), src);
|
2026-05-22 09:42:18 -05:00
|
|
|
|
assert_eq!(
|
|
|
|
|
|
no_ssa.as_ref().map(|b| b.adapter.as_str()),
|
|
|
|
|
|
Some("legacy:handler")
|
|
|
|
|
|
);
|
2026-05-22 02:52:00 -05:00
|
|
|
|
|
|
|
|
|
|
let mut ssa = SsaFuncSummary::default();
|
2026-05-22 09:42:18 -05:00
|
|
|
|
ssa.typed_call_receivers.push((0, "Repository".to_string()));
|
2026-05-22 02:52:00 -05:00
|
|
|
|
let with_ssa = adapter.detect_with_context(&summary, Some(&ssa), tree.root_node(), src);
|
|
|
|
|
|
// Default impl ignores the SSA summary, so both calls produce
|
|
|
|
|
|
// the same binding identity.
|
|
|
|
|
|
assert_eq!(with_ssa, no_ssa);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn detect_with_context_lets_adapter_observe_ssa_summary() {
|
|
|
|
|
|
// An adapter that overrides `detect_with_context` sees the
|
|
|
|
|
|
// SSA summary handed in by the caller.
|
|
|
|
|
|
let summary = synth_summary("handler", "python");
|
|
|
|
|
|
let src: &[u8] = b"def handler():\n pass\n";
|
|
|
|
|
|
let tree = parse_python(src);
|
|
|
|
|
|
let adapter = SsaProbingAdapter;
|
|
|
|
|
|
|
|
|
|
|
|
let no_ssa = adapter.detect_with_context(&summary, None, tree.root_node(), src);
|
|
|
|
|
|
assert_eq!(no_ssa.as_ref().map(|b| b.adapter.as_str()), Some("no-ssa"));
|
|
|
|
|
|
|
|
|
|
|
|
let ssa = SsaFuncSummary::default();
|
|
|
|
|
|
let with_ssa = adapter.detect_with_context(&summary, Some(&ssa), tree.root_node(), src);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
with_ssa.as_ref().map(|b| b.adapter.as_str()),
|
|
|
|
|
|
Some("with-ssa")
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn detect_binding_function_uses_legacy_detect_path() {
|
|
|
|
|
|
// The bare `detect_binding` entry point must keep working
|
|
|
|
|
|
// for every existing test in the tree — empty registry
|
|
|
|
|
|
// means no binding regardless of how it dispatches.
|
|
|
|
|
|
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 detect_binding_with_context_function_accepts_none() {
|
|
|
|
|
|
// Passing `None` for the SSA summary is behaviourally
|
|
|
|
|
|
// identical to calling `detect_binding`.
|
|
|
|
|
|
let summary = synth_summary("handler", "python");
|
|
|
|
|
|
let src: &[u8] = b"def handler():\n pass\n";
|
|
|
|
|
|
let tree = parse_python(src);
|
|
|
|
|
|
let binding =
|
|
|
|
|
|
detect_binding_with_context(&summary, None, tree.root_node(), src, Lang::Python);
|
|
|
|
|
|
assert!(binding.is_none());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-17 14:29:14 -05:00
|
|
|
|
#[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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|