Prerelease cleanup (#46)

* feat: Add const_bound_vars tracking to prevent false positives in ownership checks

* feat: Introduce field interner and typed bounded vars for enhanced type tracking

* feat: Add typed_call_receivers and typed_bounded_dto_fields for enhanced type tracking

* feat: Centralize method name extraction with bare_method_name helper

* feat: Implement Phase-6 hierarchy fan-out for runtime virtual dispatch

* feat: Enhance C++ taint tracking with additional container operations and inline method resolution

* feat: Introduce field-sensitive points-to analysis for enhanced resource tracking

* feat: Implement Pointer-Phase 6 subscript handling for enhanced container analysis

* test: Add comprehensive tests for JavaScript control flow constructs and lattice operations

* docs: Update advanced analysis documentation with field-sensitive points-to and hierarchy fan-out details

* test: Add comprehensive tests for lattice algebra laws and SSA edge cases

* feat: Add destructured session user handling and safe user ID access patterns

* feat: Implement row-population reverse-walk for enhanced authorization checks

* feat: Enhance authorization checks with local alias chain for self-actor types

* feat: Introduce ActiveRecord query safety checks and enhance snippet extraction

* feat: Implement chained method call inner-gate rebinding for SSRF prevention

* feat: Add observability and error modules, enhance debug functionality, and implement theme context

* feat: Remove Auth Analysis page and update navigation to redirect to Explorer

* feat: Optimize SSA lowering by sharing results between taint engine and artifact extractor

* feat: Optimize SSA lowering by sharing results between taint engine and artifact extractor

* feat: Reset path-safe-suppressed spans before lowering to maintain analysis integrity

* fix(ssa): ungate debug_assert_bfs_ordering for release-tests build

The helper at src/ssa/lower.rs was gated `#[cfg(debug_assertions)]` while
the unit test at the bottom of the file was gated only `#[cfg(test)]`.
Since `cfg(test)` is set in release builds with `--tests` but
`cfg(debug_assertions)` is not, `cargo build --release --tests` failed
with E0425. Removing the gate fixes the build; the body is `debug_assert!`
only, so the helper is free in release. Also drop the gate at the call
site to avoid a `dead_code` warning when the lib is built without
`--tests`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(closure-capture): flip JS/TS fixtures to required-finding

The JS and TS closure-capture fixtures pinned the old broken behaviour
via `forbidden_findings: [{ "id_prefix": "taint-" }]`. The engine now
correctly traces taint through the closure boundary (env source captured
by an arrow function, sunk via `child_process.exec` inside the body), so
the formerly-forbidden finding is a true positive.

Match the Python sibling's shape — `required_findings` with
`id_prefix` + `min_count` plus a small `noise_budget` — and rewrite the
companion READMEs and the phase8_fragility_tests doc-comments from
"known gap" to "regression guard".

Verified:
- cargo test --release --test phase8_fragility_tests → 8/8 pass
- cargo test --release --lib bfs_assertion → pass
- corpus benchmark F1 = 0.9976 (TP=205, FP=1, FN=0) — unchanged

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: Add OWASP mapping and baseline mutation hooks for enhanced security analysis

* feat: Introduce health module and enhance health score computation with calibration tests

* feat: Add expectations configuration and cleanup .gitignore for log files

* feat: Implement theme selection and enhance settings panel for triage sync

* feat: Suppress false positives for strcpy calls with literal sources in AST

* feat: Update analyse_function_ssa to return body CFG for accurate analysis

* feat: Add bug report and feature request templates for improved issue tracking

* feat: removed dev scripts

* feat: update README.md for clarity and consistency in fixture descriptions

* feat: removed dev docs

* feat: clean up error handling and UI elements for improved user experience

* feat: adjust button sizes in HeaderBar for better UI consistency

* feat: enhance taint analysis with additional context for sanitizer and taint findings

* cargo fmt

* prettier

* refactor: simplify conditional checks and improve code readability in AST and screenshot capture scripts

* feat: add script to frame PNG screenshots with brand gradient

* feat: add fuzzing support with new targets and CI workflows

* refactor: streamline match expressions and improve formatting in CLI and output handling

* feat: enhance configuration display with detailed output options

* feat: stage demo configuration for improved CLI screenshot output

* feat: expose merge_configs function for user-configurable settings

* refactor: simplify code structure and improve readability in config handling

* refactor: improve descriptions for vulnerability patterns in various languages

* feat: update MIT License section with additional usage details and copyright information

* feat: update screenshots

* refactor: update build process and paths for frontend assets

* feat: add cross-file taint fuzzing target and supporting dictionary

* refactor: clean up formatting and comments in fuzz configuration and example files

* refactor: remove outdated comments and clean up CI configuration files

* chore: update changelog dates and improve formatting in documentation

* refactor: update Cargo.toml and CI configuration for improved packaging and build process

* refactor: enhance quote-stripping logic to prevent panics and add regression tests

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Eli Peter 2026-04-29 00:58:38 -04:00 committed by GitHub
parent 79c29b394d
commit 82f18184b1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
348 changed files with 48731 additions and 2925 deletions

View file

@ -160,6 +160,24 @@ impl Lattice for AuthDomainState {
// ── ProductState ─────────────────────────────────────────────────────────
/// Per-chain-receiver proxy tracking entry.
///
/// The state machine carries this for every chained-receiver resource
/// proxy call (`c.mu.Lock()`, `c.writer.header.set(...)`). Stored in
/// [`ProductState::chain_proxies`] keyed by the joined chain text
/// (e.g. `"c.mu"`, `"c.writer.header"`) so distinct field projections
/// of the same chain root are tracked independently.
///
/// Chain-keyed proxy state is the Phase 3 replacement for the single-dot
/// band-aid that conservatively dropped chain receivers entirely — chain
/// receivers are now first-class, semantically distinct from their root.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ChainProxyState {
pub lifecycle: ResourceLifecycle,
pub class_group: crate::cfg::BodyId,
pub acquire_span: (usize, usize),
}
/// Composable product of resource and auth domains.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ProductState {
@ -173,6 +191,20 @@ pub struct ProductState {
/// Used by `extract_findings` to attribute leaks to the original resource
/// operation (e.g., fs.openSync at line 7) rather than the proxy call.
pub proxy_acquire_spans: HashMap<SymbolId, (usize, usize)>,
/// Per-chain-receiver proxy tracking, keyed by joined chain text
/// (`"c.mu"`, `"c.writer.header"`). Each chain receiver has its own
/// lifecycle, class group, and acquire span — independent of both the
/// chain root and any other chain. Phase 3 of the field-projections
/// rollout introduces this map; consumers that previously used
/// [`receiver_class_group`] for chain receivers (via the deleted
/// single-dot band-aid) now route through here for 2+ dot callees.
///
/// Phase 3 ships chain_proxies in tracking-only mode: chain receivers
/// that remain OPEN at exit are NOT promoted to leak findings (so the
/// addition is strictly behaviour-preserving against the existing
/// benchmark). Phase 4 / a follow-up adds chain-rooted leak findings
/// once the receiver-class detection is broad enough to avoid new FPs.
pub chain_proxies: HashMap<String, ChainProxyState>,
}
impl ProductState {
@ -182,6 +214,7 @@ impl ProductState {
auth: AuthDomainState::new(),
receiver_class_group: HashMap::new(),
proxy_acquire_spans: HashMap::new(),
chain_proxies: HashMap::new(),
}
}
}
@ -193,6 +226,7 @@ impl Lattice for ProductState {
auth: AuthDomainState::bot(),
receiver_class_group: HashMap::new(),
proxy_acquire_spans: HashMap::new(),
chain_proxies: HashMap::new(),
}
}
@ -202,11 +236,27 @@ impl Lattice for ProductState {
class_group.extend(other.receiver_class_group.iter());
let mut proxy_spans = self.proxy_acquire_spans.clone();
proxy_spans.extend(other.proxy_acquire_spans.iter());
// Chain proxies: union, with lifecycle joined per-key so an OPEN
// entry on one path remains OPEN if joined with a missing entry
// on another path (matches the existing receiver_class_group
// semantics). Last-writer-wins for class_group / acquire_span:
// both are stable per chain receiver in practice (a chain root +
// field path is monomorphic), so the conflict cases collapse.
let mut chain = self.chain_proxies.clone();
for (key, other_state) in &other.chain_proxies {
chain
.entry(key.clone())
.and_modify(|e| {
e.lifecycle = e.lifecycle.join(&other_state.lifecycle);
})
.or_insert_with(|| other_state.clone());
}
Self {
resource: self.resource.join(&other.resource),
auth: self.auth.join(&other.auth),
receiver_class_group: class_group,
proxy_acquire_spans: proxy_spans,
chain_proxies: chain,
}
}
@ -329,4 +379,185 @@ mod tests {
let s2 = s;
assert_eq!(s, s2);
}
// ── Lattice law checks on the real domains ─────────────────────
//
// The trait-level `lattice.rs` tests use a synthetic `Three` lattice;
// the laws also need to hold on the *actual* impls used by the
// engine. A change to ResourceLifecycle's bitset semantics or to
// AuthLevel's ordering could quietly break commutativity /
// associativity / idempotence — these tests pin those properties.
#[test]
fn resource_lifecycle_join_laws() {
let vals = [
ResourceLifecycle::empty(),
ResourceLifecycle::UNINIT,
ResourceLifecycle::OPEN,
ResourceLifecycle::CLOSED,
ResourceLifecycle::MOVED,
ResourceLifecycle::OPEN | ResourceLifecycle::CLOSED,
ResourceLifecycle::all(),
];
for a in &vals {
// Idempotence: a ⊔ a = a
assert_eq!(a.join(a), *a, "idempotence broken on {a:?}");
// Bot identity: a ⊔ ⊥ = a
assert_eq!(a.join(&ResourceLifecycle::bot()), *a);
for b in &vals {
// Commutativity: a ⊔ b = b ⊔ a
assert_eq!(a.join(b), b.join(a), "commutativity broken ({a:?},{b:?})");
// leq consistent with join: a ⊑ b iff a ⊔ b = b
let consistent = a.leq(b) == (a.join(b) == *b);
assert!(consistent, "leq/join consistency broken ({a:?} ⊑ {b:?})");
for c in &vals {
// Associativity
assert_eq!(
a.join(b).join(c),
a.join(&b.join(c)),
"associativity broken ({a:?},{b:?},{c:?})"
);
}
}
}
}
/// `AuthLevel` satisfies idempotence, commutativity, and associativity
/// of `join` (which is `min` of the privilege ordering). It does NOT
/// satisfy the `Lattice` trait's bot-identity law — see the explicit
/// `auth_level_bot_is_absorbing_not_identity` test below for a
/// rationale and a regression guard.
#[test]
fn auth_level_join_associative_commutative_idempotent() {
let vals = [AuthLevel::Unauthed, AuthLevel::Authed, AuthLevel::Admin];
for a in &vals {
assert_eq!(a.join(a), *a, "AuthLevel idempotence broken on {a:?}");
for b in &vals {
assert_eq!(
a.join(b),
b.join(a),
"AuthLevel commutativity ({a:?},{b:?})"
);
for c in &vals {
assert_eq!(
a.join(b).join(c),
a.join(&b.join(c)),
"AuthLevel associativity ({a:?},{b:?},{c:?})"
);
}
}
}
}
/// **Audit finding pinned as a regression guard.**
///
/// `AuthLevel` deliberately violates the `Lattice` trait's bot-identity
/// law (`a ⊔ ⊥ = a`). The trait says `bot()` is the join identity, but:
///
/// * `bot()` returns `Unauthed`
/// * `join` is `min` over the ordering `Unauthed < Authed < Admin`
/// * therefore `Admin.join(Unauthed) == Unauthed`, not `Admin`
///
/// In other words, `Unauthed` is the *absorbing* element of the join,
/// not the identity — the algebraic dual of what the trait expects.
///
/// This is intentional for security: if any incoming path is unauthed,
/// the merged state must be unauthed (the conservative baseline). The
/// trait contract violation matters only if the dataflow engine ever
/// joins `bot()` with a non-bot reachable state from a different path
/// (e.g. for an unreachable predecessor); in the current engine such
/// nodes are skipped, so the violation is observably benign — but
/// documenting it here prevents an accidental "fix" that flips
/// `bot()` to `Admin` and silently elevates auth across all merges.
#[test]
fn auth_level_bot_is_absorbing_not_identity() {
assert_eq!(AuthLevel::bot(), AuthLevel::Unauthed);
// Absorbing: Admin ⊔ Unauthed = Unauthed (conservative).
assert_eq!(
AuthLevel::Admin.join(&AuthLevel::Unauthed),
AuthLevel::Unauthed,
"Unauthed must absorb Admin under min-join (conservative security)"
);
// NOT identity: Admin ⊔ bot ≠ Admin (would be the trait law).
assert_ne!(
AuthLevel::Admin.join(&AuthLevel::bot()),
AuthLevel::Admin,
"if this passes, AuthLevel::bot() was changed — re-audit security implications"
);
}
/// `leq` for AuthLevel is "at least as privileged": Admin ⊑ Authed ⊑
/// Unauthed in the privilege ordering. The trait law `a.leq(b) iff
/// a.join(b) == b` therefore must read `b absorbs a`, since join is
/// min. Verify the consistency on every pair.
#[test]
fn auth_level_leq_consistent_with_join() {
let vals = [AuthLevel::Unauthed, AuthLevel::Authed, AuthLevel::Admin];
for a in &vals {
for b in &vals {
assert_eq!(
a.leq(b),
a.join(b) == *b,
"leq/join inconsistent on ({a:?}, {b:?})"
);
}
}
}
/// `AuthDomainState::join` keeps a variable as `validated` only if
/// it was validated on *every* incoming path. A variable validated
/// on one branch but not the other must be dropped — otherwise an
/// auth bypass on one path silently authorises sinks on the merge
/// path.
#[test]
fn auth_domain_join_drops_partially_validated() {
let sym_only_a = SymbolId(10);
let sym_only_b = SymbolId(11);
let a = AuthDomainState {
auth_level: AuthLevel::Authed,
validated: [sym_only_a].into_iter().collect(),
};
let b = AuthDomainState {
auth_level: AuthLevel::Authed,
validated: [sym_only_b].into_iter().collect(),
};
let j = a.join(&b);
assert!(
j.validated.is_empty(),
"validated set must drop vars not validated on all paths"
);
}
/// ProductState join must combine resource OPEN | CLOSED across
/// branches (may-leak), keep min-auth, and union the proxy maps.
/// This exercises the non-trivial join (the existing test only
/// joins two identical initial states).
#[test]
fn product_state_join_non_trivial() {
let sym_x = SymbolId(20);
let sym_y = SymbolId(21);
let mut a = ProductState::initial();
a.resource.set(sym_x, ResourceLifecycle::OPEN);
a.auth.auth_level = AuthLevel::Admin;
a.auth.validated.insert(sym_y);
let mut b = ProductState::initial();
b.resource.set(sym_x, ResourceLifecycle::CLOSED);
b.auth.auth_level = AuthLevel::Authed;
b.auth.validated.insert(sym_y);
let j = a.join(&b);
assert_eq!(
j.resource.get(sym_x),
ResourceLifecycle::OPEN | ResourceLifecycle::CLOSED,
"may-leak: OPEN on one path, CLOSED on the other"
);
assert_eq!(j.auth.auth_level, AuthLevel::Authed, "join takes min auth");
assert!(
j.auth.validated.contains(&sym_y),
"var validated on both paths must survive"
);
}
}

