mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-24 20:28:06 +02:00
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:
parent
79c29b394d
commit
82f18184b1
348 changed files with 48731 additions and 2925 deletions
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue