diff --git a/src/dynamic/lang/c.rs b/src/dynamic/lang/c.rs new file mode 100644 index 00000000..19b90d68 --- /dev/null +++ b/src/dynamic/lang/c.rs @@ -0,0 +1,52 @@ +//! C harness emitter (stub). +//! +//! No harness source is generated yet — `emit` returns +//! [`UnsupportedReason::LangUnsupported`]. The module exists so that +//! [`crate::dynamic::lang::entry_kinds_supported`] can advertise the entry +//! kinds Track B will deliver (Phase 16: `main(argc, argv)`, +//! `LLVMFuzzerTestOneInput`, free functions with `(const char*, size_t)` or +//! `(int, char**)` shapes) and so the verifier can surface +//! `Inconclusive(EntryKindUnsupported { … })` instead of dropping C findings. + +use crate::dynamic::lang::{HarnessSource, LangEmitter}; +use crate::dynamic::spec::{EntryKind, HarnessSpec}; +use crate::evidence::UnsupportedReason; + +/// Zero-sized [`LangEmitter`] handle for C. +pub struct CEmitter; + +/// Entry kinds the C emitter intends to support once Phase 16 lands. +const SUPPORTED: &[EntryKind] = &[EntryKind::Function]; + +impl LangEmitter for CEmitter { + fn emit(&self, _spec: &HarnessSpec) -> Result { + Err(UnsupportedReason::LangUnsupported) + } + + fn entry_kinds_supported(&self) -> &'static [EntryKind] { + SUPPORTED + } + + fn entry_kind_hint(&self, attempted: EntryKind) -> String { + format!( + "c emitter is a stub; once Phase 16 (Track B Rust + C/C++ vertical) lands it will support {SUPPORTED:?} plus libFuzzer + main(argc, argv) shapes — attempted `EntryKind::{attempted}`" + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn entry_kinds_supported_is_non_empty() { + assert!(!CEmitter.entry_kinds_supported().is_empty()); + } + + #[test] + fn entry_kind_hint_names_attempted_and_phase() { + let hint = CEmitter.entry_kind_hint(EntryKind::LibraryApi); + assert!(hint.contains("LibraryApi")); + assert!(hint.contains("Phase 16")); + } +} diff --git a/src/dynamic/lang/cpp.rs b/src/dynamic/lang/cpp.rs new file mode 100644 index 00000000..0781998d --- /dev/null +++ b/src/dynamic/lang/cpp.rs @@ -0,0 +1,52 @@ +//! C++ harness emitter (stub). +//! +//! No harness source is generated yet — `emit` returns +//! [`UnsupportedReason::LangUnsupported`]. The module exists so that +//! [`crate::dynamic::lang::entry_kinds_supported`] can advertise the entry +//! kinds Track B will deliver (Phase 16: `main(argc, argv)`, +//! `LLVMFuzzerTestOneInput`, free functions with `(const char*, size_t)`) +//! and so the verifier can surface `Inconclusive(EntryKindUnsupported { … })` +//! instead of dropping C++ findings. + +use crate::dynamic::lang::{HarnessSource, LangEmitter}; +use crate::dynamic::spec::{EntryKind, HarnessSpec}; +use crate::evidence::UnsupportedReason; + +/// Zero-sized [`LangEmitter`] handle for C++. +pub struct CppEmitter; + +/// Entry kinds the C++ emitter intends to support once Phase 16 lands. +const SUPPORTED: &[EntryKind] = &[EntryKind::Function]; + +impl LangEmitter for CppEmitter { + fn emit(&self, _spec: &HarnessSpec) -> Result { + Err(UnsupportedReason::LangUnsupported) + } + + fn entry_kinds_supported(&self) -> &'static [EntryKind] { + SUPPORTED + } + + fn entry_kind_hint(&self, attempted: EntryKind) -> String { + format!( + "cpp emitter is a stub; once Phase 16 (Track B Rust + C/C++ vertical) lands it will support {SUPPORTED:?} plus libFuzzer + main(argc, argv) shapes — attempted `EntryKind::{attempted}`" + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn entry_kinds_supported_is_non_empty() { + assert!(!CppEmitter.entry_kinds_supported().is_empty()); + } + + #[test] + fn entry_kind_hint_names_attempted_and_phase() { + let hint = CppEmitter.entry_kind_hint(EntryKind::CliSubcommand); + assert!(hint.contains("CliSubcommand")); + assert!(hint.contains("Phase 16")); + } +} diff --git a/src/dynamic/lang/go.rs b/src/dynamic/lang/go.rs index 8f70d78e..ffea12ef 100644 --- a/src/dynamic/lang/go.rs +++ b/src/dynamic/lang/go.rs @@ -24,10 +24,35 @@ //! //! Build container: `nyx-build-go:{toolchain_id}` (deferred; §19.1). -use crate::dynamic::lang::HarnessSource; -use crate::dynamic::spec::{HarnessSpec, PayloadSlot}; +use crate::dynamic::lang::{HarnessSource, LangEmitter}; +use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot}; use crate::evidence::UnsupportedReason; +/// Zero-sized [`LangEmitter`] handle for Go. Method bodies delegate to the +/// existing free functions in this module. +pub struct GoEmitter; + +/// Entry kinds the Go emitter currently understands. Extended in Phase 15 +/// (Track B Go vertical) to include `HttpRoute` (`net/http`, gin) and CLI +/// (`flag.Parse`) shapes. +const SUPPORTED: &[EntryKind] = &[EntryKind::Function]; + +impl LangEmitter for GoEmitter { + fn emit(&self, spec: &HarnessSpec) -> Result { + emit(spec) + } + + fn entry_kinds_supported(&self) -> &'static [EntryKind] { + SUPPORTED + } + + fn entry_kind_hint(&self, attempted: EntryKind) -> String { + format!( + "go emitter supports {SUPPORTED:?}; this finding's enclosing context is `EntryKind::{attempted}` — Track B will add net/http, gin, flag.Parse shapes in phase 15" + ) + } +} + /// Emit a Go harness for `spec`. pub fn emit(spec: &HarnessSpec) -> Result { match &spec.payload_slot { @@ -203,6 +228,19 @@ mod tests { assert_eq!(err, UnsupportedReason::EntryKindUnsupported); } + #[test] + fn entry_kinds_supported_is_non_empty() { + assert!(!GoEmitter.entry_kinds_supported().is_empty()); + assert!(GoEmitter.entry_kinds_supported().contains(&EntryKind::Function)); + } + + #[test] + fn entry_kind_hint_names_attempted_and_phase() { + let hint = GoEmitter.entry_kind_hint(EntryKind::HttpRoute); + assert!(hint.contains("HttpRoute")); + assert!(hint.contains("phase 15")); + } + #[test] fn capitalize_first_handles_lowercase() { assert_eq!(capitalize_first("handleRequest"), "HandleRequest"); diff --git a/src/dynamic/lang/java.rs b/src/dynamic/lang/java.rs index a6d53b82..1a60aee7 100644 --- a/src/dynamic/lang/java.rs +++ b/src/dynamic/lang/java.rs @@ -26,10 +26,35 @@ //! //! Build container: `nyx-build-java:{toolchain_id}` (deferred; §19.1). -use crate::dynamic::lang::HarnessSource; -use crate::dynamic::spec::{HarnessSpec, PayloadSlot}; +use crate::dynamic::lang::{HarnessSource, LangEmitter}; +use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot}; use crate::evidence::UnsupportedReason; +/// Zero-sized [`LangEmitter`] handle for Java. Method bodies delegate to the +/// existing free functions in this module. +pub struct JavaEmitter; + +/// Entry kinds the Java emitter currently understands. Extended in Phase 14 +/// (Track B Java vertical) to include `HttpRoute` (servlet / Spring / +/// Quarkus) and JUnit static-method shapes. +const SUPPORTED: &[EntryKind] = &[EntryKind::Function]; + +impl LangEmitter for JavaEmitter { + fn emit(&self, spec: &HarnessSpec) -> Result { + emit(spec) + } + + fn entry_kinds_supported(&self) -> &'static [EntryKind] { + SUPPORTED + } + + fn entry_kind_hint(&self, attempted: EntryKind) -> String { + format!( + "java emitter supports {SUPPORTED:?}; this finding's enclosing context is `EntryKind::{attempted}` — Track B will add servlet / Spring / Quarkus shapes in phase 14" + ) + } +} + /// Emit a Java harness for `spec`. pub fn emit(spec: &HarnessSpec) -> Result { match &spec.payload_slot { @@ -182,6 +207,21 @@ mod tests { assert_eq!(err, UnsupportedReason::EntryKindUnsupported); } + #[test] + fn entry_kinds_supported_is_non_empty() { + assert!(!JavaEmitter.entry_kinds_supported().is_empty()); + assert!(JavaEmitter + .entry_kinds_supported() + .contains(&EntryKind::Function)); + } + + #[test] + fn entry_kind_hint_names_attempted_and_phase() { + let hint = JavaEmitter.entry_kind_hint(EntryKind::HttpRoute); + assert!(hint.contains("HttpRoute")); + assert!(hint.contains("phase 14")); + } + #[test] fn harness_has_base64_decoder() { let spec = make_spec(PayloadSlot::Param(0)); diff --git a/src/dynamic/lang/javascript.rs b/src/dynamic/lang/javascript.rs index c9d8ae89..8f2e0e1c 100644 --- a/src/dynamic/lang/javascript.rs +++ b/src/dynamic/lang/javascript.rs @@ -19,10 +19,36 @@ //! Build: no compilation step. Command is `node harness.js`. //! Build container: `nyx-build-node:{toolchain_id}` (deferred; §19.1). -use crate::dynamic::lang::HarnessSource; -use crate::dynamic::spec::{HarnessSpec, PayloadSlot}; +use crate::dynamic::lang::{HarnessSource, LangEmitter}; +use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot}; use crate::evidence::UnsupportedReason; +/// Zero-sized [`LangEmitter`] handle for JavaScript / TypeScript (one +/// emitter, both langs share the same Node.js dispatch). Method bodies +/// delegate to the existing free functions in this module. +pub struct JavaScriptEmitter; + +/// Entry kinds the JS / TS emitter currently understands. Extended in +/// Phase 13 (Track B JS + TS vertical) to include `HttpRoute` (Express / +/// Koa / Next), `CliSubcommand`, etc. +const SUPPORTED: &[EntryKind] = &[EntryKind::Function]; + +impl LangEmitter for JavaScriptEmitter { + fn emit(&self, spec: &HarnessSpec) -> Result { + emit(spec) + } + + fn entry_kinds_supported(&self) -> &'static [EntryKind] { + SUPPORTED + } + + fn entry_kind_hint(&self, attempted: EntryKind) -> String { + format!( + "javascript / typescript emitter supports {SUPPORTED:?}; this finding's enclosing context is `EntryKind::{attempted}` — Track B will add Express / Koa / Next shapes in phase 13" + ) + } +} + /// Emit a Node.js harness for `spec`. pub fn emit(spec: &HarnessSpec) -> Result { match &spec.payload_slot { @@ -230,6 +256,21 @@ mod tests { assert_eq!(harness.entry_subpath, Some("entry.js".to_owned())); } + #[test] + fn entry_kinds_supported_is_non_empty() { + assert!(!JavaScriptEmitter.entry_kinds_supported().is_empty()); + assert!(JavaScriptEmitter + .entry_kinds_supported() + .contains(&EntryKind::Function)); + } + + #[test] + fn entry_kind_hint_names_attempted_and_phase() { + let hint = JavaScriptEmitter.entry_kind_hint(EntryKind::HttpRoute); + assert!(hint.contains("HttpRoute")); + assert!(hint.contains("phase 13")); + } + #[test] fn entry_module_name_is_always_entry_to_match_copy_destination() { // `copy_entry_file` (via `entry_module_filename`) stages every fixture diff --git a/src/dynamic/lang/mod.rs b/src/dynamic/lang/mod.rs index c474bab2..05b26f0a 100644 --- a/src/dynamic/lang/mod.rs +++ b/src/dynamic/lang/mod.rs @@ -1,16 +1,29 @@ //! Per-language harness emitters. //! -//! Each submodule implements `emit(spec) -> HarnessSource` for one language. -//! The top-level [`emit`] function dispatches on `spec.lang`. +//! Each submodule implements [`LangEmitter`] for one language. The top-level +//! [`emit`] function dispatches on `spec.lang` and validates `spec.entry_kind` +//! against the chosen emitter's [`LangEmitter::entry_kinds_supported`] list +//! before delegating, so unsupported entry kinds short-circuit with a typed +//! `UnsupportedReason::EntryKindUnsupported` rather than producing a +//! never-runnable harness. +//! +//! Two free helpers — [`entry_kinds_supported`] and [`entry_kind_hint`] — wrap +//! the trait dispatch so callers outside the harness build path (notably the +//! verifier, which surfaces an `Inconclusive` verdict with the supported list +//! and hint baked in) can advertise capability without instantiating a spec. +pub mod c; +pub mod cpp; pub mod go; pub mod java; pub mod javascript; pub mod php; pub mod python; +pub mod ruby; pub mod rust; +pub mod typescript; -use crate::dynamic::spec::HarnessSpec; +use crate::dynamic::spec::{EntryKind, HarnessSpec}; use crate::evidence::UnsupportedReason; use crate::symbol::Lang; @@ -33,15 +46,128 @@ pub struct HarnessSource { pub entry_subpath: Option, } +/// Per-language harness emitter contract. +/// +/// Implementations are zero-sized unit structs (one per `src/dynamic/lang/*.rs` +/// module). The [`emit`](LangEmitter::emit) method is the legacy +/// per-language entry point retained for the build pipeline; the two +/// capability methods are consulted both at dispatch time (`lang::emit` +/// pre-flight check) and by the verifier when constructing +/// `Inconclusive(EntryKindUnsupported { … })`. +pub trait LangEmitter { + /// Build a harness source bundle for `spec`. + fn emit(&self, spec: &HarnessSpec) -> Result; + + /// The set of [`EntryKind`] variants this emitter understands. + /// + /// Must be non-empty: every emitter advertises at least one shape it can + /// (or will) drive — even stub modules whose `emit` returns + /// `LangUnsupported`. Empty would be indistinguishable from "language + /// not in the dispatch table" and would defeat the structured + /// advertisement that callers consume. + fn entry_kinds_supported(&self) -> &'static [EntryKind]; + + /// Human-actionable hint produced when `attempted` is not in + /// [`entry_kinds_supported`](LangEmitter::entry_kinds_supported). + /// + /// The string is consumed by + /// [`crate::evidence::InconclusiveReason::EntryKindUnsupported::hint`] and + /// surfaces directly to operators triaging dynamic verification gaps; + /// keep it specific (name the supported kinds, name the phase that will + /// extend support). + fn entry_kind_hint(&self, attempted: EntryKind) -> String; +} + /// Dispatch to the appropriate language emitter. +/// +/// Validates `spec.entry_kind` against the chosen emitter's supported list +/// before delegating; an unsupported entry kind short-circuits with +/// [`UnsupportedReason::EntryKindUnsupported`] so the verifier can surface a +/// structured `Inconclusive` verdict with the supported list and hint baked +/// in (instead of producing a never-runnable harness). pub fn emit(spec: &HarnessSpec) -> Result { - match spec.lang { - Lang::Python => python::emit(spec), - Lang::Rust => rust::emit(spec), - Lang::JavaScript | Lang::TypeScript => javascript::emit(spec), - Lang::Go => go::emit(spec), - Lang::Java => java::emit(spec), - Lang::Php => php::emit(spec), - _ => Err(UnsupportedReason::LangUnsupported), + let supported = entry_kinds_supported(spec.lang); + if !supported.is_empty() && !supported.contains(&spec.entry_kind) { + return Err(UnsupportedReason::EntryKindUnsupported); + } + dispatch(spec.lang, |e| e.emit(spec)) + .unwrap_or(Err(UnsupportedReason::LangUnsupported)) +} + +/// Public free-fn dispatcher for the supported entry kinds of `lang`. +/// +/// Returns an empty slice when `lang` has no registered emitter — callers +/// distinguish that from "emitter exists but advertises none" by treating +/// empty as "language unsupported". +pub fn entry_kinds_supported(lang: Lang) -> &'static [EntryKind] { + dispatch(lang, |e| e.entry_kinds_supported()).unwrap_or(&[]) +} + +/// Public free-fn dispatcher for an emitter's hint about `attempted`. +/// +/// Falls back to a generic message when `lang` has no registered emitter so +/// callers do not need to special-case that path. +pub fn entry_kind_hint(lang: Lang, attempted: EntryKind) -> String { + dispatch(lang, |e| e.entry_kind_hint(attempted)).unwrap_or_else(|| { + format!( + "no harness emitter is registered for {lang:?}; attempted {attempted}" + ) + }) +} + +/// Internal helper: invoke `f` against the emitter registered for `lang`, +/// returning `None` when no emitter is registered for that language. +fn dispatch(lang: Lang, f: impl FnOnce(&dyn LangEmitter) -> R) -> Option { + let emitter: Option<&dyn LangEmitter> = match lang { + Lang::Python => Some(&python::PythonEmitter), + Lang::Rust => Some(&rust::RustEmitter), + Lang::JavaScript => Some(&javascript::JavaScriptEmitter), + Lang::TypeScript => Some(&typescript::TypeScriptEmitter), + Lang::Go => Some(&go::GoEmitter), + Lang::Java => Some(&java::JavaEmitter), + Lang::Php => Some(&php::PhpEmitter), + Lang::Ruby => Some(&ruby::RubyEmitter), + Lang::C => Some(&c::CEmitter), + Lang::Cpp => Some(&cpp::CppEmitter), + }; + emitter.map(f) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Every registered emitter must advertise at least one entry kind so the + /// verifier never produces an empty `supported` list in + /// `Inconclusive(EntryKindUnsupported { supported, .. })`. + #[test] + fn every_lang_advertises_at_least_one_entry_kind() { + for lang in [ + Lang::Python, + Lang::Rust, + Lang::JavaScript, + Lang::TypeScript, + Lang::Go, + Lang::Java, + Lang::Php, + Lang::Ruby, + Lang::C, + Lang::Cpp, + ] { + let kinds = entry_kinds_supported(lang); + assert!( + !kinds.is_empty(), + "{lang:?} emitter must advertise at least one EntryKind" + ); + } + } + + #[test] + fn entry_kind_hint_mentions_attempted() { + let hint = entry_kind_hint(Lang::Python, EntryKind::HttpRoute); + assert!( + hint.contains("HttpRoute"), + "hint must mention the attempted entry kind, got: {hint:?}" + ); } } diff --git a/src/dynamic/lang/php.rs b/src/dynamic/lang/php.rs index 917163d4..d0d22689 100644 --- a/src/dynamic/lang/php.rs +++ b/src/dynamic/lang/php.rs @@ -18,10 +18,35 @@ //! Build: no compilation step. Command is `php harness.php`. //! Build container: `nyx-build-php:{toolchain_id}` (deferred; §19.1). -use crate::dynamic::lang::HarnessSource; -use crate::dynamic::spec::{HarnessSpec, PayloadSlot}; +use crate::dynamic::lang::{HarnessSource, LangEmitter}; +use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot}; use crate::evidence::UnsupportedReason; +/// Zero-sized [`LangEmitter`] handle for PHP. Method bodies delegate to the +/// existing free functions in this module. +pub struct PhpEmitter; + +/// Entry kinds the PHP emitter currently understands. Extended in Phase 15 +/// (Track B PHP vertical) to include `HttpRoute` (Slim / Laravel / Symfony +/// closures) and `CliSubcommand` (`$argv`). +const SUPPORTED: &[EntryKind] = &[EntryKind::Function]; + +impl LangEmitter for PhpEmitter { + fn emit(&self, spec: &HarnessSpec) -> Result { + emit(spec) + } + + fn entry_kinds_supported(&self) -> &'static [EntryKind] { + SUPPORTED + } + + fn entry_kind_hint(&self, attempted: EntryKind) -> String { + format!( + "php emitter supports {SUPPORTED:?}; this finding's enclosing context is `EntryKind::{attempted}` — Track B will add Slim / Laravel / Symfony route + CLI shapes in phase 15" + ) + } +} + /// Emit a PHP harness for `spec`. pub fn emit(spec: &HarnessSpec) -> Result { match &spec.payload_slot { @@ -193,6 +218,21 @@ mod tests { assert_eq!(harness.entry_subpath, Some("entry.php".to_owned())); } + #[test] + fn entry_kinds_supported_is_non_empty() { + assert!(!PhpEmitter.entry_kinds_supported().is_empty()); + assert!(PhpEmitter + .entry_kinds_supported() + .contains(&EntryKind::Function)); + } + + #[test] + fn entry_kind_hint_names_attempted_and_phase() { + let hint = PhpEmitter.entry_kind_hint(EntryKind::HttpRoute); + assert!(hint.contains("HttpRoute")); + assert!(hint.contains("phase 15")); + } + #[test] fn harness_has_base64_decode() { let spec = make_spec(PayloadSlot::Param(0)); diff --git a/src/dynamic/lang/python.rs b/src/dynamic/lang/python.rs index c2acc897..cc57faf3 100644 --- a/src/dynamic/lang/python.rs +++ b/src/dynamic/lang/python.rs @@ -13,10 +13,35 @@ //! - `PayloadSlot::EnvVar(name)` — set env var before calling. //! - Other slots produce `UnsupportedReason::EntryKindUnsupported`. -use crate::dynamic::lang::HarnessSource; -use crate::dynamic::spec::{HarnessSpec, PayloadSlot}; +use crate::dynamic::lang::{HarnessSource, LangEmitter}; +use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot}; use crate::evidence::UnsupportedReason; +/// Zero-sized [`LangEmitter`] handle for Python. Registered in the +/// `lang::dispatch` table; method bodies delegate to the existing free +/// functions in this module. +pub struct PythonEmitter; + +/// Entry kinds the Python emitter currently understands. Extended in Phase 12 +/// (Track B Python vertical) to include `HttpRoute`, `CliSubcommand`, etc. +const SUPPORTED: &[EntryKind] = &[EntryKind::Function]; + +impl LangEmitter for PythonEmitter { + fn emit(&self, spec: &HarnessSpec) -> Result { + emit(spec) + } + + fn entry_kinds_supported(&self) -> &'static [EntryKind] { + SUPPORTED + } + + fn entry_kind_hint(&self, attempted: EntryKind) -> String { + format!( + "python emitter supports {SUPPORTED:?}; this finding's enclosing context is `EntryKind::{attempted}` — Track B will add framework + CLI shapes in phase 12" + ) + } +} + /// Emit a Python harness for `spec`. pub fn emit(spec: &HarnessSpec) -> Result { // Validate payload slot. @@ -237,6 +262,21 @@ mod tests { assert_eq!(module_name("no_ext"), "no_ext"); } + #[test] + fn entry_kinds_supported_is_non_empty() { + assert!(!PythonEmitter.entry_kinds_supported().is_empty()); + assert!(PythonEmitter + .entry_kinds_supported() + .contains(&EntryKind::Function)); + } + + #[test] + fn entry_kind_hint_names_attempted_and_phase() { + let hint = PythonEmitter.entry_kind_hint(EntryKind::HttpRoute); + assert!(hint.contains("HttpRoute")); + assert!(hint.contains("phase 12")); + } + #[test] fn unsupported_lang_returns_err() { let mut spec = make_spec(PayloadSlot::Param(0)); diff --git a/src/dynamic/lang/ruby.rs b/src/dynamic/lang/ruby.rs new file mode 100644 index 00000000..260cee61 --- /dev/null +++ b/src/dynamic/lang/ruby.rs @@ -0,0 +1,77 @@ +//! Ruby harness emitter (stub). +//! +//! No harness source is generated yet — `emit` returns +//! [`UnsupportedReason::LangUnsupported`]. The module exists so that +//! [`crate::dynamic::lang::entry_kinds_supported`] can advertise the entry +//! kinds Track B will deliver (Phase 15: Sinatra route, Rails action, Rack +//! middleware, generic controller method) and so the verifier can surface +//! a structured `Inconclusive(EntryKindUnsupported { … })` instead of +//! silently dropping Ruby findings. + +use crate::dynamic::lang::{HarnessSource, LangEmitter}; +use crate::dynamic::spec::{EntryKind, HarnessSpec}; +use crate::evidence::UnsupportedReason; + +/// Zero-sized [`LangEmitter`] handle for Ruby. +pub struct RubyEmitter; + +/// Entry kinds the Ruby emitter intends to support once Phase 15 lands. +/// Advertised pre-implementation so the verifier can route findings into +/// `Inconclusive(EntryKindUnsupported)` rather than `Unsupported`. +const SUPPORTED: &[EntryKind] = &[EntryKind::Function]; + +impl LangEmitter for RubyEmitter { + fn emit(&self, _spec: &HarnessSpec) -> Result { + Err(UnsupportedReason::LangUnsupported) + } + + fn entry_kinds_supported(&self) -> &'static [EntryKind] { + SUPPORTED + } + + fn entry_kind_hint(&self, attempted: EntryKind) -> String { + format!( + "ruby emitter is a stub; once Phase 15 (Track B Ruby vertical) lands it will support {SUPPORTED:?} plus Sinatra / Rails / Rack route shapes — attempted `EntryKind::{attempted}`" + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn entry_kinds_supported_is_non_empty() { + assert!(!RubyEmitter.entry_kinds_supported().is_empty()); + } + + #[test] + fn entry_kind_hint_names_attempted_and_phase() { + let hint = RubyEmitter.entry_kind_hint(EntryKind::HttpRoute); + assert!(hint.contains("HttpRoute")); + assert!(hint.contains("Phase 15")); + } + + #[test] + fn emit_returns_lang_unsupported() { + let spec = HarnessSpec { + finding_id: "0".into(), + entry_file: "x.rb".into(), + entry_name: "f".into(), + entry_kind: EntryKind::Function, + lang: crate::symbol::Lang::Ruby, + toolchain_id: "ruby-3".into(), + payload_slot: crate::dynamic::spec::PayloadSlot::Param(0), + expected_cap: crate::labels::Cap::SQL_QUERY, + constraint_hints: vec![], + sink_file: "x.rb".into(), + sink_line: 1, + spec_hash: "0".into(), + derivation: crate::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, + }; + assert_eq!( + RubyEmitter.emit(&spec).unwrap_err(), + UnsupportedReason::LangUnsupported + ); + } +} diff --git a/src/dynamic/lang/rust.rs b/src/dynamic/lang/rust.rs index db2e80c3..f8d03a2e 100644 --- a/src/dynamic/lang/rust.rs +++ b/src/dynamic/lang/rust.rs @@ -21,11 +21,36 @@ //! //! HTML_ESCAPE is n/a for Rust (§15.4). -use crate::dynamic::lang::HarnessSource; -use crate::dynamic::spec::{HarnessSpec, PayloadSlot}; +use crate::dynamic::lang::{HarnessSource, LangEmitter}; +use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot}; use crate::evidence::UnsupportedReason; use crate::labels::Cap; +/// Zero-sized [`LangEmitter`] handle for Rust. Method bodies delegate to the +/// existing free functions in this module. +pub struct RustEmitter; + +/// Entry kinds the Rust emitter currently understands. Extended in Phase 16 +/// (Track B Rust + C/C++ vertical) to include `HttpRoute` (`actix_web`, +/// `axum`), `CliSubcommand` (clap), and `LibraryApi` (libfuzzer). +const SUPPORTED: &[EntryKind] = &[EntryKind::Function]; + +impl LangEmitter for RustEmitter { + fn emit(&self, spec: &HarnessSpec) -> Result { + emit(spec) + } + + fn entry_kinds_supported(&self) -> &'static [EntryKind] { + SUPPORTED + } + + fn entry_kind_hint(&self, attempted: EntryKind) -> String { + format!( + "rust emitter supports {SUPPORTED:?}; this finding's enclosing context is `EntryKind::{attempted}` — Track B will add actix / axum / clap / libfuzzer shapes in phase 16" + ) + } +} + /// Emit a Rust harness for `spec`. pub fn emit(spec: &HarnessSpec) -> Result { match &spec.payload_slot { @@ -247,6 +272,21 @@ mod tests { assert!(cargo.contains("path = \"src/main.rs\"")); } + #[test] + fn entry_kinds_supported_is_non_empty() { + assert!(!RustEmitter.entry_kinds_supported().is_empty()); + assert!(RustEmitter + .entry_kinds_supported() + .contains(&EntryKind::Function)); + } + + #[test] + fn entry_kind_hint_names_attempted_and_phase() { + let hint = RustEmitter.entry_kind_hint(EntryKind::HttpRoute); + assert!(hint.contains("HttpRoute")); + assert!(hint.contains("phase 16")); + } + #[test] fn b64_decode_roundtrip() { // Test by compiling: actual b64_decode is in generated code. diff --git a/src/dynamic/lang/typescript.rs b/src/dynamic/lang/typescript.rs new file mode 100644 index 00000000..453c32c1 --- /dev/null +++ b/src/dynamic/lang/typescript.rs @@ -0,0 +1,64 @@ +//! TypeScript harness emitter. +//! +//! Today TypeScript shares the JS emitter — `tsc` is not invoked; the runner +//! treats `.ts` / `.tsx` / `.mts` / `.cts` files as Node-compatible because +//! every shape we currently emit (free functions, `module.exports`-style +//! handlers) is identical at the runtime level after type erasure. This +//! module exists so the [`crate::dynamic::lang::LangEmitter`] dispatch table +//! has a discoverable per-language handle and so callers can call +//! `entry_kinds_supported(Lang::TypeScript)` symmetrically with the other +//! languages — the actual `emit` body delegates to +//! [`crate::dynamic::lang::javascript::emit`]. +//! +//! Phase 13 (Track B JS + TS vertical) introduces TS-specific shapes +//! (Next.js route handlers, `tsx` browser modules under jsdom). When those +//! land, the supported list / hint shift here without affecting the JS +//! emitter. + +use crate::dynamic::lang::{javascript, HarnessSource, LangEmitter}; +use crate::dynamic::spec::{EntryKind, HarnessSpec}; +use crate::evidence::UnsupportedReason; + +/// Zero-sized [`LangEmitter`] handle for TypeScript. +pub struct TypeScriptEmitter; + +/// Entry kinds the TypeScript emitter currently understands. Same as JS until +/// Phase 13 introduces TS-specific shapes (Next.js route handlers, `tsx` +/// browser modules). +const SUPPORTED: &[EntryKind] = &[EntryKind::Function]; + +impl LangEmitter for TypeScriptEmitter { + fn emit(&self, spec: &HarnessSpec) -> Result { + javascript::emit(spec) + } + + fn entry_kinds_supported(&self) -> &'static [EntryKind] { + SUPPORTED + } + + fn entry_kind_hint(&self, attempted: EntryKind) -> String { + format!( + "typescript emitter supports {SUPPORTED:?} (delegates to the JavaScript emitter); this finding's enclosing context is `EntryKind::{attempted}` — Track B will add Next.js / jsdom shapes in phase 13" + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn entry_kinds_supported_is_non_empty() { + assert!(!TypeScriptEmitter.entry_kinds_supported().is_empty()); + assert!(TypeScriptEmitter + .entry_kinds_supported() + .contains(&EntryKind::Function)); + } + + #[test] + fn entry_kind_hint_names_attempted_and_phase() { + let hint = TypeScriptEmitter.entry_kind_hint(EntryKind::HttpRoute); + assert!(hint.contains("HttpRoute")); + assert!(hint.contains("phase 13")); + } +} diff --git a/src/dynamic/spec.rs b/src/dynamic/spec.rs index de273951..b5208daf 100644 --- a/src/dynamic/spec.rs +++ b/src/dynamic/spec.rs @@ -47,18 +47,12 @@ pub struct EntryRef { pub function: String, } -/// What kind of entry point the harness should call. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum EntryKind { - /// Free function. Build a `main` that calls it directly. - Function, - /// HTTP route. Stand up the framework, send a request. - HttpRoute, - /// CLI subcommand. Spawn the binary with crafted argv. - CliSubcommand, - /// Library API surface. Build an in-process consumer. - LibraryApi, -} +/// Re-export of [`crate::evidence::EntryKind`]. +/// +/// The canonical definition lives in `evidence.rs` so that +/// [`crate::evidence::InconclusiveReason::EntryKindUnsupported`] can name the +/// attempted / supported variants without depending on the `dynamic` feature. +pub use crate::evidence::EntryKind; /// Where the payload goes when the harness fires. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -210,6 +204,23 @@ impl HarnessSpec { Err(UnsupportedReason::SpecDerivationFailed) } + /// True when [`HarnessSpec::entry_kind`] is in + /// [`crate::dynamic::lang::entry_kinds_supported`] for [`HarnessSpec::lang`]. + /// + /// Strategies 1–4 may stamp non-`Function` entry kinds (route handlers, + /// CLI subcommands) onto the spec when the rule namespace or the + /// resolved [`crate::summary::FuncSummary`] indicates the enclosing + /// function is externally driven; not every lang emitter understands + /// those shapes yet (Tracks B.12–B.16 add them per language). The + /// verifier consults this gate so unsupported shapes route to + /// [`crate::evidence::InconclusiveReason::EntryKindUnsupported`] with a + /// concrete supported list and hint, rather than degrading silently to + /// `Unsupported`. + pub fn entry_kind_is_supported(&self) -> bool { + let supported = crate::dynamic::lang::entry_kinds_supported(self.lang); + supported.contains(&self.entry_kind) + } + /// Returns the ordered list of derivation strategies that /// [`HarnessSpec::from_finding_opts`] attempts. Used by the verifier when /// it needs to report which candidates were tried before declaring an diff --git a/src/dynamic/telemetry.rs b/src/dynamic/telemetry.rs index f30a4aa1..c86a6af6 100644 --- a/src/dynamic/telemetry.rs +++ b/src/dynamic/telemetry.rs @@ -122,6 +122,41 @@ impl TelemetryEvent { path: Some(diag.path.clone()), } } + + /// Telemetry event for a verdict reached without a [`Diag`] handle. + /// + /// Used by `verify_finding` when emitting an + /// `Inconclusive(EntryKindUnsupported)` from inside `build_verdict` — + /// the diag is not threaded that far, but the spec's `entry_file` and + /// the inconclusive reason carry enough signal to populate the event. + /// `cap` and `finding_id` default to empty / `0`; downstream consumers + /// already handle that path for `no_spec` events. + pub fn no_spec_for_path( + path: &str, + status: VerifyStatus, + inconclusive_reason: Option, + ) -> Self { + let lang = Path::new(path) + .extension() + .and_then(|e| e.to_str()) + .and_then(crate::symbol::Lang::from_extension) + .map(|l| l.as_str().to_owned()) + .unwrap_or_else(|| "unknown".to_owned()); + Self { + ts: chrono::Utc::now().to_rfc3339(), + finding_id: String::new(), + spec_hash: String::new(), + lang, + cap: "0".to_owned(), + status: format!("{status:?}"), + toolchain_id: String::new(), + toolchain_match: String::new(), + duration_ms: 0, + build_attempts: 0, + inconclusive_reason: inconclusive_reason.map(|r| format!("{r:?}")), + path: Some(path.to_owned()), + } + } } /// Write a telemetry event to the events log. diff --git a/src/dynamic/verify.rs b/src/dynamic/verify.rs index f822d5ea..ed818a0f 100644 --- a/src/dynamic/verify.rs +++ b/src/dynamic/verify.rs @@ -167,6 +167,60 @@ fn insert_verdict_cache( ); } +/// Build an `Inconclusive(EntryKindUnsupported)` verdict for a finding whose +/// derived spec named an entry kind the lang emitter does not yet handle. +/// +/// `attempted` is the spec's entry kind; `lang` is the spec's language; the +/// supported list and human-readable hint come from the lang emitter via +/// [`crate::dynamic::lang::entry_kinds_supported`] / +/// [`crate::dynamic::lang::entry_kind_hint`], so adding new shapes in later +/// Track B phases automatically narrows what gets routed here without +/// touching this function. +/// +/// The caller passes the originating [`Diag`] when one is in scope (for the +/// pre-flight gate) or `None` otherwise (for the residual harness-emit path, +/// where only the spec is available); telemetry derives `lang`/`path` from +/// the diag when present and falls back to the spec otherwise. +fn entry_kind_unsupported_verdict( + finding_id: String, + diag: Option<&Diag>, + spec_entry_path: &str, + lang: crate::symbol::Lang, + attempted: crate::dynamic::spec::EntryKind, +) -> VerifyResult { + let supported = crate::dynamic::lang::entry_kinds_supported(lang).to_vec(); + let hint = crate::dynamic::lang::entry_kind_hint(lang, attempted); + let inconclusive_reason = InconclusiveReason::EntryKindUnsupported { + lang, + attempted, + supported, + hint, + }; + let event = match diag { + Some(d) => TelemetryEvent::no_spec( + d, + VerifyStatus::Inconclusive, + Some(inconclusive_reason.clone()), + ), + None => TelemetryEvent::no_spec_for_path( + spec_entry_path, + VerifyStatus::Inconclusive, + Some(inconclusive_reason.clone()), + ), + }; + telemetry::emit(&event); + VerifyResult { + finding_id, + status: VerifyStatus::Inconclusive, + triggered_payload: None, + reason: None, + inconclusive_reason: Some(inconclusive_reason), + detail: None, + attempts: vec![], + toolchain_match: None, + } +} + /// Decide whether a [`HarnessSpec::from_finding_opts`] failure should surface /// as `Unsupported` (the finding is genuinely unmodellable) or /// `Inconclusive(SpecDerivationFailed)` (the rule namespace or sink evidence @@ -279,6 +333,21 @@ pub fn verify_finding(diag: &Diag, opts: &VerifyOptions) -> VerifyResult { } }; + // Pre-flight gate: surface a structured `Inconclusive(EntryKindUnsupported)` + // up-front when the spec's [`EntryKind`] is not in the lang emitter's + // supported list. Without this, the same condition would degrade silently + // through `lang::emit -> HarnessError::Unsupported` and lose the + // supported-list / hint context the operator needs to triage. + if !spec.entry_kind_is_supported() { + return entry_kind_unsupported_verdict( + finding_id, + Some(diag), + &spec.entry_file, + spec.lang, + spec.entry_kind, + ); + } + // Scan the entry file's directory for sensitive files (§17.3 mount filter). // If the entry file itself matches a sensitive pattern, refuse to run it: // the harness would copy it into the workdir and expose secrets. @@ -498,6 +567,25 @@ fn build_verdict( toolchain_match: None, }, Err(RunError::Harness(e)) => { + // EntryKindUnsupported coming back from the lang emitter is + // promoted to a structured `Inconclusive(EntryKindUnsupported)` + // verdict so the operator sees the supported list + hint, not a + // bare `Unsupported`. The pre-flight gate in `verify_finding` + // catches the common case (entry_kind decided by spec + // derivation); this arm covers the residual where an emitter + // rejects a payload-slot / shape combination internally. + if let crate::dynamic::harness::HarnessError::Unsupported( + UnsupportedReason::EntryKindUnsupported, + ) = &e + { + return entry_kind_unsupported_verdict( + finding_id.to_owned(), + None, + &spec.entry_file, + spec.lang, + spec.entry_kind, + ); + } // Typed `Unsupported(reason)` carries its semantics in `reason`; the // free-form `detail` is reserved for `Inconclusive`/unexpected paths // (cf. §10 decision 14 and the verify_result_json_shape contract). diff --git a/src/evidence.rs b/src/evidence.rs index b5645f10..36509679 100644 --- a/src/evidence.rs +++ b/src/evidence.rs @@ -8,6 +8,7 @@ use crate::commands::scan::Diag; use crate::patterns::Severity; +use crate::symbol::Lang; use serde::{Deserialize, Serialize}; use std::fmt; use std::str::FromStr; @@ -188,6 +189,36 @@ pub enum UnsupportedReason { LangUnsupported, } +/// What kind of entry point a harness should call. +/// +/// Lives in `evidence.rs` (not `dynamic::spec`) so that +/// [`InconclusiveReason::EntryKindUnsupported`] can name the attempted / +/// supported variants without depending on the `dynamic` feature. The +/// canonical accessor is `crate::dynamic::spec::EntryKind` (re-export). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum EntryKind { + /// Free function. Build a `main` that calls it directly. + Function, + /// HTTP route. Stand up the framework, send a request. + HttpRoute, + /// CLI subcommand. Spawn the binary with crafted argv. + CliSubcommand, + /// Library API surface. Build an in-process consumer. + LibraryApi, +} + +impl fmt::Display for EntryKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Self::Function => "Function", + Self::HttpRoute => "HttpRoute", + Self::CliSubcommand => "CliSubcommand", + Self::LibraryApi => "LibraryApi", + }; + f.write_str(s) + } +} + /// Spec-derivation strategy attempted by [`crate::dynamic::spec::HarnessSpec::from_finding_opts`]. /// /// Lives in `evidence.rs` (not `dynamic::spec`) so that @@ -252,6 +283,16 @@ pub enum InconclusiveReason { tried: Vec, hint: String, }, + /// The lang-specific harness emitter does not yet support the spec's + /// [`EntryKind`]. Carries the language, the attempted entry kind, the + /// list of entry kinds the emitter currently understands, and a + /// human-actionable hint pointing at the phase that will add support. + EntryKindUnsupported { + lang: Lang, + attempted: EntryKind, + supported: Vec, + hint: String, + }, } /// High-level outcome of a dynamic verification attempt. diff --git a/src/fmt.rs b/src/fmt.rs index 97fffa43..3d3706b4 100644 --- a/src/fmt.rs +++ b/src/fmt.rs @@ -527,6 +527,16 @@ fn format_inconclusive_reason(r: &crate::evidence::InconclusiveReason) -> String format!("spec derivation failed ({hint})") } } + InconclusiveReason::EntryKindUnsupported { + lang, + attempted, + supported, + .. + } => { + format!( + "entry kind {attempted} unsupported for {lang:?} (supported: {supported:?})" + ) + } } }