View file

@ -253,6 +253,7 @@ mod tests {
resource_pairs: rules::resource_pairs(Lang::C),
interner: &interner,
resource_method_summaries: &[],
ptr_proxy_hints: None,
};
let result = run_forward(&cfg, entry, &transfer, ProductState::initial());
@ -323,6 +324,7 @@ mod tests {
resource_pairs: rules::resource_pairs(Lang::C),
interner: &interner,
resource_method_summaries: &[],
ptr_proxy_hints: None,
};
let result = run_forward(&cfg, entry, &transfer, ProductState::initial());
@ -508,4 +510,168 @@ mod tests {
assert_eq!(wl.len(), 3);
assert_eq!(in_wl.len(), 3);
}
// ── CFG-shape robustness ─────────────────────────────────────────────
//
// The audit flagged that `run_forward` had only linear/diamond test
// shapes. These tests exercise edge cases that can trip up the
// worklist algorithm: nodes the entry can't reach, a CFG with only
// an entry node, irreducible flow with multiple paths into the
// same loop body, and a self-loop. Each must terminate without
// panicking and produce a sensible converged state.
/// A node disconnected from the entry must NOT receive any state
/// (it's unreachable). The engine processes only nodes reachable
/// from the worklist seed; a quiescent unreachable node should
/// stay absent from the result map.
#[test]
fn unreachable_nodes_get_no_state() {
use crate::state::domain::ProductState;
let mut cfg: Cfg = Graph::new();
let entry = cfg.add_node(make_node(StmtKind::Entry));
let reachable = cfg.add_node(make_node(StmtKind::Seq));
let exit = cfg.add_node(make_node(StmtKind::Exit));
// Unreachable island: no edge from entry leads here.
let orphan = cfg.add_node(make_node(StmtKind::Seq));
let orphan_exit = cfg.add_node(make_node(StmtKind::Exit));
cfg.add_edge(entry, reachable, EdgeKind::Seq);
cfg.add_edge(reachable, exit, EdgeKind::Seq);
cfg.add_edge(orphan, orphan_exit, EdgeKind::Seq);
let interner = SymbolInterner::from_cfg(&cfg);
let transfer = DefaultTransfer {
lang: Lang::C,
resource_pairs: rules::resource_pairs(Lang::C),
interner: &interner,
resource_method_summaries: &[],
ptr_proxy_hints: None,
};
let result = run_forward(&cfg, entry, &transfer, ProductState::initial());
assert!(result.converged);
assert!(
result.states.contains_key(&entry),
"entry must have a state"
);
assert!(
result.states.contains_key(&reachable),
"reachable node must have a state"
);
assert!(
!result.states.contains_key(&orphan),
"orphan island must NOT receive any state"
);
assert!(
!result.states.contains_key(&orphan_exit),
"orphan exit must NOT receive any state"
);
}
/// A single-node graph (entry only, no edges) is the minimal case.
/// The engine must terminate immediately, mark converged, and leave
/// the entry's initial state untouched.
#[test]
fn single_node_graph_terminates_immediately() {
use crate::state::domain::ProductState;
let mut cfg: Cfg = Graph::new();
let entry = cfg.add_node(make_node(StmtKind::Entry));
let interner = SymbolInterner::from_cfg(&cfg);
let transfer = DefaultTransfer {
lang: Lang::C,
resource_pairs: rules::resource_pairs(Lang::C),
interner: &interner,
resource_method_summaries: &[],
ptr_proxy_hints: None,
};
let result = run_forward(&cfg, entry, &transfer, ProductState::initial());
assert!(result.converged);
assert!(
result.states.contains_key(&entry),
"single-node graph still seeds the entry state"
);
}
/// Self-loop on a single node: `entry → A → A → … → exit`. The
/// worklist must not livelock — once A's state is stable, the
/// back-edge stops re-enqueueing it.
#[test]
fn self_loop_terminates() {
use crate::state::domain::ProductState;
let mut cfg: Cfg = Graph::new();
let entry = cfg.add_node(make_node(StmtKind::Entry));
let a = cfg.add_node(make_node(StmtKind::Seq));
let exit = cfg.add_node(make_node(StmtKind::Exit));
cfg.add_edge(entry, a, EdgeKind::Seq);
cfg.add_edge(a, a, EdgeKind::Back); // self-loop
cfg.add_edge(a, exit, EdgeKind::Seq);
let interner = SymbolInterner::from_cfg(&cfg);
let transfer = DefaultTransfer {
lang: Lang::C,
resource_pairs: rules::resource_pairs(Lang::C),
interner: &interner,
resource_method_summaries: &[],
ptr_proxy_hints: None,
};
let result = run_forward(&cfg, entry, &transfer, ProductState::initial());
assert!(result.converged, "self-loop must converge");
assert!(result.states.contains_key(&exit));
}
/// Irreducible CFG: two distinct paths from entry both enter the
/// same loop body, so the loop has multiple "entry points". This
/// is the classic shape that breaks structured-loop assumptions
/// (e.g., "every loop has a unique header"). The forward worklist
/// must still terminate.
///
/// Shape:
/// entry → a ─┐
/// ├→ loop_body ─→ exit
/// entry → b ─┘ ↑
/// └─ back-edge from loop_body to itself
#[test]
fn irreducible_cfg_terminates() {
use crate::state::domain::ProductState;
let mut cfg: Cfg = Graph::new();
let entry = cfg.add_node(make_node(StmtKind::Entry));
let a = cfg.add_node(make_node(StmtKind::Seq));
let b = cfg.add_node(make_node(StmtKind::Seq));
let loop_body = cfg.add_node(make_node(StmtKind::Loop));
let exit = cfg.add_node(make_node(StmtKind::Exit));
cfg.add_edge(entry, a, EdgeKind::Seq);
cfg.add_edge(entry, b, EdgeKind::Seq);
cfg.add_edge(a, loop_body, EdgeKind::Seq);
cfg.add_edge(b, loop_body, EdgeKind::Seq);
cfg.add_edge(loop_body, loop_body, EdgeKind::Back);
cfg.add_edge(loop_body, exit, EdgeKind::Seq);
let interner = SymbolInterner::from_cfg(&cfg);
let transfer = DefaultTransfer {
lang: Lang::C,
resource_pairs: rules::resource_pairs(Lang::C),
interner: &interner,
resource_method_summaries: &[],
ptr_proxy_hints: None,
};
let result = run_forward(&cfg, entry, &transfer, ProductState::initial());
assert!(
result.converged,
"irreducible CFG must still converge under run_forward"
);
// Every reachable node must have a state.
for n in [entry, a, b, loop_body, exit] {
assert!(result.states.contains_key(&n), "node {n:?} must be visited");
}
}
}

