#![allow(clippy::collapsible_if)] use super::domain::{AuthLevel, ChainProxyState, ProductState, ResourceLifecycle}; use super::engine::Transfer; use super::symbol::{SymbolId, SymbolInterner}; use crate::cfg::{EdgeKind, NodeInfo, StmtKind}; 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). /// /// Textual mirror of `try_lower_field_proj_chain` in /// `src/ssa/lower.rs`. The state engine doesn't read SSA bodies, so /// the parse rules are duplicated. 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)] pub struct TransferEvent { pub kind: TransferEventKind, pub node: NodeIndex, pub var: SymbolId, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TransferEventKind { UseAfterClose, DoubleClose, } /// Resource-use patterns: callees that read/write/operate on a resource handle /// (triggering use-after-close if the handle is closed). static RESOURCE_USE_PATTERNS: &[&str] = &[ "read", "write", "send", "recv", "fread", "fwrite", "fgets", "fputs", "fprintf", "fscanf", "fflush", "fseek", "ftell", "rewind", "feof", "ferror", "fgetc", "fputc", "getc", "putc", "ungetc", "query", "execute", "fetch", "sendto", "recvfrom", "ioctl", "fcntl", // Memory access functions (for malloc/free use-after-free detection) "strcpy", "strncpy", "strcat", "strncat", "memcpy", "memmove", "memset", "memcmp", "strcmp", "strncmp", "strlen", "sprintf", "snprintf", // Dot-prefixed method patterns (cross-language method calls) ".read", ".write", ".send", ".recv", ".query", ".execute", ".fetch", // JS/TS Sync variants (suffix doesn't match plain "read"/"write") "readSync", "writeSync", "readFileSync", "writeFileSync", "appendFileSync", "ftruncateSync", "fsyncSync", "fstatSync", // Stream operations "pipe", "unpipe", "resume", "pause", "destroy", ]; /// Auth-call matchers for admin-level privilege. static ADMIN_PATTERNS: &[&str] = &[ "is_admin", "hasrole", "has_role", "check_admin", "require_admin", ]; /// Effect type for resource method summaries. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ResourceEffect { Acquire, Release, } /// Summary for a method body that wraps a known resource operation. /// Only created for methods whose bodies actually contain a recognized /// resource acquire/release call from the existing resource_pairs matchers. #[derive(Debug, Clone)] pub struct ResourceMethodSummary { /// Method name (e.g., "open", "close"). pub method_name: String, /// Whether this method acquires or releases a resource. pub effect: ResourceEffect, /// `parent_body_id` of the declaring method, groups methods by class. pub class_group: crate::cfg::BodyId, /// Span of the actual resource operation (e.g., fs.openSync at line 7). pub original_span: (usize, usize), } pub struct DefaultTransfer<'a> { pub lang: Lang, pub resource_pairs: &'a [ResourcePair], 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>, } impl Transfer for DefaultTransfer<'_> { type Event = TransferEvent; fn apply( &self, node_idx: NodeIndex, info: &NodeInfo, edge: Option, mut state: ProductState, ) -> (ProductState, Vec) { let mut events = Vec::new(); match info.kind { StmtKind::Call => { self.apply_call(node_idx, info, &mut state, &mut events); } StmtKind::If => { self.apply_if(info, edge, &mut state); } StmtKind::Seq => { self.apply_assignment(node_idx, info, &mut state); } _ => {} } (state, events) } } impl DefaultTransfer<'_> { /// Look up a variable's [`SymbolId`] using the node's enclosing function /// as scope context. This ensures same-name variables in different /// functions resolve to distinct IDs. fn get_sym(&self, info: &NodeInfo, name: &str) -> Option { self.interner .get_scoped(info.ast.enclosing_func.as_deref(), name) } /// Returns `true` when the call was fully handled as a /// field-aliased receiver proxy and the rest of `apply_call` /// should bail. Activates on single-dot calls whose receiver is /// `FieldOnly` in the hint map and that match a /// [`ResourceMethodSummary`]. The acquire/release effect is /// recorded against `state.chain_proxies` keyed by receiver name. 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, info: &NodeInfo, state: &mut ProductState, events: &mut Vec, ) { let callee = match &info.call.callee { Some(c) => c.to_ascii_lowercase(), None => return, }; // ── 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 ───────────────────────────────────────────── // SAFE-FOR-FIELD-LHS (Go only): skip member-expression LHS // acquires. A `b.cpuprof = os.Create(...)` pattern transfers // ownership to the containing struct; the local function body // cannot observe the closure (which lives in a paired // Stop()/dispose() method), so tracking `b.cpuprof` as a local // resource is a guaranteed FP at function exit. Mirrors the // gate in src/cfg_analysis/resources.rs::run. Production // trigger: prometheus cmd/promtool/tsdb.go::startProfiling // cluster (b.cpuprof, b.memprof, b.blockprof, b.mtxprof). // Restricted to Go because TS/JS class-field acquires // (`this.fd = fs.openSync(...)`) are still expected to be // tracked — the leak fixtures rely on it. let mut direct_acquire = false; let define_is_field_lhs = self.lang == Lang::Go && info .taint .defines .as_deref() .is_some_and(|d| d.contains('.')); let resource_pairs_iter: &[ResourcePair] = if define_is_field_lhs { &[] } else { self.resource_pairs }; for pair in resource_pairs_iter { let is_acquire = pair.acquire.iter().any(|a| callee_matches(&callee, a)); let is_excluded = pair .exclude_acquire .iter() .any(|e| callee_matches(&callee, e)); if is_acquire && !is_excluded && let Some(ref def) = info.taint.defines && let Some(sym) = self.get_sym(info, def) { state.resource.set(sym, ResourceLifecycle::OPEN); direct_acquire = true; } } // ── Resource release ───────────────────────────────────────────── // Track which variables have already been released to avoid double- // matching across multiple resource pair definitions. let mut direct_release = false; let mut released: smallvec::SmallVec<[SymbolId; 4]> = smallvec::SmallVec::new(); for pair in self.resource_pairs { let is_release = pair.release.iter().any(|r| callee_matches(&callee, r)); if is_release { direct_release = true; // Go `defer f.Close()`: skip the CLOSED transition so the // variable stays OPEN mid-function. Leak suppression is // handled separately in extract_findings(). if info.in_defer { continue; } for used in &info.taint.uses { if let Some(sym) = self.get_sym(info, used) { if released.contains(&sym) { continue; } let current = state.resource.get(sym); if current == ResourceLifecycle::CLOSED { // Double close events.push(TransferEvent { kind: TransferEventKind::DoubleClose, node: node_idx, var: sym, }); } else if current.contains(ResourceLifecycle::OPEN) { state.resource.set(sym, ResourceLifecycle::CLOSED); } released.push(sym); } } } } // INNER-CALL-RELEASE-IN-ARG: walk info.arg_callees so a release // method that lives in argument position is still observed. // Production triggers: `require.NoError(t, f.Close())` (Go // testify), `errs = append(errs, f.Close())`, JUnit // `assertEquals(0, in.read())`. Conservative: bare-receiver // inner calls only (recv has no dot — chained-receiver // releases are owned by chain_proxies which doesn't observe // inner-call positions today); marks CLOSED only (no // DoubleClose since attribution is approximate); respects // in_defer for symmetry with the direct-release branch above. if !info.in_defer && !info.arg_callees.is_empty() { for arg_callee in &info.arg_callees { let Some(arg_callee_text) = arg_callee.as_deref() else { continue; }; let Some((recv_text, _method)) = try_chain_decompose(arg_callee_text) else { continue; }; if recv_text.contains('.') { continue; } let arg_callee_lower = arg_callee_text.to_ascii_lowercase(); let matches_release = self.resource_pairs.iter().any(|p| { p.release .iter() .any(|r| callee_matches(&arg_callee_lower, r)) }); if !matches_release { continue; } let Some(sym) = self.get_sym(info, recv_text) else { continue; }; if released.contains(&sym) { continue; } let current = state.resource.get(sym); if current.contains(ResourceLifecycle::OPEN) { state.resource.set(sym, ResourceLifecycle::CLOSED); released.push(sym); } } } // ── Resource method proxy ──────────────────────────────────────── // When no direct resource pair matched, check if the callee is a // method wrapper for a known resource operation. // // 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 (`.`) 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) { 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; } } } } } } else if !direct_acquire && !direct_release { // Single-dot receiver (`.`): 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); } } } } } } } // ── Resource use (pair-specific patterns first, then global fallback) let mut use_checked = false; for pair in self.resource_pairs { if pair.use_patterns.iter().any(|p| callee_matches(&callee, p)) { use_checked = true; for used in &info.taint.uses { if let Some(sym) = self.get_sym(info, used) { if state.resource.get(sym) == ResourceLifecycle::CLOSED { events.push(TransferEvent { kind: TransferEventKind::UseAfterClose, node: node_idx, var: sym, }); } } } } } if !use_checked { let is_use = RESOURCE_USE_PATTERNS .iter() .any(|p| callee_matches(&callee, p)); if is_use { for used in &info.taint.uses { if let Some(sym) = self.get_sym(info, used) { if state.resource.get(sym) == ResourceLifecycle::CLOSED { events.push(TransferEvent { kind: TransferEventKind::UseAfterClose, node: node_idx, var: sym, }); } } } } } // ── Auth call ──────────────────────────────────────────────────── let auth_rules = rules::auth_rules(self.lang); let is_auth = auth_rules.iter().any(|rule| { rule.matchers .iter() .any(|m| callee_matches(&callee, &m.to_ascii_lowercase())) }); if is_auth { let is_admin = ADMIN_PATTERNS.iter().any(|p| callee_matches(&callee, p)); let new_level = if is_admin { AuthLevel::Admin } else { AuthLevel::Authed }; if new_level > state.auth.auth_level { state.auth.auth_level = new_level; } } // ── Validation call (guard) ────────────────────────────────────── if is_guard_like(&callee) { for used in &info.taint.uses { if let Some(sym) = self.get_sym(info, used) { state.auth.validated.insert(sym); } } } } fn apply_if(&self, info: &NodeInfo, edge: Option, state: &mut ProductState) { // Determine the "positive edge", the edge where the underlying // (de-negated) condition evaluates to true. // // For `if (is_authenticated(req))`: positive = True edge // For `if (!allowed[cmd])`: positive = False edge // (because `!X` being false means `X` is true) let is_positive_edge = if info.condition_negated { matches!(edge, Some(EdgeKind::False)) } else { matches!(edge, Some(EdgeKind::True)) }; // Resource null-check: `if (f)` or `if (!f)` where f is a tracked // resource currently in OPEN state. The "var is falsy" edge means // the acquisition returned null/zero, no resource was actually // produced, so subsequent close requirements do not apply on that // path. Clearing OPEN suppresses the spurious may-leak finding for // the canonical NULL-safe close idiom in C / C++ / similar: // // FILE *f = fopen(path, "r"); // if (f) fclose(f); // // Without this rule the false edge keeps OPEN, joins with the true // edge's CLOSED at function exit, and produces a may-leak FP even // though the code is correct. // // Heuristic conditions: // * condition is a single-variable truth check (no comparisons, // no calls, `condition_vars.len() == 1` and the trimmed text // equals that variable name). // * the var has OPEN in its lifecycle bitset. // * the edge represents "var is falsy" (= !is_positive_edge). if !is_positive_edge && is_simple_truth_check(info) { for var in &info.condition_vars { if let Some(sym) = self.get_sym(info, var) { let lc = state.resource.get(sym); if lc.contains(ResourceLifecycle::OPEN) { state .resource .set(sym, lc.difference(ResourceLifecycle::OPEN)); } } } } if !is_positive_edge { return; } if let Some(ref cond) = info.condition_text { let cond_lower = cond.to_ascii_lowercase(); // Strip leading negation operator for pattern matching , // the edge selection above already encodes the semantics. let cond_inner = if info.condition_negated { cond_lower.trim_start_matches('!').trim_start() } else { cond_lower.as_str() }; // Auth-related condition let auth_rules = rules::auth_rules(self.lang); let is_auth_cond = auth_rules.iter().any(|rule| { rule.matchers .iter() .any(|m| condition_contains_auth_token(cond_inner, m)) }); if is_auth_cond { let is_admin = ADMIN_PATTERNS .iter() .any(|p| condition_contains_auth_token(cond_inner, p)); let new_level = if is_admin { AuthLevel::Admin } else { AuthLevel::Authed }; if new_level > state.auth.auth_level { state.auth.auth_level = new_level; } } // Go-specific: map boolean lookup is an allowlist/authorization guard. // In Go, `map[string]bool` lookups like `allowed[cmd]` return false // for missing keys, making `if allowed[cmd]` a standard allowlist pattern. if self.lang == Lang::Go && is_go_map_boolean_guard(cond_inner) { if AuthLevel::Authed > state.auth.auth_level { state.auth.auth_level = AuthLevel::Authed; } } // Validation-related condition if is_guard_like(cond_inner) { for var in &info.condition_vars { if let Some(sym) = self.get_sym(info, var) { state.auth.validated.insert(sym); } } } } } fn apply_assignment(&self, _node_idx: NodeIndex, info: &NodeInfo, state: &mut ProductState) { // Ownership transfer: if `defines` reassigns a tracked resource // variable from a `uses` variable, transfer the lifecycle. // // Skip when the RHS is a function or lambda literal: storing a // closure into a property (`ws.onclose = () => { ... }`, // `obj.handler = function(){...}`) does not move ownership of the // resources the closure body references — those identifiers appear // in `info.taint.uses` only because `def_use` walks the literal's // body, not because the assignment itself reads them. Without this // gate, the first OPEN-tracked capture inside the closure body gets // marked MOVED and the property's symbol becomes the new OPEN // owner, which then surfaces as a spurious leak on the property. if info.rhs_is_function_literal { return; } if let Some(ref def) = info.taint.defines && let Some(def_sym) = self.get_sym(info, def) { // SAFE-FOR-FIELD-LHS: when the LHS is a member expression // (struct field / object property), do NOT track the field as // a separate resource — the parent struct/object owns the // field's lifecycle and the local function body cannot // observe whether/when the parent's destructor (or paired // Stop()/dispose() method on the parent) releases the // underlying storage. Still mark the RHS as MOVED so the // local-leak analysis treats the assignment as ownership // transfer to the parent, not as a continuing local handle. // // Two real-repo shapes this closes (curl, openssl, postgres): // // (i) Sub-buffer alias inside a returned struct: // e = curlx_calloc(...); // e->name = (char *)e + sizeof(*e); // sub-buffer alias // return e; // Without this gate, e's OPEN transferred to e->name, e went // MOVED, and e->name surfaced as "never closed". // // (ii) Local-into-field ownership transfer: // ptr = malloc(...); // mem->buf = ptr; // ownership now lives in *mem // Without this gate, ptr was MOVED to mem->buf, but mem->buf // then leaked at exit because *mem's lifecycle is owned by // the caller. With this gate, ptr is MOVED (transfer // acknowledged) and mem->buf is not separately tracked. // // Multi-language: applies to all languages. This is distinct // from the `apply_call` field-LHS gate (Go-only because the // documented TS/JS class-field acquire // `this.fd = fs.openSync(...)` IS the expected leak pattern // in tests/fixtures/.../typescript/state/resource_class.ts — // that path remains untouched here because RHS-is-a-call // routes through `apply_call`, not `apply_assignment`). if def.contains('.') || def.contains("->") { for used in &info.taint.uses { if let Some(use_sym) = self.get_sym(info, used) { let lc = state.resource.get(use_sym); if lc.contains(ResourceLifecycle::OPEN) { state.resource.set(use_sym, ResourceLifecycle::MOVED); return; } } } return; } // If the RHS is a tracked resource, transfer its state for used in &info.taint.uses { if let Some(use_sym) = self.get_sym(info, used) { let lc = state.resource.get(use_sym); if lc.contains(ResourceLifecycle::OPEN) { state.resource.set(def_sym, lc); state.resource.set(use_sym, ResourceLifecycle::MOVED); return; } } } } } } /// Public wrapper for `callee_matches` used by `build_resource_method_summaries`. pub fn callee_matches_pub(callee: &str, pattern: &str) -> bool { callee_matches(callee, pattern) } /// Check if a callee matches a pattern. /// Supports suffix matching (e.g., "fclose" matches callee "my_fclose") /// and dot-prefix matching (e.g., ".close" matches "file.close"). fn callee_matches(callee: &str, pattern: &str) -> bool { let pattern_lower = pattern.to_ascii_lowercase(); if pattern_lower.starts_with('.') { // Method pattern: ".close" matches "x.close", "file.close", etc. callee.ends_with(&pattern_lower) } else { // Exact or suffix match callee == pattern_lower || callee.ends_with(&pattern_lower) } } /// Check if a callee looks like a guard/validation function. fn is_guard_like(callee: &str) -> bool { static GUARD_PREFIXES: &[&str] = &["validate", "sanitize", "check_", "verify_", "assert_"]; GUARD_PREFIXES.iter().any(|p| callee.starts_with(p)) } /// True iff the condition is a single-variable truth check (no comparison, /// no method call, no boolean composition), the bare `if (f)` or `if (!f)` /// shape used as a NULL-safe gate around resource access. /// /// Conservative: requires `condition_vars` to have exactly one entry, and /// the de-negated `condition_text` to be exactly that variable name (with /// optional parens stripped). Rejects `if (f != NULL)`, `if (f.method())`, /// `if (f && g)`, etc., which are not the simple truth-check idiom and may /// have different semantics for the false-branch resource state. fn is_simple_truth_check(info: &NodeInfo) -> bool { if info.condition_vars.len() != 1 { return false; } let var = &info.condition_vars[0]; let Some(text) = info.condition_text.as_deref() else { return false; }; let stripped = text.trim(); let stripped = stripped.trim_start_matches('!').trim(); let stripped = stripped.trim_matches(|c: char| c == '(' || c == ')').trim(); stripped == var } /// Detect Go `map[string]bool` allowlist lookups used as boolean guards. /// /// Matches when the entire condition is an index expression of the form /// `identifier[identifier]` (e.g., `allowed[cmd]`, `whitelist[key]`). /// In Go, indexing a `map[string]bool` returns `false` for missing keys, /// making `if allowed[cmd]` a standard allowlist/authorization pattern. /// /// Narrow by design: does NOT match complex expressions (`arr[i] > 0`), /// dotted receivers (`obj.map[key]`), or nested indexing. fn is_go_map_boolean_guard(cond: &str) -> bool { let cond = cond.trim(); let Some(bracket_start) = cond.find('[') else { return false; }; if !cond.ends_with(']') { return false; } let before = &cond[..bracket_start]; let inside = &cond[bracket_start + 1..cond.len() - 1]; // Before bracket: plain identifier (no dots, no operators) // Inside bracket: identifier, possibly dotted (r.URL.Query().Get("cmd")) !before.is_empty() && before .bytes() .all(|b| b.is_ascii_alphanumeric() || b == b'_') && !inside.is_empty() && inside .bytes() .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'.') } /// Check if condition text contains an auth/admin matcher at a word boundary. /// /// Dispatches based on matcher content: /// - **Identifier-only** (`is_authenticated`, `require_auth`): tokenise condition /// text on non-identifier characters and require an exact token match. /// - **Contains punctuation** (`middleware.auth`): find the matcher as a substring /// and verify word boundaries (non-ident char or string edge) on both sides. fn condition_contains_auth_token(cond: &str, matcher: &str) -> bool { let matcher_lower = matcher.to_ascii_lowercase(); let is_ident_only = matcher_lower .bytes() .all(|b| b.is_ascii_alphanumeric() || b == b'_'); if is_ident_only { // Tokenise on non-identifier chars, check for exact token match. cond.split(|c: char| !c.is_ascii_alphanumeric() && c != '_') .filter(|s| !s.is_empty()) .any(|token| token == matcher_lower) } else { // Word-boundary substring match for punctuated patterns. let hay = cond.as_bytes(); let needle = matcher_lower.as_bytes(); if needle.len() > hay.len() { return false; } let mut start = 0; while start + needle.len() <= hay.len() { if let Some(pos) = cond[start..].find(&*matcher_lower) { let abs = start + pos; let end = abs + needle.len(); let left_ok = abs == 0 || { let c = hay[abs - 1]; !c.is_ascii_alphanumeric() && c != b'_' }; let right_ok = end >= hay.len() || { let c = hay[end]; !c.is_ascii_alphanumeric() && c != b'_' }; if left_ok && right_ok { return true; } start = abs + 1; } else { break; } } false } } #[cfg(test)] mod tests { use super::*; use crate::cfg::{AstMeta, CallMeta, TaintMeta}; #[test] fn callee_matches_exact() { assert!(callee_matches("fopen", "fopen")); assert!(!callee_matches("fopen", "fclose")); } #[test] fn callee_matches_suffix() { assert!(callee_matches("curlx_fclose", "fclose")); } #[test] fn callee_matches_dot_prefix() { assert!(callee_matches("file.close", ".close")); assert!(!callee_matches("file.close", ".open")); } #[test] fn callee_matches_js_fd_use_patterns() { assert!(callee_matches("fs.readsync", "fs.readSync")); assert!(callee_matches("fs.writesync", "fs.writeSync")); assert!(!callee_matches("fs.readsync", "fs.writeSync")); } #[test] fn callee_matches_stream_method_patterns() { assert!(callee_matches("reader.pipe", ".pipe")); assert!(callee_matches("stream.write", ".write")); assert!(!callee_matches("readstream", ".read")); // no dot, no match } #[test] fn callee_matches_dot_prefix_no_c_interference() { assert!(!callee_matches("fread", ".read")); assert!(!callee_matches("fwrite", ".write")); assert!(!callee_matches("send", ".send")); } #[test] fn acquire_sets_open() { let mut interner = SymbolInterner::new(); let sym_f = interner.intern("f"); let transfer = DefaultTransfer { lang: Lang::C, resource_pairs: rules::resource_pairs(Lang::C), interner: &interner, resource_method_summaries: &[], ptr_proxy_hints: None, }; let info = NodeInfo { kind: StmtKind::Call, ast: AstMeta { span: (0, 10), ..Default::default() }, taint: TaintMeta { defines: Some("f".into()), ..Default::default() }, call: CallMeta { callee: Some("fopen".into()), ..Default::default() }, ..Default::default() }; let (state, events) = transfer.apply(NodeIndex::new(0), &info, None, ProductState::initial()); assert!(events.is_empty()); assert_eq!(state.resource.get(sym_f), ResourceLifecycle::OPEN); } #[test] fn close_after_open_sets_closed() { let mut interner = SymbolInterner::new(); let sym_f = interner.intern("f"); let transfer = DefaultTransfer { lang: Lang::C, resource_pairs: rules::resource_pairs(Lang::C), interner: &interner, resource_method_summaries: &[], ptr_proxy_hints: None, }; let mut state = ProductState::initial(); state.resource.set(sym_f, ResourceLifecycle::OPEN); let info = NodeInfo { kind: StmtKind::Call, ast: AstMeta { span: (10, 20), ..Default::default() }, taint: TaintMeta { uses: vec!["f".into()], ..Default::default() }, call: CallMeta { callee: Some("fclose".into()), ..Default::default() }, ..Default::default() }; let (state, events) = transfer.apply(NodeIndex::new(1), &info, None, state); assert!(events.is_empty()); assert_eq!(state.resource.get(sym_f), ResourceLifecycle::CLOSED); } #[test] fn double_close_emits_event() { let mut interner = SymbolInterner::new(); let sym_f = interner.intern("f"); let transfer = DefaultTransfer { lang: Lang::C, resource_pairs: rules::resource_pairs(Lang::C), interner: &interner, resource_method_summaries: &[], ptr_proxy_hints: None, }; let mut state = ProductState::initial(); state.resource.set(sym_f, ResourceLifecycle::CLOSED); let info = NodeInfo { kind: StmtKind::Call, ast: AstMeta { span: (20, 30), ..Default::default() }, taint: TaintMeta { uses: vec!["f".into()], ..Default::default() }, call: CallMeta { callee: Some("fclose".into()), ..Default::default() }, ..Default::default() }; let (_state, events) = transfer.apply(NodeIndex::new(2), &info, None, state); assert_eq!(events.len(), 1); assert_eq!(events[0].kind, TransferEventKind::DoubleClose); assert_eq!(events[0].var, sym_f); } #[test] fn use_after_close_emits_event() { let mut interner = SymbolInterner::new(); let sym_f = interner.intern("f"); let transfer = DefaultTransfer { lang: Lang::C, resource_pairs: rules::resource_pairs(Lang::C), interner: &interner, resource_method_summaries: &[], ptr_proxy_hints: None, }; let mut state = ProductState::initial(); state.resource.set(sym_f, ResourceLifecycle::CLOSED); let info = NodeInfo { kind: StmtKind::Call, ast: AstMeta { span: (30, 40), ..Default::default() }, taint: TaintMeta { uses: vec!["f".into()], ..Default::default() }, call: CallMeta { callee: Some("fread".into()), ..Default::default() }, ..Default::default() }; let (_state, events) = transfer.apply(NodeIndex::new(3), &info, None, state); assert_eq!(events.len(), 1); assert_eq!(events[0].kind, TransferEventKind::UseAfterClose); } #[test] fn is_guard_like_check() { assert!(is_guard_like("validate_input")); assert!(is_guard_like("sanitize_html")); assert!(is_guard_like("check_permission")); assert!(!is_guard_like("open_file")); } /// SAFE-FOR-FIELD-LHS gate: when an assignment writes a tracked /// resource into a struct field (`def` contains `.` or `->`), the /// RHS local must be marked MOVED (ownership transferred to the /// parent struct) and the field must NOT be tracked as a separate /// OPEN resource. Pins the curl/dynhds.c::entry_new shape. #[test] fn field_lhs_assignment_moves_rhs_and_does_not_track_field() { let mut interner = SymbolInterner::new(); let sym_e = interner.intern("e"); let sym_field = interner.intern("e->name"); let transfer = DefaultTransfer { lang: Lang::C, resource_pairs: rules::resource_pairs(Lang::C), interner: &interner, resource_method_summaries: &[], ptr_proxy_hints: None, }; let mut state = ProductState::initial(); state.resource.set(sym_e, ResourceLifecycle::OPEN); // `e->name = e` (sub-buffer alias): defines = "e->name", uses = ["e"]. let info = NodeInfo { kind: StmtKind::Seq, ast: AstMeta { span: (0, 10), ..Default::default() }, taint: TaintMeta { defines: Some("e->name".into()), uses: vec!["e".into()], ..Default::default() }, ..Default::default() }; let (state, events) = transfer.apply(NodeIndex::new(0), &info, None, state); assert!(events.is_empty()); assert_eq!( state.resource.get(sym_e), ResourceLifecycle::MOVED, "RHS local should transfer to MOVED (ownership handed to parent struct)" ); assert_eq!( state.resource.get(sym_field), ResourceLifecycle::empty(), "field-LHS must NOT be seeded as a separately-tracked OPEN resource" ); } /// Recall guard for the field-LHS gate: a plain local-to-local /// assignment (no field on the LHS) must still transfer the OPEN /// state to the new alias and mark the source MOVED, preserving /// existing local-leak detection. #[test] fn local_to_local_assignment_still_transfers_open() { let mut interner = SymbolInterner::new(); let sym_buf = interner.intern("buf"); let sym_cursor = interner.intern("cursor"); let transfer = DefaultTransfer { lang: Lang::C, resource_pairs: rules::resource_pairs(Lang::C), interner: &interner, resource_method_summaries: &[], ptr_proxy_hints: None, }; let mut state = ProductState::initial(); state.resource.set(sym_buf, ResourceLifecycle::OPEN); // `cursor = buf`: plain alias, no field. let info = NodeInfo { kind: StmtKind::Seq, ast: AstMeta { span: (0, 10), ..Default::default() }, taint: TaintMeta { defines: Some("cursor".into()), uses: vec!["buf".into()], ..Default::default() }, ..Default::default() }; let (state, events) = transfer.apply(NodeIndex::new(0), &info, None, state); assert!(events.is_empty()); assert_eq!(state.resource.get(sym_buf), ResourceLifecycle::MOVED); assert_eq!(state.resource.get(sym_cursor), ResourceLifecycle::OPEN); } #[test] fn is_simple_truth_check_recognises_bare_identifier() { let make = |text: &str, vars: Vec<&str>| NodeInfo { kind: StmtKind::If, ast: AstMeta::default(), condition_text: Some(text.to_string()), condition_vars: vars.into_iter().map(String::from).collect(), ..Default::default() }; // Plain `if (f)` truth check assert!(is_simple_truth_check(&make("f", vec!["f"]))); // Negated form `if (!f)` assert!(is_simple_truth_check(&make("!f", vec!["f"]))); // Parenthesised form `if ((f))` assert!(is_simple_truth_check(&make("(f)", vec!["f"]))); // Negated parenthesised form `if (!(f))` assert!(is_simple_truth_check(&make("!(f)", vec!["f"]))); // Negative: comparison assert!(!is_simple_truth_check(&make("f != NULL", vec!["f"]))); // Negative: method call assert!(!is_simple_truth_check(&make("f.is_valid()", vec!["f"]))); // Negative: composite condition assert!(!is_simple_truth_check(&make("f && g", vec!["f", "g"]))); // Negative: empty vars assert!(!is_simple_truth_check(&make("f", vec![]))); } #[test] fn null_check_clears_open_on_false_edge() { let mut interner = SymbolInterner::new(); let sym_f = interner.intern("f"); let transfer = DefaultTransfer { lang: Lang::C, resource_pairs: rules::resource_pairs(Lang::C), interner: &interner, resource_method_summaries: &[], ptr_proxy_hints: None, }; let mut state = ProductState::initial(); state.resource.set(sym_f, ResourceLifecycle::OPEN); let info = NodeInfo { kind: StmtKind::If, condition_text: Some("f".into()), condition_vars: vec!["f".into()], condition_negated: false, ..Default::default() }; // False edge: f is null → should clear OPEN let (state_false, _) = transfer.apply( NodeIndex::new(5), &info, Some(EdgeKind::False), state.clone(), ); assert!( !state_false .resource .get(sym_f) .contains(ResourceLifecycle::OPEN), "OPEN should be cleared on the null edge of `if (f)`" ); // True edge: f is non-null → OPEN preserved let (state_true, _) = transfer.apply( NodeIndex::new(5), &info, Some(EdgeKind::True), state.clone(), ); assert!( state_true .resource .get(sym_f) .contains(ResourceLifecycle::OPEN), "OPEN should be preserved on the non-null edge of `if (f)`" ); } #[test] fn null_check_negated_clears_open_on_true_edge() { let mut interner = SymbolInterner::new(); let sym_f = interner.intern("f"); let transfer = DefaultTransfer { lang: Lang::C, resource_pairs: rules::resource_pairs(Lang::C), interner: &interner, resource_method_summaries: &[], ptr_proxy_hints: None, }; let mut state = ProductState::initial(); state.resource.set(sym_f, ResourceLifecycle::OPEN); // `if (!f)`, condition_negated=true, true-edge means f is null let info = NodeInfo { kind: StmtKind::If, condition_text: Some("!f".into()), condition_vars: vec!["f".into()], condition_negated: true, ..Default::default() }; let (state_true, _) = transfer.apply( NodeIndex::new(5), &info, Some(EdgeKind::True), state.clone(), ); assert!( !state_true .resource .get(sym_f) .contains(ResourceLifecycle::OPEN), "OPEN should be cleared on the null edge of `if (!f)` (true edge)" ); let (state_false, _) = transfer.apply( NodeIndex::new(5), &info, Some(EdgeKind::False), state.clone(), ); assert!( state_false .resource .get(sym_f) .contains(ResourceLifecycle::OPEN), "OPEN should be preserved on the non-null edge of `if (!f)` (false edge)" ); } // ── callee_matches for resource patterns ─────────────────────────── #[test] fn callee_matches_js_end_release() { assert!(callee_matches("conn.end", ".end")); assert!(callee_matches("pool.end", ".end")); assert!(!callee_matches("backend", ".end")); // no dot } #[test] fn callee_matches_go_sql_open() { assert!(callee_matches("sql.open", "sql.Open")); // case-insensitive } #[test] fn callee_matches_php_pg() { assert!(callee_matches("pg_connect", "pg_connect")); assert!(callee_matches("pg_close", "pg_close")); assert!(!callee_matches("pg_query", "pg_connect")); } #[test] fn callee_matches_java_prepare_statement() { assert!(callee_matches("conn.preparestatement", "prepareStatement")); assert!(callee_matches("preparestatement", "prepareStatement")); } #[test] fn callee_matches_websocket() { assert!(callee_matches("websocket", "WebSocket")); } #[test] fn callee_matches_mysql_create_connection() { assert!(callee_matches( "mysql.createconnection", "mysql.createConnection" )); } #[test] fn callee_matches_finish_release() { assert!(callee_matches("http.finish", ".finish")); assert!(!callee_matches("finish_setup", ".finish")); // no dot } // ── condition_contains_auth_token ──────────────────────────────────── #[test] fn auth_token_exact_match() { assert!(condition_contains_auth_token( "is_authenticated", "is_authenticated" )); assert!(condition_contains_auth_token("is_admin", "is_admin")); assert!(condition_contains_auth_token( "require_auth", "require_auth" )); } #[test] fn auth_token_dotted_access() { assert!(condition_contains_auth_token( "req.is_authenticated()", "is_authenticated" )); assert!(condition_contains_auth_token( "user.is_authenticated == true", "is_authenticated" )); assert!(condition_contains_auth_token( "req.user.is_authenticated", "is_authenticated" )); assert!(condition_contains_auth_token("user.is_admin()", "is_admin")); } #[test] fn auth_token_rejects_substring_regression() { // Explicit regression locks for known false positives. assert!(!condition_contains_auth_token( "not_is_authenticated", "is_authenticated" )); assert!(!condition_contains_auth_token( "cached_is_authenticated_flag", "is_authenticated" )); assert!(!condition_contains_auth_token( "xis_authenticated", "is_authenticated" )); assert!(!condition_contains_auth_token( "this_is_admin_panel", "is_admin" )); } #[test] fn auth_token_underscore_camel_boundary_cases() { // Underscore-joined identifiers are single tokens, must not match interior. assert!(!condition_contains_auth_token( "req.user_is_authenticated_flag", "is_authenticated" )); // Dot-separated segments ARE separate tokens. assert!(condition_contains_auth_token( "req.user.is_authenticated", "is_authenticated" )); } #[test] fn auth_token_dotted_matcher() { assert!(condition_contains_auth_token( "middleware.auth()", "middleware.auth" )); assert!(condition_contains_auth_token( "if middleware.auth(req)", "middleware.auth" )); // Left boundary violation. assert!(!condition_contains_auth_token( "xmiddleware.auth()", "middleware.auth" )); // Right boundary violation, "middleware.authz" extends past "middleware.auth". assert!(!condition_contains_auth_token( "middleware.authz()", "middleware.auth" )); // "middleware.auth.check", matcher ends at '.', which is non-ident → matches. assert!(condition_contains_auth_token( "middleware.auth.check()", "middleware.auth" )); } // ── condition_contains_auth_token for auth patterns ──────────────── #[test] fn auth_token_jwt_verify() { assert!(condition_contains_auth_token( "jwt.verify(token)", "jwt.verify" )); assert!(!condition_contains_auth_token( "jwt.verifyAsync(token)", "jwt.verify" )); } #[test] fn auth_token_passport() { assert!(condition_contains_auth_token( "passport.authenticate('local')", "passport.authenticate" )); } #[test] fn auth_token_generate_not_auth() { assert!(!condition_contains_auth_token( "generateToken(secret)", "verify_token" )); assert!(!condition_contains_auth_token( "generateToken(secret)", "validate_token" )); assert!(!condition_contains_auth_token( "generateToken(secret)", "authenticate" )); } #[test] fn auth_token_ensure_authenticated() { // condition_contains_auth_token expects pre-lowered condition text assert!(condition_contains_auth_token( "ensureauthenticated(req)", "ensureAuthenticated" )); } #[test] fn auth_token_require_role_not_substring() { assert!(condition_contains_auth_token( "requirerole('admin')", "requireRole" )); assert!(!condition_contains_auth_token( "prerequirerole()", "requireRole" )); } #[test] fn auth_token_boolean_composition() { // Compound conditions, each token should be individually matchable. assert!(condition_contains_auth_token( "is_authenticated && is_admin", "is_authenticated" )); assert!(condition_contains_auth_token( "is_authenticated && is_admin", "is_admin" )); assert!(condition_contains_auth_token( "!is_authenticated && is_admin", "is_authenticated" )); assert!(condition_contains_auth_token( "user == null || !user.is_authenticated", "is_authenticated" )); } // ───────────────────────────────────────────────────────────────── // 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 `....` 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() { // 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::>() ); 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::>() ); } #[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::>(), state.receiver_class_group.keys().collect::>() ); } } #[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: 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::>() ); assert!( state.receiver_class_group.is_empty(), "FieldOnly hint must not record SymbolId proxy entry; got {:?}", state.receiver_class_group.keys().collect::>() ); 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 = 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()); } #[test] fn inner_call_release_in_arg_marks_closed() { let mut interner = SymbolInterner::new(); let sym_f = interner.intern_scoped(None, "f"); let transfer = DefaultTransfer { lang: Lang::Go, resource_pairs: rules::resource_pairs(Lang::Go), interner: &interner, resource_method_summaries: &[], ptr_proxy_hints: None, }; let mut state = ProductState::initial(); state.resource.set(sym_f, ResourceLifecycle::OPEN); let info = NodeInfo { kind: StmtKind::Call, ast: AstMeta { span: (0, 30), ..Default::default() }, taint: TaintMeta { uses: vec!["t".into(), "f".into()], ..Default::default() }, call: CallMeta { callee: Some("require.NoError".into()), ..Default::default() }, arg_callees: vec![None, Some("f.Close".into())], ..Default::default() }; let (state, events) = transfer.apply(NodeIndex::new(0), &info, None, state); assert!(events.is_empty()); assert_eq!(state.resource.get(sym_f), ResourceLifecycle::CLOSED); } #[test] fn inner_call_release_in_arg_chained_receiver_skipped() { let mut interner = SymbolInterner::new(); let sym_c = interner.intern_scoped(None, "c"); let transfer = DefaultTransfer { lang: Lang::Go, resource_pairs: rules::resource_pairs(Lang::Go), interner: &interner, resource_method_summaries: &[], ptr_proxy_hints: None, }; let mut state = ProductState::initial(); state.resource.set(sym_c, ResourceLifecycle::OPEN); let info = NodeInfo { kind: StmtKind::Call, ast: AstMeta { span: (0, 30), ..Default::default() }, taint: TaintMeta { uses: vec!["c".into()], ..Default::default() }, call: CallMeta { callee: Some("t.Helper".into()), ..Default::default() }, arg_callees: vec![Some("c.mu.Unlock".into())], ..Default::default() }; let (state, _) = transfer.apply(NodeIndex::new(0), &info, None, state); assert_eq!(state.resource.get(sym_c), ResourceLifecycle::OPEN); } #[test] fn inner_call_release_in_arg_respects_in_defer() { let mut interner = SymbolInterner::new(); let sym_f = interner.intern_scoped(None, "f"); let transfer = DefaultTransfer { lang: Lang::Go, resource_pairs: rules::resource_pairs(Lang::Go), interner: &interner, resource_method_summaries: &[], ptr_proxy_hints: None, }; let mut state = ProductState::initial(); state.resource.set(sym_f, ResourceLifecycle::OPEN); let info = NodeInfo { kind: StmtKind::Call, ast: AstMeta { span: (0, 30), ..Default::default() }, taint: TaintMeta { uses: vec!["f".into()], ..Default::default() }, call: CallMeta { callee: Some("log.Print".into()), ..Default::default() }, arg_callees: vec![Some("f.Close".into())], in_defer: true, ..Default::default() }; let (state, _) = transfer.apply(NodeIndex::new(0), &info, None, state); assert_eq!(state.resource.get(sym_f), ResourceLifecycle::OPEN); } #[test] fn member_field_lhs_acquire_skips_resource_state() { let interner = SymbolInterner::new(); let transfer = DefaultTransfer { lang: Lang::Go, resource_pairs: rules::resource_pairs(Lang::Go), interner: &interner, resource_method_summaries: &[], ptr_proxy_hints: None, }; let info = NodeInfo { kind: StmtKind::Call, ast: AstMeta { span: (0, 30), ..Default::default() }, taint: TaintMeta { defines: Some("b.cpuprof".into()), ..Default::default() }, call: CallMeta { callee: Some("os.Create".into()), ..Default::default() }, ..Default::default() }; let (state, _) = transfer.apply(NodeIndex::new(0), &info, None, ProductState::initial()); assert!(state.resource.vars.is_empty()); } #[test] fn bare_ident_lhs_acquire_still_tracks() { let mut interner = SymbolInterner::new(); let sym_f = interner.intern_scoped(None, "f"); let transfer = DefaultTransfer { lang: Lang::Go, resource_pairs: rules::resource_pairs(Lang::Go), interner: &interner, resource_method_summaries: &[], ptr_proxy_hints: None, }; let info = NodeInfo { kind: StmtKind::Call, ast: AstMeta { span: (0, 30), ..Default::default() }, taint: TaintMeta { defines: Some("f".into()), ..Default::default() }, call: CallMeta { callee: Some("os.Open".into()), ..Default::default() }, ..Default::default() }; let (state, _) = transfer.apply(NodeIndex::new(0), &info, None, ProductState::initial()); assert!(state.resource.get(sym_f).contains(ResourceLifecycle::OPEN)); } }