View file

@ -292,46 +292,58 @@ pub fn extract_findings(
// CLOSED at function exit (no OPEN paths), check whether there are
// intervening calls between the proxy acquire and release nodes that
// could throw and bypass the release. If so, emit a possible leak.
for (idx, info) in cfg.node_references() {
if !is_terminal_function_exit(idx, info, cfg) {
continue;
}
let Some(state) = result.states.get(&idx) else {
continue;
};
for (&sym, &lifecycle) in &state.resource.vars {
// Only for proxy-acquired resources that are fully CLOSED at exit
if !state.proxy_acquire_spans.contains_key(&sym) {
//
// **Language gate**: this heuristic is JS/TS-specific. Other
// languages (Go, Java, C, C++, Python, Rust, Ruby, PHP) use
// explicit error returns / try-catch with deterministic control
// flow — an intervening call does NOT silently bypass a release.
// Firing this on Go gave the gin/context.go FP where any method
// calling another method (`c.Set`, `c.Get`) was flagged as a
// possible leak on the receiver. Skip the section but continue
// to section 3 (auth-required sinks) which is independent of the
// resource state machine.
if matches!(lang, Lang::JavaScript | Lang::TypeScript) {
for (idx, info) in cfg.node_references() {
if !is_terminal_function_exit(idx, info, cfg) {
continue;
}
if lifecycle.contains(ResourceLifecycle::OPEN) {
continue; // Already handled by the normal leak detection above
}
if !lifecycle.contains(ResourceLifecycle::CLOSED) {
let Some(state) = result.states.get(&idx) else {
continue;
}
// Check if there are intervening Call nodes between acquire and release
// in the CFG (these could throw and bypass the release)
let has_intervening_calls = cfg.node_references().any(|(_, ni)| {
ni.kind == StmtKind::Call
&& ni.ast.enclosing_func == info.ast.enclosing_func
&& ni.call.callee.is_some()
// Not the acquire or release proxy itself
&& !state.proxy_acquire_spans.values().any(|s| *s == ni.ast.span)
});
if has_intervening_calls {
let var_name = interner.resolve(sym);
let acquire_span = state.proxy_acquire_spans.get(&sym).copied();
findings.push(StateFinding {
rule_id: "state-resource-leak-possible".into(),
severity: Severity::Low,
span: acquire_span.unwrap_or(info.ast.span),
message: format!("resource `{var_name}` may not be closed on all paths"),
machine: "resource",
subject: Some(var_name.to_string()),
from_state: "open",
to_state: "possibly_leaked",
};
for (&sym, &lifecycle) in &state.resource.vars {
// Only for proxy-acquired resources that are fully CLOSED at exit
if !state.proxy_acquire_spans.contains_key(&sym) {
continue;
}
if lifecycle.contains(ResourceLifecycle::OPEN) {
continue; // Already handled by the normal leak detection above
}
if !lifecycle.contains(ResourceLifecycle::CLOSED) {
continue;
}
// Check if there are intervening Call nodes between acquire and release
// in the CFG (these could throw and bypass the release)
let has_intervening_calls = cfg.node_references().any(|(_, ni)| {
ni.kind == StmtKind::Call
&& ni.ast.enclosing_func == info.ast.enclosing_func
&& ni.call.callee.is_some()
// Not the acquire or release proxy itself
&& !state.proxy_acquire_spans.values().any(|s| *s == ni.ast.span)
});
if has_intervening_calls {
let var_name = interner.resolve(sym);
let acquire_span = state.proxy_acquire_spans.get(&sym).copied();
findings.push(StateFinding {
rule_id: "state-resource-leak-possible".into(),
severity: Severity::Low,
span: acquire_span.unwrap_or(info.ast.span),
message: format!("resource `{var_name}` may not be closed on all paths"),
machine: "resource",
subject: Some(var_name.to_string()),
from_state: "open",
to_state: "possibly_leaked",
});
}
}
}
}
@ -533,6 +545,7 @@ mod tests {
resource_pairs: rules::resource_pairs(Lang::C),
interner: &interner,
resource_method_summaries: &[],
ptr_proxy_hints: None,
};
let result = engine::run_forward(&cfg, entry, &transfer, ProductState::initial());
@ -592,6 +605,7 @@ mod tests {
resource_pairs: rules::resource_pairs(Lang::C),
interner: &interner,
resource_method_summaries: &[],
ptr_proxy_hints: None,
};
let result = engine::run_forward(&cfg, entry, &transfer, ProductState::initial());
@ -725,6 +739,7 @@ mod tests {
resource_pairs: rules::resource_pairs(Lang::C),
interner: &interner,
resource_method_summaries: &[],
ptr_proxy_hints: None,
};
let result = engine::run_forward(&cfg, entry, &transfer, ProductState::initial());
@ -789,6 +804,7 @@ mod tests {
resource_pairs: rules::resource_pairs(Lang::C),
interner: &interner,
resource_method_summaries: &[],
ptr_proxy_hints: None,
};
let result = engine::run_forward(&cfg, entry, &transfer, ProductState::initial());

View file

@ -69,6 +69,12 @@ pub fn run_state_analysis(
resource_method_summaries: &[transfer::ResourceMethodSummary],
auth_decorators: &[String],
path_safe_suppressed_sink_spans: &std::collections::HashSet<(usize, usize)>,
// Optional `var_name → PtrProxyHint` map derived from the body's
// PointsToFacts. When present, the proxy-acquire transfer suppresses
// SymbolId attribution on field-aliased receivers (`m := c.mu;
// m.Lock()`) and routes them through `chain_proxies` instead. Pass
// `None` to disable — strict-additive.
ptr_proxy_hints: Option<&std::collections::HashMap<String, crate::pointer::PtrProxyHint>>,
) -> Vec<StateFinding> {
let _span = tracing::debug_span!("run_state_analysis").entered();
@ -88,6 +94,7 @@ pub fn run_state_analysis(
resource_pairs,
interner: &interner,
resource_method_summaries,
ptr_proxy_hints,
};
// Seed initial auth level from decorator-based authorization markers.

View file

@ -1,6 +1,6 @@
#![allow(clippy::collapsible_if)]
use super::domain::{AuthLevel, ProductState, ResourceLifecycle};
use super::domain::{AuthLevel, ChainProxyState, ProductState, ResourceLifecycle};
use super::engine::Transfer;
use super::symbol::{SymbolId, SymbolInterner};
use crate::cfg::{EdgeKind, NodeInfo, StmtKind};
@ -8,6 +8,47 @@ use crate::cfg_analysis::rules::{self, ResourcePair};
use crate::symbol::Lang;
use petgraph::graph::NodeIndex;
/// Decompose a textual callee like `"c.mu.Lock"` into
/// `(chain_receiver_text, method_suffix)`. Returns `None` when the
/// callee isn't a clean dotted member chain (parens, brackets, `::`,
/// arrow operators, whitespace, or other complex tokens disqualify it).
///
/// Phase 3 of the field-projections rollout: this is the textual mirror
/// of `try_lower_field_proj_chain` in `src/ssa/lower.rs`. The state
/// engine doesn't yet read SSA bodies (would require threading SSA
/// through the lattice run), so the same parse rules are duplicated
/// here. Both helpers share the contract: a success here implies a
/// FieldProj chain at SSA level (or a direct receiver for the 1-dot
/// case).
///
/// **Returns** `Some(("c", "Close"))` for `"c.Close"` (1 dot — the
/// receiver is a bare ident); `Some(("c.mu", "Lock"))` for
/// `"c.mu.Lock"` (2 dots — receiver is a 1-element chain);
/// `Some(("c.writer.header", "set"))` for `"c.writer.header.set"`
/// (3 dots — receiver is a 2-element chain). Returns `None` for any
/// callee shape we can't safely decompose textually.
fn try_chain_decompose(callee: &str) -> Option<(&str, &str)> {
for ch in callee.chars() {
match ch {
'(' | ')' | '[' | ']' | '<' | '>' | '?' | '*' | '&' | ':' | ' ' | '\t' | '\n' | '-'
| '!' | ',' | ';' | '"' | '\'' | '\\' => return None,
_ => {}
}
}
let last_dot = callee.rfind('.')?;
let receiver_text = &callee[..last_dot];
let method_suffix = &callee[last_dot + 1..];
if receiver_text.is_empty() || method_suffix.is_empty() {
return None;
}
// Reject if any segment in the receiver is empty (leading dot,
// double dots) — same discipline as the SSA-side helper.
if receiver_text.split('.').any(str::is_empty) {
return None;
}
Some((receiver_text, method_suffix))
}
/// Events emitted during transfer for illegal state transitions.
/// These are NOT lattice values — they become findings in `facts.rs`.
#[derive(Debug, Clone)]
@ -130,6 +171,20 @@ pub struct DefaultTransfer<'a> {
pub interner: &'a SymbolInterner,
/// Resource method summaries for cross-body proxy resolution.
pub resource_method_summaries: &'a [ResourceMethodSummary],
/// Optional per-body field-only points-to hints — names that resolve
/// to a value whose entire abstract heap identity is one or more
/// [`crate::pointer::AbsLoc::Field`] locations (e.g. `m := c.mu`).
///
/// Populated only when `NYX_POINTER_ANALYSIS=1` is set and the
/// state-analysis caller built the body's
/// [`crate::pointer::PointsToFacts`]. When present, the proxy-acquire
/// logic routes single-dot calls on field-aliased receivers
/// (e.g. `m.Lock()` after `m := c.mu`) into `chain_proxies` instead
/// of marking the local with a `SymbolId` that would later be flagged
/// as a leak. Strict-additive: when `None`, behaviour matches the
/// pointer-unaware fallback exactly.
pub ptr_proxy_hints:
Option<&'a std::collections::HashMap<String, crate::pointer::PtrProxyHint>>,
}
impl Transfer<ProductState> for DefaultTransfer<'_> {
@ -170,6 +225,77 @@ impl DefaultTransfer<'_> {
.get_scoped(info.ast.enclosing_func.as_deref(), name)
}
/// Pointer-Phase 2 hook. Returns `true` when the call has been
/// fully handled as a field-aliased receiver proxy and the rest of
/// `apply_call` should bail.
///
/// Activates only on single-dot calls (`<recv>.<method>`) whose
/// receiver name is recorded with [`crate::pointer::PtrProxyHint::FieldOnly`]
/// in the per-body hint map AND for which a matching
/// [`ResourceMethodSummary`] exists. The acquire/release effect
/// is recorded against `state.chain_proxies` keyed by the receiver
/// name — chain_proxies is a tracking-only lattice today, so leak
/// detection (which only inspects `state.resource`) is suppressed
/// for the alias. Strict-additive: when no hint map is supplied,
/// when the receiver isn't `FieldOnly`, or when no method summary
/// matches, the function returns `false` and the legacy branches
/// run unchanged.
fn try_apply_field_alias_proxy(
&self,
info: &NodeInfo,
callee: &str,
state: &mut ProductState,
) -> bool {
let Some(hints) = self.ptr_proxy_hints else {
return false;
};
// Only single-dot callees: `m.Lock`, not `c.mu.Lock` (which the
// chain-receiver block already handles textually) and not zero-
// dot (no receiver to alias).
let Some((receiver_text, method_suffix)) = try_chain_decompose(callee) else {
return false;
};
if receiver_text.contains('.') {
return false;
}
let recv_name: &str = match info.call.receiver.as_deref() {
Some(r) if !r.contains('.') && !r.contains('(') => r,
_ => receiver_text,
};
if hints.get(recv_name).copied() != Some(crate::pointer::PtrProxyHint::FieldOnly) {
return false;
}
let mut handled = false;
for summary in self.resource_method_summaries {
if !summary.method_name.eq_ignore_ascii_case(method_suffix) {
continue;
}
handled = true;
match summary.effect {
ResourceEffect::Acquire => {
state.chain_proxies.insert(
recv_name.to_string(),
ChainProxyState {
lifecycle: ResourceLifecycle::OPEN,
class_group: summary.class_group,
acquire_span: summary.original_span,
},
);
}
ResourceEffect::Release => {
if let Some(entry) = state.chain_proxies.get_mut(recv_name) {
if entry.class_group == summary.class_group
&& entry.lifecycle.contains(ResourceLifecycle::OPEN)
{
entry.lifecycle = ResourceLifecycle::CLOSED;
}
}
}
}
}
handled
}
fn apply_call(
&self,
node_idx: NodeIndex,
@ -182,6 +308,23 @@ impl DefaultTransfer<'_> {
None => return,
};
// ── Pointer-Phase 2: field-aliased receiver fast-path ───────────
// When the receiver name resolves through points-to to a value
// whose abstract heap identity is purely `Field(_, _)` (e.g.
// `m := c.mu` followed by `m.Lock()`), the receiver is a
// sub-object alias rather than a standalone resource handle.
// Routing the entire call into `chain_proxies` here — *before*
// the SymbolId-based direct-acquire/release/proxy branches —
// suppresses the FP class where the local `m` would otherwise
// be flagged as a leakable resource at function exit.
//
// Strict-additive: when `ptr_proxy_hints` is `None` or the
// receiver name is absent from the map, this returns early and
// the legacy branches run unchanged.
if self.try_apply_field_alias_proxy(info, &callee, state) {
return;
}
// ── Resource acquire ─────────────────────────────────────────────
let mut direct_acquire = false;
for pair in self.resource_pairs {
@ -240,47 +383,92 @@ impl DefaultTransfer<'_> {
// ── Resource method proxy ────────────────────────────────────────
// When no direct resource pair matched, check if the callee is a
// method wrapper for a known resource operation. Only fires when:
// 1. The callee is a method call (contains `.`)
// 2. An explicit receiver is identified
// 3. The method suffix matches a ResourceMethodSummary
// 4. For Release: the receiver was previously acquired by the same class group
if !direct_acquire && !direct_release && callee.contains('.') {
// Extract receiver: prefer explicit NodeInfo.call.receiver, fall back
// to everything before the last `.` in the callee string.
let recv_from_callee: Option<String>;
let recv_name: Option<&str> = if let Some(ref r) = info.call.receiver {
Some(r.as_str())
} else {
recv_from_callee = callee.rsplit_once('.').map(|(prefix, _)| {
// For multi-segment paths like "a.b.c", use the root receiver
prefix.split('.').next().unwrap_or(prefix).to_string()
});
recv_from_callee.as_deref()
};
if let Some(recv) = recv_name {
let method_suffix = callee.rsplit('.').next().unwrap_or("");
// method wrapper for a known resource operation.
//
// Phase 3 (field-projections rollout, 2026-04-25): the previous
// single-dot band-aid (`callee.matches('.').count() == 1 &&
// !callee.contains('(')`) silently dropped chained receivers
// because the original textual extractor took the chain root as
// receiver — collapsing `c.writer.header().set` to `c` and
// marking `c` as proxy-acquired (the gin/context.go FP class).
//
// The band-aid is now deleted. Chained-receiver method calls
// are routed to a *separate* state map (`chain_proxies`) keyed by
// the joined receiver chain text — so `c.mu.Lock()` acquires
// `c.mu` (a chain-receiver entity), not `c`. The chain receiver
// is independent of the chain root: leaks/double-closes are
// tracked per chain, never propagated up to the root.
//
// The single-dot case (`<recv>.<method>`) keeps the original
// SymbolId-based path so existing fixtures' lifecycle tracking,
// leak detection, and finding attribution stay bit-for-bit
// identical.
// Chain-receiver proxy path runs independently of the direct
// acquire/release flags: it touches a *separate* state map
// (`chain_proxies`) that doesn't overlap with the SymbolId-based
// `state.resource` / `receiver_class_group` lattice. This is
// important for callees like `c.mu.Unlock()` where the textual
// direct-release matcher (`.Unlock`) fires (sets `direct_release`
// even without a SymbolId state change), but the chain receiver
// (`c.mu`) is still the semantically meaningful target.
if let Some((receiver_text, method_suffix)) = try_chain_decompose(&callee) {
let receiver_is_chain = receiver_text.contains('.');
if receiver_is_chain {
for summary in self.resource_method_summaries {
if summary.method_name.eq_ignore_ascii_case(method_suffix) {
if let Some(sym) = self.get_sym(info, recv) {
match summary.effect {
ResourceEffect::Acquire => {
state.resource.set(sym, ResourceLifecycle::OPEN);
// Track class group for release matching
state.receiver_class_group.insert(sym, summary.class_group);
// Store original acquire span for finding attribution
state.proxy_acquire_spans.insert(sym, summary.original_span);
if !summary.method_name.eq_ignore_ascii_case(method_suffix) {
continue;
}
match summary.effect {
ResourceEffect::Acquire => {
state.chain_proxies.insert(
receiver_text.to_string(),
ChainProxyState {
lifecycle: ResourceLifecycle::OPEN,
class_group: summary.class_group,
acquire_span: summary.original_span,
},
);
}
ResourceEffect::Release => {
if let Some(entry) = state.chain_proxies.get_mut(receiver_text) {
if entry.class_group == summary.class_group
&& entry.lifecycle.contains(ResourceLifecycle::OPEN)
{
entry.lifecycle = ResourceLifecycle::CLOSED;
}
ResourceEffect::Release => {
// Only release if receiver was acquired by same class group
if state.receiver_class_group.get(&sym)
== Some(&summary.class_group)
{
let current = state.resource.get(sym);
if current.contains(ResourceLifecycle::OPEN) {
state.resource.set(sym, ResourceLifecycle::CLOSED);
}
}
}
}
}
}
} else if !direct_acquire && !direct_release {
// Single-dot receiver (`<recv>.<method>`): existing
// SymbolId-based path. Gated on direct_acquire/release
// because it shares state with the direct paths above —
// running both would double-transition. Honour the
// explicit `info.call.receiver` when it's the same bare
// ident, otherwise fall back to the parsed receiver text.
let recv_name: &str = match info.call.receiver.as_deref() {
Some(r) if !r.contains('.') && !r.contains('(') => r,
_ => receiver_text,
};
for summary in self.resource_method_summaries {
if !summary.method_name.eq_ignore_ascii_case(method_suffix) {
continue;
}
let Some(sym) = self.get_sym(info, recv_name) else {
continue;
};
match summary.effect {
ResourceEffect::Acquire => {
state.resource.set(sym, ResourceLifecycle::OPEN);
state.receiver_class_group.insert(sym, summary.class_group);
state.proxy_acquire_spans.insert(sym, summary.original_span);
}
ResourceEffect::Release => {
if state.receiver_class_group.get(&sym) == Some(&summary.class_group) {
let current = state.resource.get(sym);
if current.contains(ResourceLifecycle::OPEN) {
state.resource.set(sym, ResourceLifecycle::CLOSED);
}
}
}
@ -658,6 +846,7 @@ mod tests {
resource_pairs: rules::resource_pairs(Lang::C),
interner: &interner,
resource_method_summaries: &[],
ptr_proxy_hints: None,
};
let info = NodeInfo {
@ -693,6 +882,7 @@ mod tests {
resource_pairs: rules::resource_pairs(Lang::C),
interner: &interner,
resource_method_summaries: &[],
ptr_proxy_hints: None,
};
let mut state = ProductState::initial();
@ -730,6 +920,7 @@ mod tests {
resource_pairs: rules::resource_pairs(Lang::C),
interner: &interner,
resource_method_summaries: &[],
ptr_proxy_hints: None,
};
let mut state = ProductState::initial();
@ -768,6 +959,7 @@ mod tests {
resource_pairs: rules::resource_pairs(Lang::C),
interner: &interner,
resource_method_summaries: &[],
ptr_proxy_hints: None,
};
let mut state = ProductState::initial();
@ -840,6 +1032,7 @@ mod tests {
resource_pairs: rules::resource_pairs(Lang::C),
interner: &interner,
resource_method_summaries: &[],
ptr_proxy_hints: None,
};
let mut state = ProductState::initial();
@ -894,6 +1087,7 @@ mod tests {
resource_pairs: rules::resource_pairs(Lang::C),
interner: &interner,
resource_method_summaries: &[],
ptr_proxy_hints: None,
};
let mut state = ProductState::initial();
@ -1156,4 +1350,638 @@ mod tests {
"is_authenticated"
));
}
// ─────────────────────────────────────────────────────────────────
// Phase 3: chain-receiver decomposition + chain_proxies tracking
// ─────────────────────────────────────────────────────────────────
//
// These tests pin the contract that:
// 1. `try_chain_decompose` parses dotted callees into receiver +
// method, bailing on complex tokens.
// 2. The proxy-method routing in `apply_call` records chained
// receivers in `state.chain_proxies` (keyed by joined chain
// text) — independent from the chain root's `SymbolId`-based
// `state.receiver_class_group` entries.
// 3. Single-dot callees still flow through the existing SymbolId
// path (regression guard).
// 4. The deleted single-dot band-aid no longer suppresses chain
// cases — `c.mu.Lock()` now fires the chain-proxies path
// instead of being silently dropped.
#[test]
fn try_chain_decompose_basic_two_dots() {
// `c.mu.Lock` → receiver "c.mu", method "Lock". The receiver
// is a 1-element chain (one FieldProj at the SSA level).
let (recv, method) = try_chain_decompose("c.mu.Lock").unwrap();
assert_eq!(recv, "c.mu");
assert_eq!(method, "Lock");
}
#[test]
fn try_chain_decompose_three_dots() {
// `c.writer.header.set` → receiver "c.writer.header", method "set".
let (recv, method) = try_chain_decompose("c.writer.header.set").unwrap();
assert_eq!(recv, "c.writer.header");
assert_eq!(method, "set");
}
#[test]
fn try_chain_decompose_one_dot_keeps_bare_receiver() {
// `f.Close` → receiver "f" (bare ident), method "Close". The
// single-dot case still decomposes; apply_call routes it through
// the existing SymbolId-based path (not chain_proxies).
let (recv, method) = try_chain_decompose("f.Close").unwrap();
assert_eq!(recv, "f");
assert_eq!(method, "Close");
}
#[test]
fn try_chain_decompose_no_dot_returns_none() {
assert!(try_chain_decompose("Close").is_none());
assert!(try_chain_decompose("fopen").is_none());
}
#[test]
fn try_chain_decompose_complex_tokens_returns_none() {
// Each of these contains a token signaling complexity that breaks
// the simple `<ident>.<ident>...` shape; helper must bail to
// preserve the conservative behaviour the band-aid established.
for s in [
"Foo::bar::baz", // Rust path — `::` rules it out
"ptr->field.f", // C arrow operator
"obj.f().g", // intermediate call
"vec[0].field", // index expression
"obj?.f.g", // optional chain
"obj.f g", // whitespace
"c.writer.header()", // trailing parens (the gin/context shape)
] {
assert!(
try_chain_decompose(s).is_none(),
"expected bail on complex callee {s}"
);
}
}
#[test]
fn try_chain_decompose_rejects_empty_segments() {
for s in [".x.f", "x..f", "x.f.", "."] {
assert!(try_chain_decompose(s).is_none(), "expected bail on {s}");
}
}
#[test]
fn chain_proxy_acquire_records_chain_text_not_root() {
// Phase 3 key behaviour: a chained-receiver acquire (`c.mu.Lock()`)
// records `c.mu` in `state.chain_proxies` and DOES NOT touch the
// SymbolId-keyed `receiver_class_group` for the chain root `c`.
let mut interner = SymbolInterner::new();
let _sym_c = interner.intern_scoped(None, "c");
let lock = ResourceMethodSummary {
method_name: "Lock".into(),
effect: ResourceEffect::Acquire,
class_group: crate::cfg::BodyId(7),
original_span: (10, 20),
};
let transfer = DefaultTransfer {
lang: Lang::Go,
resource_pairs: rules::resource_pairs(Lang::Go),
interner: &interner,
resource_method_summaries: std::slice::from_ref(&lock),
ptr_proxy_hints: None,
};
let info = NodeInfo {
kind: StmtKind::Call,
ast: AstMeta {
span: (0, 30),
..Default::default()
},
taint: TaintMeta::default(),
call: CallMeta {
callee: Some("c.mu.Lock".into()),
..Default::default()
},
..Default::default()
};
let (state, events) =
transfer.apply(NodeIndex::new(0), &info, None, ProductState::initial());
assert!(events.is_empty());
// chain_proxies has the chain text entry.
assert!(
state.chain_proxies.contains_key("c.mu"),
"expected chain_proxies['c.mu'] entry; got {:?}",
state.chain_proxies.keys().collect::<Vec<_>>()
);
let entry = &state.chain_proxies["c.mu"];
assert_eq!(entry.lifecycle, ResourceLifecycle::OPEN);
assert_eq!(entry.class_group, crate::cfg::BodyId(7));
assert_eq!(entry.acquire_span, (10, 20));
// Root `c` is NOT marked in receiver_class_group — the gin/context FP
// the band-aid was guarding against can no longer reappear.
assert!(
state.receiver_class_group.is_empty(),
"chain root must not inherit proxy state; receiver_class_group was {:?}",
state.receiver_class_group
);
}
#[test]
fn chain_proxy_release_after_acquire_transitions_to_closed() {
// Acquire + matching Release on the same chain receiver +
// class group should transition the chain entry to CLOSED.
let mut interner = SymbolInterner::new();
let _sym_c = interner.intern_scoped(None, "c");
let class_group = crate::cfg::BodyId(11);
let summaries = vec![
ResourceMethodSummary {
method_name: "Lock".into(),
effect: ResourceEffect::Acquire,
class_group,
original_span: (0, 10),
},
ResourceMethodSummary {
method_name: "Unlock".into(),
effect: ResourceEffect::Release,
class_group,
original_span: (20, 30),
},
];
let transfer = DefaultTransfer {
lang: Lang::Go,
resource_pairs: rules::resource_pairs(Lang::Go),
interner: &interner,
resource_method_summaries: &summaries,
ptr_proxy_hints: None,
};
let lock_info = NodeInfo {
kind: StmtKind::Call,
ast: AstMeta {
span: (0, 10),
..Default::default()
},
taint: TaintMeta::default(),
call: CallMeta {
callee: Some("c.mu.Lock".into()),
..Default::default()
},
..Default::default()
};
let (state, _) =
transfer.apply(NodeIndex::new(0), &lock_info, None, ProductState::initial());
assert_eq!(
state.chain_proxies["c.mu"].lifecycle,
ResourceLifecycle::OPEN
);
let unlock_info = NodeInfo {
kind: StmtKind::Call,
ast: AstMeta {
span: (20, 30),
..Default::default()
},
taint: TaintMeta::default(),
call: CallMeta {
callee: Some("c.mu.Unlock".into()),
..Default::default()
},
..Default::default()
};
let (state, _) = transfer.apply(NodeIndex::new(1), &unlock_info, None, state);
assert_eq!(
state.chain_proxies["c.mu"].lifecycle,
ResourceLifecycle::CLOSED
);
}
#[test]
fn chain_proxy_distinct_chains_dont_collide() {
// `c.mu.Lock()` and `c.other.Lock()` are independent chain
// receivers — each gets its own entry in chain_proxies.
let interner = SymbolInterner::new();
let class_group = crate::cfg::BodyId(3);
let lock = ResourceMethodSummary {
method_name: "Lock".into(),
effect: ResourceEffect::Acquire,
class_group,
original_span: (0, 0),
};
let transfer = DefaultTransfer {
lang: Lang::Go,
resource_pairs: rules::resource_pairs(Lang::Go),
interner: &interner,
resource_method_summaries: std::slice::from_ref(&lock),
ptr_proxy_hints: None,
};
let mk_call = |callee: &str| NodeInfo {
kind: StmtKind::Call,
ast: AstMeta {
span: (0, 0),
..Default::default()
},
taint: TaintMeta::default(),
call: CallMeta {
callee: Some(callee.into()),
..Default::default()
},
..Default::default()
};
let (state, _) = transfer.apply(
NodeIndex::new(0),
&mk_call("c.mu.Lock"),
None,
ProductState::initial(),
);
let (state, _) = transfer.apply(NodeIndex::new(1), &mk_call("c.other.Lock"), None, state);
assert!(state.chain_proxies.contains_key("c.mu"));
assert!(state.chain_proxies.contains_key("c.other"));
assert_eq!(state.chain_proxies.len(), 2);
}
#[test]
fn single_dot_proxy_acquire_uses_symbol_id_path() {
// REGRESSION: single-dot callees keep the existing SymbolId-based
// path — `f.acquireMine()` records against
// `receiver_class_group[sym_f]`, NOT `chain_proxies["f"]`. This
// preserves all existing 1-dot proxy semantics (leak detection,
// finding attribution).
//
// We use an unusual method name so the direct-pair matcher
// doesn't fire first (Go's resource_pairs cover `.Close`,
// `.close`, etc., which would short-circuit before the proxy
// routing).
let mut interner = SymbolInterner::new();
let sym_f = interner.intern_scoped(None, "f");
let class_group = crate::cfg::BodyId(2);
let acquire = ResourceMethodSummary {
method_name: "acquireMine".into(),
effect: ResourceEffect::Acquire,
class_group,
original_span: (0, 0),
};
let transfer = DefaultTransfer {
lang: Lang::Go,
resource_pairs: rules::resource_pairs(Lang::Go),
interner: &interner,
resource_method_summaries: std::slice::from_ref(&acquire),
ptr_proxy_hints: None,
};
let info = NodeInfo {
kind: StmtKind::Call,
ast: AstMeta {
span: (0, 0),
..Default::default()
},
taint: TaintMeta::default(),
call: CallMeta {
callee: Some("f.acquireMine".into()),
receiver: Some("f".into()),
..Default::default()
},
..Default::default()
};
let (state, _) = transfer.apply(NodeIndex::new(0), &info, None, ProductState::initial());
// SymbolId path fired: receiver_class_group has the SymbolId entry.
assert_eq!(
state.receiver_class_group.get(&sym_f),
Some(&class_group),
"single-dot must use SymbolId path"
);
// chain_proxies stays empty: this is NOT a chain receiver.
assert!(
state.chain_proxies.is_empty(),
"single-dot must not populate chain_proxies; got {:?}",
state.chain_proxies.keys().collect::<Vec<_>>()
);
}
#[test]
fn complex_callee_does_not_record_proxy() {
// REGRESSION: callees with parens / `::` / `[` / `?` are
// unparseable as chain receivers. The helper bails, no proxy
// entry is recorded anywhere. Matches the conservative behaviour
// the band-aid established.
let interner = SymbolInterner::new();
let class_group = crate::cfg::BodyId(0);
let lock = ResourceMethodSummary {
method_name: "Lock".into(),
effect: ResourceEffect::Acquire,
class_group,
original_span: (0, 0),
};
let transfer = DefaultTransfer {
lang: Lang::Go,
resource_pairs: rules::resource_pairs(Lang::Go),
interner: &interner,
resource_method_summaries: std::slice::from_ref(&lock),
ptr_proxy_hints: None,
};
for callee in ["c.writer.header().Lock", "Foo::bar::Lock", "c[i].mu.Lock"] {
let info = NodeInfo {
kind: StmtKind::Call,
ast: AstMeta {
span: (0, 0),
..Default::default()
},
taint: TaintMeta::default(),
call: CallMeta {
callee: Some(callee.into()),
..Default::default()
},
..Default::default()
};
let (state, _) =
transfer.apply(NodeIndex::new(0), &info, None, ProductState::initial());
assert!(
state.chain_proxies.is_empty() && state.receiver_class_group.is_empty(),
"complex callee {callee} should not record any proxy state; chain={:?} root={:?}",
state.chain_proxies.keys().collect::<Vec<_>>(),
state.receiver_class_group.keys().collect::<Vec<_>>()
);
}
}
#[test]
fn chain_proxy_lattice_join_unions_keys() {
// Sanity check: the lattice join unions chain_proxies keys.
// Branch A: `c.mu` OPEN. Branch B: `c.other` OPEN. Join must
// contain both — this is the dataflow-correctness invariant
// for chain tracking across branches.
use crate::state::lattice::Lattice;
let mut a = ProductState::initial();
let mut b = ProductState::initial();
a.chain_proxies.insert(
"c.mu".into(),
ChainProxyState {
lifecycle: ResourceLifecycle::OPEN,
class_group: crate::cfg::BodyId(1),
acquire_span: (0, 0),
},
);
b.chain_proxies.insert(
"c.other".into(),
ChainProxyState {
lifecycle: ResourceLifecycle::OPEN,
class_group: crate::cfg::BodyId(2),
acquire_span: (10, 20),
},
);
let joined = a.join(&b);
assert_eq!(joined.chain_proxies.len(), 2);
assert!(joined.chain_proxies.contains_key("c.mu"));
assert!(joined.chain_proxies.contains_key("c.other"));
}
#[test]
fn chain_proxy_lattice_join_merges_lifecycle() {
// Same chain key on two branches — the lifecycle is OR-joined
// (OPEN CLOSED). Mirrors the `ResourceLifecycle::join`
// bitflag-or semantics already used for SymbolId-based tracking.
use crate::state::lattice::Lattice;
let mut a = ProductState::initial();
let mut b = ProductState::initial();
a.chain_proxies.insert(
"c.mu".into(),
ChainProxyState {
lifecycle: ResourceLifecycle::OPEN,
class_group: crate::cfg::BodyId(1),
acquire_span: (0, 0),
},
);
b.chain_proxies.insert(
"c.mu".into(),
ChainProxyState {
lifecycle: ResourceLifecycle::CLOSED,
class_group: crate::cfg::BodyId(1),
acquire_span: (0, 0),
},
);
let joined = a.join(&b);
assert_eq!(joined.chain_proxies.len(), 1);
let lc = joined.chain_proxies["c.mu"].lifecycle;
assert!(lc.contains(ResourceLifecycle::OPEN));
assert!(lc.contains(ResourceLifecycle::CLOSED));
}
// ─────────────────────────────────────────────────────────────────
// Pointer-analysis Phase 2: PtrProxyHint::FieldOnly routes
// single-dot proxy-acquire to chain_proxies, suppressing the
// SymbolId path that would otherwise mark the field-aliased local
// as a leakable resource.
// ─────────────────────────────────────────────────────────────────
#[test]
fn field_only_hint_routes_single_dot_acquire_to_chain_proxies() {
// Models `m := c.mu; m.Lock()` — `m`'s pt set is `{Field(SelfParam, mu)}`,
// so PtrProxyHint::FieldOnly applies. The acquire must record
// `m` in chain_proxies, NOT in receiver_class_group, so the
// leak detector does not later flag `m` as an OPEN-at-exit
// resource (it lives inside the function and never escapes).
let mut interner = SymbolInterner::new();
let _sym_m = interner.intern_scoped(None, "m");
let class_group = crate::cfg::BodyId(2);
let acquire = ResourceMethodSummary {
method_name: "Lock".into(),
effect: ResourceEffect::Acquire,
class_group,
original_span: (0, 10),
};
let mut hints = std::collections::HashMap::new();
hints.insert("m".to_string(), crate::pointer::PtrProxyHint::FieldOnly);
let transfer = DefaultTransfer {
lang: Lang::Go,
resource_pairs: rules::resource_pairs(Lang::Go),
interner: &interner,
resource_method_summaries: std::slice::from_ref(&acquire),
ptr_proxy_hints: Some(&hints),
};
let info = NodeInfo {
kind: StmtKind::Call,
ast: AstMeta {
span: (0, 10),
..Default::default()
},
taint: TaintMeta::default(),
call: CallMeta {
callee: Some("m.Lock".into()),
receiver: Some("m".into()),
..Default::default()
},
..Default::default()
};
let (state, events) =
transfer.apply(NodeIndex::new(0), &info, None, ProductState::initial());
assert!(events.is_empty());
assert!(
state.chain_proxies.contains_key("m"),
"FieldOnly hint should route `m.Lock()` into chain_proxies; got {:?}",
state.chain_proxies.keys().collect::<Vec<_>>()
);
assert!(
state.receiver_class_group.is_empty(),
"FieldOnly hint must not record SymbolId proxy entry; got {:?}",
state.receiver_class_group.keys().collect::<Vec<_>>()
);
let entry = &state.chain_proxies["m"];
assert_eq!(entry.lifecycle, ResourceLifecycle::OPEN);
assert_eq!(entry.class_group, class_group);
}
#[test]
fn field_only_hint_release_transitions_chain_entry_to_closed() {
// Acquire + Release pair on the field-aliased local both route
// through chain_proxies — the entry transitions OPEN → CLOSED
// exactly as the existing chain-receiver path does.
let mut interner = SymbolInterner::new();
let _sym_m = interner.intern_scoped(None, "m");
let class_group = crate::cfg::BodyId(11);
let summaries = vec![
ResourceMethodSummary {
method_name: "Lock".into(),
effect: ResourceEffect::Acquire,
class_group,
original_span: (0, 10),
},
ResourceMethodSummary {
method_name: "Unlock".into(),
effect: ResourceEffect::Release,
class_group,
original_span: (20, 30),
},
];
let mut hints = std::collections::HashMap::new();
hints.insert("m".to_string(), crate::pointer::PtrProxyHint::FieldOnly);
let transfer = DefaultTransfer {
lang: Lang::Go,
resource_pairs: rules::resource_pairs(Lang::Go),
interner: &interner,
resource_method_summaries: &summaries,
ptr_proxy_hints: Some(&hints),
};
let lock_info = NodeInfo {
kind: StmtKind::Call,
call: CallMeta {
callee: Some("m.Lock".into()),
receiver: Some("m".into()),
..Default::default()
},
..Default::default()
};
let (state, _) =
transfer.apply(NodeIndex::new(0), &lock_info, None, ProductState::initial());
assert_eq!(state.chain_proxies["m"].lifecycle, ResourceLifecycle::OPEN);
let unlock_info = NodeInfo {
kind: StmtKind::Call,
call: CallMeta {
callee: Some("m.Unlock".into()),
receiver: Some("m".into()),
..Default::default()
},
..Default::default()
};
let (state, _) = transfer.apply(NodeIndex::new(1), &unlock_info, None, state);
assert_eq!(
state.chain_proxies["m"].lifecycle,
ResourceLifecycle::CLOSED
);
}
#[test]
fn no_hint_falls_through_to_existing_symbol_id_path() {
// REGRESSION: when `ptr_proxy_hints` is `None`, the single-dot
// proxy-acquire branch behaves exactly as today — the SymbolId
// path fires, `chain_proxies` stays empty. Strict-additive
// contract: pointer analysis disabled ⇒ no behavioural change.
let mut interner = SymbolInterner::new();
let sym_f = interner.intern_scoped(None, "f");
let class_group = crate::cfg::BodyId(3);
let acquire = ResourceMethodSummary {
method_name: "acquireMine".into(),
effect: ResourceEffect::Acquire,
class_group,
original_span: (0, 0),
};
let transfer = DefaultTransfer {
lang: Lang::Go,
resource_pairs: rules::resource_pairs(Lang::Go),
interner: &interner,
resource_method_summaries: std::slice::from_ref(&acquire),
ptr_proxy_hints: None,
};
let info = NodeInfo {
kind: StmtKind::Call,
call: CallMeta {
callee: Some("f.acquireMine".into()),
receiver: Some("f".into()),
..Default::default()
},
..Default::default()
};
let (state, _) = transfer.apply(NodeIndex::new(0), &info, None, ProductState::initial());
assert_eq!(
state.receiver_class_group.get(&sym_f),
Some(&class_group),
"no hint ⇒ SymbolId path"
);
assert!(state.chain_proxies.is_empty());
}
#[test]
fn empty_hint_map_does_not_redirect() {
// REGRESSION: an empty hint map means "every name resolves to
// PtrProxyHint::Other". The single-dot branch must fall
// through to the SymbolId path — not silently route to
// chain_proxies because the map happened to be empty.
let mut interner = SymbolInterner::new();
let sym_f = interner.intern_scoped(None, "f");
let class_group = crate::cfg::BodyId(3);
let acquire = ResourceMethodSummary {
method_name: "acquireMine".into(),
effect: ResourceEffect::Acquire,
class_group,
original_span: (0, 0),
};
let hints: std::collections::HashMap<String, crate::pointer::PtrProxyHint> =
std::collections::HashMap::new();
let transfer = DefaultTransfer {
lang: Lang::Go,
resource_pairs: rules::resource_pairs(Lang::Go),
interner: &interner,
resource_method_summaries: std::slice::from_ref(&acquire),
ptr_proxy_hints: Some(&hints),
};
let info = NodeInfo {
kind: StmtKind::Call,
call: CallMeta {
callee: Some("f.acquireMine".into()),
receiver: Some("f".into()),
..Default::default()
},
..Default::default()
};
let (state, _) = transfer.apply(NodeIndex::new(0), &info, None, ProductState::initial());
assert_eq!(state.receiver_class_group.get(&sym_f), Some(&class_group));
assert!(state.chain_proxies.is_empty());
}
}