nyx/src/engine_notes.rs

623 lines
25 KiB
Rust
Raw Normal View History

Release/0.5.0 (#35) * feat: Introduce function-scoped variable interning for state analysis with new tests and fixtures * feat: Add Phase 26 symbolic execution enhancements with bitwise operator support, abstract interpretation refinements, and new taint analysis tests * feat: Refine state analysis to handle factory-pattern resource returns with mixed-path tests and leak detection enhancements * feat: Add Phase 27 debug views with symbolic execution, abstract interpretation, SSA, and call graph viewers; integrate with debug layout and styles * feat: Add Phase 31 type-qualified symbolic resolution with receiver-based callee disambiguation and testing * feat: Extend symbolic execution with state iteration, enhanced debug views, and debounced input handling * feat: Add Phase 13 resource and auth pattern extensions with new tests and fixtures * feat: Introduce CFG debug graph renderer with compact mode, toolbar, and DAG layout integration * feat: Add Phase 28 encoding and decoding transform modeling with structural symex enhancements and new taint analysis tests * feat: Extend abstract interpretation with type facts and constant value tracking in debug views and server logic * feat: Add linear path handling and witness extraction to symbolic execution with Phase 28 transform mismatch detection * feat: Refine Go auth and sanitizer handling with enhanced rules, state updates, and benchmark improvements * feat: Enable auth-state analysis by default and update relevant tests in benchmark config * test: Update state_tests to reflect default enablement of auth-state analysis and add auth suppression test * docs: update CHANGELOG.md * feat: Introduce per-index taint tracking in `HeapState` with `HeapSlot`, overflow handling, and revised SSA transfers * feat: Introduce C/C++ language labels and refine heap state tracking in SSA transfers * feat: Implement per-index array slot tracking in symbolic heap with overflow collapse * feat: Add implicit definition handling for uninitialized declarations in SSA value allocation * feat: Refactor function parameters and constants for improved clarity and maintainability * refactor: Reorder module imports and improve formatting for consistency * refactor: Fix formatting erorrs * refactor: Fix clippy warnings * refactor: Fix fmt warnings (again) * chore: Update dependencies and improve feature configuration * Add comprehensive tests for undertested modules (#36) (COPILOT) * Add comprehensive tests for undertested modules Co-authored-by: elicpeter <54954007+elicpeter@users.noreply.github.com> Agent-Logs-Url: https://github.com/elicpeter/nyx/sessions/f3fc877e-f386-49ba-9793-fc93d3805083 * Add comprehensive tests for ext, project, walk, and errors modules Co-authored-by: elicpeter <54954007+elicpeter@users.noreply.github.com> Agent-Logs-Url: https://github.com/elicpeter/nyx/sessions/f3fc877e-f386-49ba-9793-fc93d3805083 --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: elicpeter <54954007+elicpeter@users.noreply.github.com> * chore: Update dependencies and improve feature configuration * fix: formatting errors in new tests * chore: Update license list in about.toml * chore: made functions input inline * chore: updated cfg graph to take up the full page * chore: add Prettier configuration and update code formatting * Add frontend test suite with Vitest (111 tests) (#37) * Add Vitest test suite for frontend - 111 tests across utils, components, hooks, and graph utilities Co-authored-by: elicpeter <54954007+elicpeter@users.noreply.github.com> Agent-Logs-Url: https://github.com/elicpeter/nyx/sessions/7cf0dba2-ecff-4740-ba4d-92717e74a0b7 * ci: add frontend test step to CI workflow Co-authored-by: elicpeter <54954007+elicpeter@users.noreply.github.com> Agent-Logs-Url: https://github.com/elicpeter/nyx/sessions/5bc0ac9f-0a32-4d03-9cb7-7a15aea53fca --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: elicpeter <54954007+elicpeter@users.noreply.github.com> * chore: simplify array initialization in test files for consistency * ran typecheck * feat: add AnalysisWorkspace component and integrate it into CfgViewerPage * feat: update routing in AppLayout and improve empty state message in ExplorerPage * feat: enhance scan progress tracking with additional metrics and stages * feat: update license information and add license check script * feat: implement cross-file symbolic execution with callee body persistence * feat: replace dagre graphs with Graphology + ELK + Sigma for more advanced call stack and cfg rendering * feat: ensure CFG function view is scoped to the selected function, preventing bleed into sibling functions * feat: enhance resource tracking with proxy method summaries and improve finding extraction * feat: add terminal function exit detection for accurate resource leak analysis * feat: add warnings for loops and functions without bodies to improve error recovery * feat: update lambda expression handling to ensure proper function classification and control flow * feat: remove bounded formatting/string ops and add JSON.parse sanitizer for improved data handling * feat: add inline return taint analysis and regression tests for improved security checks * feat: add engine version management and migration handling for database schema updates * feat: enhance first_call_ident to skip nested function bodies and add regression tests * feat: enhance callee name resolution with two-segment normalization and disambiguation * feat: add cross-file context flags and debug assertions for taint analysis * feat: refactor taint analysis structure to unify context handling and improve clarity * feat: enhance dead code elimination to preserve Sink, Source, and Sanitizer labels with new tests * docs: updated CHANGELOG.md * fmt: formatting fixes * fix: fixed frontend formatting and lint warnings * fix: optimized ci * fix: optimized ci * Add comprehensive multi-file test coverage to Nyx (#38) * Initial checklist for multi-file test suite expansion Agent-Logs-Url: https://github.com/elicpeter/nyx/sessions/e550cb88-9767-4442-94d4-101bf5bb0e23 Co-authored-by: elicpeter <54954007+elicpeter@users.noreply.github.com> * Add 12 new multi-file test fixtures with TP/TN/near-miss coverage Agent-Logs-Url: https://github.com/elicpeter/nyx/sessions/e550cb88-9767-4442-94d4-101bf5bb0e23 Co-authored-by: elicpeter <54954007+elicpeter@users.noreply.github.com> * deleted root repo * rebuilt to test for regressions --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: elicpeter <54954007+elicpeter@users.noreply.github.com> Co-authored-by: elipeter <elicpeter@gmail.com> * feat: enhance import alias resolution and taint tracking * feat: implement security hardening with CSRF protection and path validation * feat: add support for import alias bindings in Python, PHP, and Rust * feat: enhance CFG analysis modes and improve code readability * feat: add detection for parameterized SQL queries to enhance security * feat: add safe internal redirect handling and enhance session destroy validation * feat: implement security improvements by addressing vulnerabilities in execAsync, session management, and file downloads * feat: enhance taint detection by adding support for inline source member expressions in call arguments * feat: implement pre-emission of Source nodes for inline source member expressions in call arguments * feat: add support for Throw statement in control flow and error handling * feat: add debug and echo endpoints with potential information leakage * feat: implement internal redirect suppression and enhance taint detection * feat: implement module alias tracking for dynamic dispatch in JS/TS * feat: add authorization analysis module with Express support * feat: add authorization analysis module with Express support * feat: add tests for admin guard requirements and clean checks in authorization analysis * feat: integrate Koa and Fastify frameworks into authorization analysis * feat: add Flask and Django support to authorization analysis module * feat: add support for Rails and Sinatra frameworks in authorization analysis * feat: add support for Axum, ActixWeb, and Rocket frameworks in authorization analysis * feat: add support for ActixWeb, Axum, and Rocket frameworks in authorization analysis * feat: add support for Rails and Sinatra in authorization analysis * chore: add .DS_Store to .gitignore * refactor: simplify conditional checks and improve readability in multiple files * refactor: update usage of Option methods for improved clarity and consistency * refactor: improve code readability by simplifying conditional checks and formatting * refactor: improve code formatting and readability by simplifying conditional checks * refactor: simplify conditional checks and improve readability in multiple files * refactor: simplify conditional checks in axum.rs for improved readability * feat: add CodeQL analysis configuration for enhanced security scanning * test: add comprehensive tests for `src/output.rs` SARIF builder (#39) * chore: start test coverage improvement work Agent-Logs-Url: https://github.com/elicpeter/nyx/sessions/cd7ff398-134e-4728-a5e7-0353a0744423 Co-authored-by: elicpeter <54954007+elicpeter@users.noreply.github.com> * test: add comprehensive tests for src/output.rs SARIF builder Agent-Logs-Url: https://github.com/elicpeter/nyx/sessions/cd7ff398-134e-4728-a5e7-0353a0744423 Co-authored-by: elicpeter <54954007+elicpeter@users.noreply.github.com> * refactor: improve code formatting and readability in output.rs --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: elicpeter <54954007+elicpeter@users.noreply.github.com> Co-authored-by: elipeter <elicpeter@gmail.com> * refactor: improve code formatting and readability in output.rs * Potential fix for code scanning alert no. 210: Uncontrolled data used in path expression Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * Potential fix for code scanning alert no. 211: Uncontrolled data used in path expression Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * refactor: enhance triage file path handling with improved error management and validation * refactor: updated func summaries for richer detail * refactor: update SSA summary extraction to use canonical FuncKey for distinct entries * refactor: enhance callee metadata structure to support arity, receiver, and qualifier for better overload resolution * refactor: add support for keyword arguments in function calls and enhance receiver extraction for method-style calls * refactor: implement new Flask routes for safe and unsafe shell command execution * refactor: separate receiver handling in SSA operations and enhance taint propagation * refactor: improve arity handling by using arg_uses for positional argument count and enhance witness scoring for tainted arguments * refactor: implement auth decorator extraction and classification for multiple languages * refactor: enhance Rust module path resolution and use map handling for cross-file disambiguation * refactor: introduce CalleeQuery struct for structured callee resolution and enhance resolver logic * refactor: implement same-file identity collision handling for `runTask` to ensure correct resolver behavior * refactor: standardize default struct initialization across multiple files * feat: add scripts for formatting checks and auto-fixes with test summaries * refactor: simplify character splitting and enhance namespace qualifier handling * refactor: improve documentation clarity and enhance code readability in resolver logic * refactor: replace default struct initialization with explicit field assignments for clarity * feat: enhance anonymous function naming by deriving context-based bindings * refactor: streamline match expressions for improved readability and performance * refactor: streamline match expressions for improved readability and performance * refactor: replace loop with while let for improved clarity and performance * feat: add SSA constant propagation support to analysis context for improved accuracy * feat: add SSA constant propagation support to analysis context for improved accuracy * feat: implement shell metacharacter validation and bounded-length checks in Rust analysis * feat: add static map analysis for command injection suppression and type safety * refactor: simplify match statements and reduce line breaks for improved readability * feat(summary): phase 1/5 SinkSite data model for primary sink-location attribution Introduce SinkSite (file_rel, line, col, snippet, cap) carrying the primary sink source-location through function summaries. Swap SsaFuncSummary.param_to_sink and FuncSummary.param_to_sink from a coarse Cap map to a deduped SmallVec<[SinkSite; 1]> per parameter, with a backward-compatible cap_sites() helper and serde defaults so pre-phase-1 on-disk rows continue to deserialise cleanly. Extraction: SinkSiteLocator bundles the tree/bytes/file_rel needed by extract_ssa_func_summary; ParsedFile::extract_ssa_artifacts wires the locator in for the persisted pass-1 path, while pass-2 intra-file transient summaries fall back to cap-only sites (behavior unchanged). Merge: GlobalSummaries::insert now unions sink sites with (file_rel, line, col, cap) dedup via shared union_param_sink_sites helper. Database: JSON-serialised summary columns carry the new shape automatically; no schema change needed. Phase 2 will consume SinkSite in build_taint_diag() to overwrite the caller-site Finding.line with the callee's sink line when resolved via summary. Phase 1 keeps behavior unchanged: scanning tests/benchmark/corpus/rust/cmdi/cmdi_indirect.rs still produces the same (wrong) line 10 finding. Adds round-trip tests covering SinkSite solo, SsaFuncSummary with sink sites, legacy-JSON default handling for both summary types, and merge dedup. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(taint): phase 2/5 thread SinkSite into SsaTaintEvent and Finding Plumb Phase 1's SinkSite through the event pipeline into Findings, no output change yet. SsaTaintEvent gains `primary_sink_site: Option<SinkSite>`; when the main or callback sink-emission path has non-empty `param_to_sink_sites`, filter to sites whose `(line != 0) && (cap ∩ sink_caps != ∅)` and emit one event per distinct site — the multi-primary collapse keeps each downstream Finding single-primary. Resolution: ResolvedSummary and SinkInfo gain mirror `param_to_sink_sites` fields, populated from `SsaFuncSummary.param_to_sink` (SSA + callback paths) and `FuncSummary.param_to_sink` (global paths). Label, local-summary, and interop resolution paths leave the field empty — they only ever had cap-level info to begin with. Finding: new `primary_location: Option<SinkLocation>` with `file_rel/line/col`. `ssa_events_to_findings` maps `event.primary_sink_site` → `Finding.primary_location`, filtering cap-only sites (`line == 0`) to `None` so the (0,0) sentinel never leaks to formatters. Dedup key extended with the primary location so multi-site events aren't collapsed back together. Invariants (debug_assert!): * every SinkSite reaching emission has `line != 0 && cap ∩ sink_caps != ∅` — enforced by the pick_primary_sink_sites* filters; * every populated Finding.primary_location has `line != 0` AND non-empty `file_rel` — the cap-only → None translation upstream guarantees this. Deliberately independent of `uses_summary`: that flag tracks whether the *taint chain* used a summary, whereas primary attribution requires only that the *sink* itself was summary-resolved. A local source reaching a cross-file sink produces `uses_summary=false` alongside a populated primary_location — documented on Finding.primary_location, covered by `cross_file_sink_finding_carries_primary_location`. build_taint_diag, SARIF/JSON/explanation formatters, and the benchmark scorer remain untouched: finding.line still comes from `cfg_graph[finding.sink]`, so cmdi_indirect.rs still reports line 10 and the benchmark's rs-cmdi-003 row still shows FN in the LOC column. Tests: `cross_file_sink_finding_carries_primary_location` (proves plumbing via a synthetic FuncSummary carrying a SinkSite at 42:5) and `cross_file_sink_cap_only_site_leaves_primary_location_none` (regression guard against cap-only sites surfacing). All 1566 lib tests + integration tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(output): phase 3/5 consume primary sink location in diag + SARIF When a finding's primary_location (populated in phase 2 from a callee summary's SinkSite) names the dangerous instruction inside a callee body, attribute the diagnostic line to that location instead of the caller's call site. The call site is demoted to a Call step in flow_steps, and a synthetic Sink step at the primary location is appended so analysts still see the full trace. Changes: - Add scan_root parameter to build_taint_diag so file_rel can be resolved back to an absolute path via a shared resolve_file_rel helper. Empty file_rel (single-file scans where namespace == "") resolves to the file under analysis. - Extend SinkLocation with snippet, carried from the upstream SinkSite so the formatter needs no second file read. - Relax the ssa_events_to_findings debug_assert to allow empty file_rel, which is valid when scan root equals the file itself. - SARIF: emit data-flow as codeFlows[0].threadFlows[0].locations[]; locations[0] already reflects the primary sink position via the updated diag line/col. Acceptance: scan on tests/benchmark/corpus/rust/cmdi/cmdi_indirect.rs now reports line 5 (Command::new) as the primary sink, with the call site at line 10 visible in flow_steps. Two expect.json fixtures updated (must_match line_range widened): - javascript/taint/context_sensitive_call: 12-14 -> 7-14 (line 8 is the real sink inside run()). - rust/cfg/closure_async: 10-10 -> 10-11 (line 11 is Command::new inside the closure). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(bench): phase 4/5 validate primary sink attribution across corpus Extend the benchmark scorer and ground truth to lock in phase 3's primary-location behavior, and add fixtures that exercise the new capability end-to-end. Scorer (tests/benchmark_test.rs): - Add optional `expected_call_site_lines: Option<Vec<[usize; 2]>>` on Case. When present, score_location_level additionally requires at least one flow_step in the finding's evidence trace to fall within ±2 of the call-site range. When absent, the check is skipped — fully forward-compatible with existing fixtures. - Retain ±2 tolerance on expected_sink_lines (compared against the now-primary Diag.line post-phase-3). Ground truth edits: - rs-cmdi-cross-001: expected_sink_lines [8,8] -> [9,9]. Line 8 is the transform::wrap call site (a cross-file propagator, not a sink); line 9 is Command::new, the real sink. The ±2 tolerance happened to mask this stale attribution but it was semantically wrong — phase 4 is the right time to correct it. Also adds expected_call_site_lines [8,8] so the new field is exercised on an existing cross-file case. - rs-cmdi-003: adds expected_call_site_lines [10,10] (run_cmd call). This fixture's sink (Command::new inside run_cmd at line 5) was the motivating case for phases 1-3; adding the call-site assertion guards against regression to caller-line attribution. New fixtures: - rust/cmdi/cmdi_indirect_multisink.rs (rs-cmdi-009): helper run_both takes two tainted params and invokes two Command sinks on consecutive lines. Locks in that primary line lands inside the helper (lines 5-6), not at the caller (line 12). Notes document that SinkSite is currently one-per-callee so both findings today collapse onto the first sink; expected_sink_lines=[5,6] and expected_call_site_lines=[12,12] stay valid either way. - python/cmdi/cross_indirect_sink/{app.py,helper.py} (py-cmdi-cross- 004): sink os.system lives in helper.py (cross-file), caller in app.py reads env source and calls run_cmd. Verifies phase 3's cross-file primary attribution: Diag.path = helper.py, Diag.line = 5, with app.py:7 recorded in flow_steps as a Call step. Acceptance: - `cargo test --test benchmark_test -- --ignored --nocapture` passes. - rs-cmdi-003 is TP/TP/TP (the target flip FN->TP at LOC). All pre-existing TP/TP/TP fixtures remain TP/TP/TP; 2 new fixtures are TP/TP/TP. - Aggregate rule-level: TP=158 FP=10 FN=1 TN=97, P=0.940 R=0.994 F1=0.966 on the 266-case corpus (was TP=156 FP=10 FN=1 TN=97 on 264 pre-phase-4, delta is the +2 new cases both resolving TP). - Full `cargo test` green (1566 lib tests + all integration tests). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(taint): phase 5/5 lock Finding.primary_location contract via regression test Add a regression test in src/taint/ssa_transfer.rs that wires up a synthetic SsaFuncSummary with a SinkSite at other.rs:42:10 and drives the three emission stages (pick_primary_sink_sites → emit_ssa_taint_events → ssa_events_to_findings) against a minimal caller SSA body. Asserts the resulting Finding.primary_location is exactly that triple. The existing integration tests in src/taint/tests.rs cover the coarse FuncSummary path end-to-end through analyse_file. This test locks in the lower-level SSA-side plumbing so a future refactor that silently drops the site between pick → emit → findings fails here rather than only at the benchmark layer. Also refreshes tests/benchmark/results/latest.json (timestamp only; rs-cmdi-003 remains TP/TP/TP and the aggregate P/R/F1 are unchanged from phase 4). Closes the primary sink-location attribution feature (phases 1-5/5): * Phase 1 — SinkSite data model on summaries. * Phase 2 — SinkSite threaded into SsaTaintEvent and Finding. * Phase 3 — diag + SARIF consume primary_location. * Phase 4 — benchmark validates primary_call_site_lines across corpus. * Phase 5 — regression test locks the event→finding contract. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor: clean up formatting and improve readability in multiple files * refactor: simplify type definition for deduplication key in findings * test(harness): add must_not_match expectation for FP regression guards Extends ExpectedFinding with must_not_match field that asserts a diagnostic must NOT fire — presence is a hard failure. Non-consuming scan so it coexists with must_match entries on the same rule_id. Adds forbidden_violations accumulator and updates summary line. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(regression): update expectations to ensure must_not_match for various taint and resource leak rules * feat: implement auto-seeding for JS/TS handler parameters to enhance taint tracking * feat: update switch statement handling to improve control flow analysis * feat: implement promisify alias handling for JS/TS to enhance taint tracking * feat: enhance taint tracking by refining expectation handling and adding mode filtering * feat: refine SQL handling in stream processing and enhance auto-seeding for handler parameters * feat: update taint tracking rules to enforce full mode matching and improve flow analysis * feat: enhance Ruby subshell handling to improve taint tracking and flow analysis * feat: update xss_response expectations to refine taint flow analysis and enhance regression guarding * feat: refine framework detection and update expectation handling for Echo and Sinatra * feat: implement max_count for taint tracking expectations and deduplicate findings * feat: add strict_unexpected handling for taint-unsanitised-flow in expectation files * feat: enhance deduplication of taint-unsanitised-flow findings by collapsing based on line and severity * feat: add strict_unexpected handling for taint-unsanitised-flow in multiple expectation files * feat: add structural invariant checks for SSA bodies * feat: ensure deterministic phi emission order using BTreeSet * feat: enhance handling of terminators to ensure authoritative flow through successor edges * feat: enhance Goto terminator handling to ensure all successors are marked executable * feat: refactor code for improved readability and organization * feat: simplify predicate checks and enhance readability in SSA handling * feat: implement per-file parse timeout and enhance file size handling * feat: migrate analysis engine toggles from environment variables to configuration file * feat: remove unnecessary whitespace in hostile_input_tests.rs * feat: remove unnecessary whitespace in hostile_input_tests.rs * feat: update dependencies and enhance documentation on language maturity * feat: enhance security headers and improve request body limits * feat: implement sink capability bits for deduplication and enhance evidence tagging * feat: implement dynamic activation handling for gated sinks and enhance validation logic * feat: enhance configuration documentation and clarify inline analysis cache behavior * feat: implement panic recovery during analysis to continue scans past errors * feat: add expectations configuration for taint analysis and performance metrics * feat: enhance error handling and logging during file reading and mutex locking * feat: add cross-file body loading tests and plumbing for CF-1 phase * feat: implement cross-file k=1 context-sensitive inline taint analysis with new tests and fixtures * feat: implement indexed-scan parity in cross-file inline analysis with new dropdown and copy functionality * feat: enhance classification span handling in CFG and AST for improved source attribution * feat: add new Express routes for handling user input and telemetry data * feat: implement ternary expression handling in CFG with diamond structure for JS/TS * feat: implement Phase CF-3 abstract-domain transfer channels in summaries * feat: add support for string-prefix transfer in cross-file calls and update tests * docs: reduce RESULTS.md doc size * feat: implement Phase CF-4 per-return-path summary decomposition with tests * feat: update parameter handling in pass1 and refactor SsaFuncSummary initialization * feat: implement Phase CF-5 for cross-file SCC joint fixed-point convergence with new flags and tests * feat: implement Phase CF-6 with parameter-granularity points-to summaries and associated tests * refactor: update comments and documentation for clarity and consistency * style: format code for consistency and readability * refactor: simplify verdict handling and improve edge checking logic * refactor: optimize path and identifier collection by avoiding unnecessary cloning * chore: update Cargo.toml for Rust version 1.85 and add ignored files; modify CHANGELOG and README for clarity on state analysis defaults * refactor: update documentation and improve clarity in configuration files * refactor: update documentation and improve clarity in configuration files * feat: add JS/TS pass-2 convergence tests and expectations configuration * feat: add Phase 5 regression tests for inline cache origin attribution and update related logic * feat: implement Phase 7 deduplication and alternative path linking for taint findings * feat: implement structural DFS index for anonymous functions and update naming conventions * feat: add Phase 8 regression tests for container-element taint in JS and Python * feat: add engine-depth profiles and explain-engine option for CLI * feat: update expectations and add new README fixtures for multi-file scan regression * feat: implement Phase 11 callback-alias and factory patterns with regression tests * feat: implement Terminator::Switch for multi-way dispatch and add regression tests * feat: add real-CVE benchmark fixtures for CVE-2023-48022, CVE-2019-14939, and CVE-2023-26159 with corresponding patched variants * refactor: extract cfg and ssa_transfer to submodules * refactor: cargo fmt * refactor: remove unnecessary blank line in cfg_tests.rs * refactor: remove unnecessary planning file * chore: update Rust version to 1.88 and bump dependencies in Cargo files * feat: enhance triage UI with new layout and controls, update README for clarity * feat: enhance triage UI with new layout and controls, update README for clarity * chore: remove outdated section from README for version 0.5.0 * docs: improve clarity and consistency in README content * chore: add "GPL-3.0-or-later" to license options in about.toml * chore: update license handling in about.toml and check-licenses.mjs * style: format code for improved readability in TriagePage component * style: format code for improved readability in TriagePage component * chore: enhance license handling and improve body_id scoping in seed lookup * feat: introduce owner and parent body IDs for enhanced seed scoping * feat: implement direction-aware engine provenance with new CLI flag for strict CI gating * feat: add Undef SSA operation for improved control-flow handling * style: improve code formatting for consistency and readability in multiple files * feat: add 16-function chain SCC across multiple files for enhanced analysis * style: simplify code formatting for improved readability in multiple files * fix: update CapHitReason default implementation and improve README clarity * docs: enhance README with detailed explanations of taint analysis and limitations * docs: refine README for clarity and consistency in taint analysis section * style: improve code formatting for better readability in NewScanModal and scans * fix: update cargo-about command to use --offline for deterministic license generation * fix: update cargo-about command to use --offline for deterministic license generation * ci: add step to prime cargo registry cache for deterministic license generation * feat: add support for non-sink collections in authorization analysis * feat: enhance authorization checks with row-level ownership equality and binding tracking * feat: implement self-scoped user handling and enhance ownership checks * refactor: simplify assertions and formatting in authorization analysis tests * fix: normalize line endings in THIRDPARTY-LICENSES.html generation and update README with AI disclosure * docs: update AI disclosure section for clarity and conciseness * feat: add AI Contribution Policy and update contributing guidelines for AI assistance disclosure * feat: enhance authorization analysis with SSA-derived variable type classification * feat: implement auth_finding_to_diag function for enhanced security diagnostics * feat: add args_value_refs to CallSite struct for enhanced argument tracking * feat: add args_value_refs to CallSite struct for enhanced argument tracking * feat: add direction-aware engine provenance with LossDirection classification and new CLI flag * feat: simplify strip_cap_from_call_args call by removing unnecessary line breaks * feat: enhance error message handling in cli_validation_tests for better Windows compatibility * feat: optimize release profile settings in Cargo.toml and update CodeQL configuration * feat: enhance release build process with SBOM generation and SLSA provenance * feat: update actions/checkout and actions/setup-node to v6, enhance CLI options, and improve auth-check summaries * feat: introduce PathFact handling for path safety checks and rejection logic * feat: introduce PathFact handling for path safety checks and rejection logic * feat: update benchmark data and enhance path sanitization logic with new safety checks * feat: document AI assistance in frontend UI development and human review process * feat: add return path facts for enhanced path safety checks and update documentation * chore: update release date for version 0.5.0 in CHANGELOG.md * chore: clean up ci.yml by removing outdated comments and clarifying steps * feat: implement cross-language path sanitizers and validators for enhanced security * feat: enhance SSA value usage tracking by including block terminators and improve path safety checks * feat: enhance switch statement handling by adding per-case path constraints and support for exclusive cases * refactor: simplify conditional formatting and improve code readability in executor and lower modules * feat: add vulnerable examples for various languages demonstrating authentication and sanitization issues * feat: enhance actor context recognition for self-actor identifiers and add support for global non-sink receivers * feat: enhance actor context recognition for self-actor identifiers and add support for global non-sink receivers * feat: add transform classifiers for Java, Go, and Ruby with corresponding tests * refactor: clarify comments on reassign-to-constant idiom and sink behavior in guards.rs --------- Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 17:59:11 -04:00
//! Provenance notes attached to findings when the engine has hit an
//! internal budget, widening, or lowering cap.
//!
//! The notes are surfaced through `Finding.engine_notes` (and
//! `Evidence.engine_notes` once the finding reaches the `Diag` layer) so
//! downstream consumers can tell "we found nothing" from "we stopped
//! looking".
//!
//! Each note carries a [`LossDirection`] classification that describes
//! *how* the engine deviated from a fully-converged analysis. The
//! direction drives two downstream behaviours:
//!
//! * [`crate::evidence::compute_confidence`] caps confidence at
//! `Medium` when any attached note has direction
//! [`LossDirection::OverReport`] or [`LossDirection::Bail`] (the
//! finding itself may be spurious).
//! * [`crate::rank`] applies a direction-aware `completeness` penalty
//! to the attack-surface score (see `rank.rs::completeness_penalty`).
//!
//! This replaces the earlier Phase-3 stance of "notes are purely
//! additive and never influence score". A release audit flagged that
//! users sorting thousands of findings by rank could not distinguish
//! converged analysis from capped analysis, which produced false
//! confidence in fragile findings. The direction-aware pipeline
//! preserves the observability goal while fixing the credibility gap.
use serde::{Deserialize, Serialize};
use smallvec::SmallVec;
/// Classification of *why* a fix-point loop hit its safety cap.
///
/// The cap-hit alone is not actionable — "we ran 64 iterations and did
/// not detect convergence" can mean several very different things:
///
/// * the lattice is still shrinking but slowly (e.g. a 72-function chain
/// SCC that legitimately needs >64 iterations),
/// * the lattice stopped shrinking but the convergence predicate still
/// detects change (the change set stabilised at a non-zero value —
/// monotonicity is fine but something in the convergence predicate is
/// spurious), or
/// * the lattice is oscillating (two iterations alternating with the
/// same change-set size; this is a *bug*, not a tuning issue).
///
/// Recording the reason makes cap-hit telemetry actionable: operators
/// can tell when "raise the cap" would actually help vs. when they are
/// looking at a summary-non-monotonicity regression.
///
/// Serialized as a nested snake_case tagged enum so SARIF/JSON consumers
/// can pattern-match without depending on Rust layout.
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum CapHitReason {
/// The change-set size was still decreasing when the cap fired.
/// `trajectory` is the last N iteration deltas (most recent last).
/// Operators can safely raise the cap; the underlying analysis is
/// healthy but the SCC is larger than the current budget.
MonotoneShrinking { trajectory: SmallVec<[u32; 4]> },
/// The change-set size stayed constant for the last ≥2 iterations
/// without reaching zero. This is unusual: every iteration is
/// updating the *same* keys, which suggests a summary that changes
/// the same fields back and forth even though the cap bits are
/// saturating. Raise the cap **and** investigate.
Plateau { delta: u32 },
/// The change-set size oscillated with a detected period ≤ N/2.
/// Genuinely bad — the analysis is not monotone, convergence will
/// *never* be reached, and raising the cap will not help. File a
/// bug with the fixture attached.
SuspectedOscillation {
period: u8,
trajectory: SmallVec<[u32; 4]>,
},
/// Default when the engine did not record a trajectory (e.g. the
/// cap fired after only one iteration so there is nothing to
/// classify). Preserves backwards compatibility for old notes
/// deserialized from disk.
#[default]
Unknown,
}
impl CapHitReason {
/// Classify a trajectory of per-iteration change-set sizes.
///
/// `deltas` should carry the *changed-key counts* from the last N
/// iterations (most recent last). Classification rules:
///
/// 1. Fewer than 2 samples → `Unknown` (nothing to diff against).
/// 2. A period-2 pattern (a,b,a,b) with a ≠ b → `SuspectedOscillation`.
/// 3. Last two samples equal and non-zero → `Plateau`.
/// 4. Strictly decreasing tail → `MonotoneShrinking`.
/// 5. Otherwise → `Unknown` (inconclusive; rare in practice).
///
/// The function is pure — no allocation beyond the returned
/// [`SmallVec`] — so it is safe to call from within a hot loop when
/// a cap actually fires. Callers should accumulate deltas in a
/// fixed-size ring buffer to bound memory.
pub fn classify(deltas: &[u32]) -> CapHitReason {
if deltas.len() < 2 {
return CapHitReason::Unknown;
}
// Detect period-2 oscillation: last 4 samples as (a,b,a,b) with a ≠ b.
if deltas.len() >= 4 {
let n = deltas.len();
let (a0, b0, a1, b1) = (deltas[n - 4], deltas[n - 3], deltas[n - 2], deltas[n - 1]);
if a0 == a1 && b0 == b1 && a0 != b0 {
let tail = deltas
.iter()
.rev()
.take(4)
.rev()
.copied()
.collect::<SmallVec<[u32; 4]>>();
return CapHitReason::SuspectedOscillation {
period: 2,
trajectory: tail,
};
}
}
let last = deltas[deltas.len() - 1];
let prev = deltas[deltas.len() - 2];
// Plateau: change-set size stuck at the same non-zero value.
if last == prev && last > 0 {
return CapHitReason::Plateau { delta: last };
}
// Monotone shrinking: strictly decreasing over the full
// recorded tail. (Equal-zero at the end would have meant
// convergence, so the cap wouldn't have fired.)
let mut monotone = true;
for w in deltas.windows(2) {
if w[1] > w[0] {
monotone = false;
break;
}
}
if monotone {
let tail = deltas
.iter()
.rev()
.take(4)
.rev()
.copied()
.collect::<SmallVec<[u32; 4]>>();
return CapHitReason::MonotoneShrinking { trajectory: tail };
}
CapHitReason::Unknown
}
/// Stable snake-case tag for log/diag consumption.
pub fn tag(&self) -> &'static str {
match self {
CapHitReason::MonotoneShrinking { .. } => "monotone_shrinking",
CapHitReason::Plateau { .. } => "plateau",
CapHitReason::SuspectedOscillation { .. } => "suspected_oscillation",
CapHitReason::Unknown => "unknown",
}
}
}
/// Direction of precision loss encoded by an [`EngineNote`].
///
/// Every new [`EngineNote`] variant must declare a direction via
/// [`EngineNote::direction`] — the match is exhaustive by design so the
/// classification cannot silently default.
///
/// Ordering matters: variants are sorted by worsening impact on a
/// specific finding's credibility. [`combine`](Self::combine) uses the
/// `Ord` impl to merge directions when multiple notes are attached.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LossDirection {
/// The note is informational only. Analysis was fully converged;
/// the note records a harmless event such as a cache reuse.
Informational,
/// The analysis may have *missed* additional findings (e.g. the
/// worklist was capped before fully propagating taint). Findings
/// that *were* reported are still sound — they correspond to real
/// flows — but the result set is a lower bound.
UnderReport,
/// The analysis may have reported a *spurious* finding (e.g.
/// predicate state was widened to top, so a validation guard that
/// would have suppressed the finding was lost). The specific
/// finding is more likely to be a false positive than one produced
/// from converged state.
OverReport,
/// Analysis of this finding's body aborted before producing a
/// trustworthy result (e.g. SSA lowering bailed, parse timed out).
/// The finding is weakly supported; a human reviewer should treat
/// it as a starting point rather than a confirmed flow.
Bail,
}
impl LossDirection {
/// Merge two directions by taking the worse (later in `Ord`).
///
/// A body with both `UnderReport` and `OverReport` notes is treated
/// as `OverReport` because over-reporting is the more credibility-
/// damaging failure mode for a specific emitted finding.
pub fn combine(self, other: LossDirection) -> LossDirection {
self.max(other)
}
/// Snake-case tag used in console output and JSON properties.
pub fn tag(self) -> &'static str {
match self {
LossDirection::Informational => "informational",
LossDirection::UnderReport => "under-report",
LossDirection::OverReport => "over-report",
LossDirection::Bail => "bail",
}
}
}
/// A single provenance event recorded during analysis.
///
/// `kind` is serialized as a snake_case tag so tooling can pattern-match
/// across JSON and SARIF output without depending on Rust enum layout.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum EngineNote {
/// The taint worklist hit its iteration budget before converging.
/// Direction: [`LossDirection::UnderReport`] — the fixpoint was
/// aborted, so some flows may have been missed, but emitted flows
/// are still backed by propagated taint.
WorklistCapped { iterations: u32 },
/// Origin tracking was truncated when a value exceeded the configured
/// per-value origin cap (`analysis.engine.max_origins`, default 32).
/// Direction: [`LossDirection::UnderReport`] — each dropped origin
/// corresponds to a real source flow whose independent finding will
/// not be emitted. Other survivors still produce findings, so the
/// counter is a strict lower bound on under-reporting. Raise
/// `max_origins` if operators observe this note on realistic inputs.
/// Truncation is deterministic: origins are sorted by source
/// location and the largest-by-location are dropped first, so the
/// survivor set is stable across runs and merge orderings.
OriginsTruncated { dropped: u32 },
/// JS/TS pass-2 in-file global propagation hit its iteration cap.
/// Direction: [`LossDirection::UnderReport`] — global state may
/// not have reached fixpoint; cross-function flows could be missed.
///
/// `reason` classifies *why* the cap fired (monotone-but-slow,
/// plateau, suspected oscillation) so operators can tell a
/// tunable-budget problem from a monotonicity regression. Older
/// serialized notes without this field default to
/// [`CapHitReason::Unknown`].
InFileFixpointCapped {
iterations: u32,
#[serde(default)]
reason: CapHitReason,
},
/// Cross-file SCC fixpoint hit `SCC_FIXPOINT_SAFETY_CAP`.
/// Direction: [`LossDirection::UnderReport`] — the iterative
/// cross-file join aborted; summaries for members of this SCC may
/// be incomplete.
///
/// `reason` classifies *why* the cap fired (monotone-but-slow,
/// plateau, suspected oscillation) so operators can tell a
/// tunable-budget problem from a monotonicity regression. Older
/// serialized notes without this field default to
/// [`CapHitReason::Unknown`].
CrossFileFixpointCapped {
iterations: u32,
#[serde(default)]
reason: CapHitReason,
},
/// SSA lowering produced an empty body (parse failure or
/// unsupported shape). Direction: [`LossDirection::Bail`] — any
/// finding attributed to this body is weakly supported because the
/// IR itself is malformed.
SsaLoweringBailed { reason: String },
/// Tree-sitter parse exceeded the configured timeout.
/// Direction: [`LossDirection::Bail`] — parse aborted; findings
/// surfaced from the partial tree should be treated as a human-
/// review starting point.
ParseTimeout { timeout_ms: u32 },
/// Predicate state was widened to top to maintain monotonicity.
/// Direction: [`LossDirection::OverReport`] — validation guards
/// that would have suppressed the finding may have been lost, so
/// the finding is more likely to be a false positive.
PredicateStateWidened,
/// Path-environment constraints exceeded internal cap; widened to
/// top. Direction: [`LossDirection::OverReport`] — same reasoning
/// as [`Self::PredicateStateWidened`]: dropped path constraints can
/// only turn infeasible paths into apparent-feasible ones.
PathEnvCapped,
/// Inline cache reused a cached body summary; origins were
/// re-attributed. Direction: [`LossDirection::Informational`] —
/// the cache hit does not affect precision, but surfacing the
/// re-attribution helps explain why origin locations move between
/// runs that share a body signature.
InlineCacheReused,
/// Points-to analysis dropped heap object members when an
/// intra-procedural points-to set exceeded
/// `analysis.engine.max_pointsto` (default 32).
/// Direction: [`LossDirection::UnderReport`] — stores and loads
/// that flow through the truncated set miss the dropped abstract
/// heap objects, so any taint into those objects via this alias
/// path will not reach downstream sinks. Other aliasing paths to
/// the same objects still propagate normally, so the counter is a
/// strict lower bound on under-reporting. Raise `max_pointsto`
/// if operators observe this note on factory-heavy codebases.
PointsToTruncated { dropped: u32 },
}
impl EngineNote {
/// Classify this note by direction of precision loss.
///
/// The match is exhaustive: every `EngineNote` variant must declare
/// a direction. When adding a new cap site, pick the direction
/// that most honestly describes the impact on an emitted finding:
///
/// * `Informational` — analysis fully converged; note is a
/// provenance breadcrumb (e.g. cache reuse).
/// * `UnderReport` — analysis was cut short, but anything emitted
/// is still backed by real propagation.
/// * `OverReport` — precision was widened, so the emitted finding
/// is *more* likely to be a false positive than the baseline.
/// * `Bail` — analysis of this body aborted; the finding is weakly
/// supported.
pub fn direction(&self) -> LossDirection {
match self {
EngineNote::WorklistCapped { .. } => LossDirection::UnderReport,
EngineNote::OriginsTruncated { .. } => LossDirection::UnderReport,
EngineNote::InFileFixpointCapped { .. } => LossDirection::UnderReport,
EngineNote::CrossFileFixpointCapped { .. } => LossDirection::UnderReport,
EngineNote::SsaLoweringBailed { .. } => LossDirection::Bail,
EngineNote::ParseTimeout { .. } => LossDirection::Bail,
EngineNote::PredicateStateWidened => LossDirection::OverReport,
EngineNote::PathEnvCapped => LossDirection::OverReport,
EngineNote::InlineCacheReused => LossDirection::Informational,
EngineNote::PointsToTruncated { .. } => LossDirection::UnderReport,
}
}
/// True if this note indicates the engine may have deviated from a
/// fully-converged analysis (any non-informational direction).
///
/// This is a convenience over
/// `self.direction() != LossDirection::Informational` and drives
/// the `confidence_capped` SARIF property.
pub fn lowers_confidence(&self) -> bool {
self.direction() != LossDirection::Informational
}
}
/// Compute the worst direction across a slice of notes.
///
/// Returns `None` when `notes` is empty or contains only
/// [`LossDirection::Informational`] notes. Returns `Some(dir)` with
/// the most impactful direction otherwise — this is what downstream
/// consumers (rank, confidence) use to decide how to degrade a finding.
pub fn worst_direction(notes: &[EngineNote]) -> Option<LossDirection> {
let mut worst: Option<LossDirection> = None;
for note in notes {
let dir = note.direction();
if dir == LossDirection::Informational {
continue;
}
worst = Some(match worst {
Some(w) => w.combine(dir),
None => dir,
});
}
worst
}
/// Deduplicating push: does not append if an identical note is already
/// present. Used to keep per-finding note lists small when a cap site
/// fires repeatedly inside the same body.
pub fn push_unique(notes: &mut smallvec::SmallVec<[EngineNote; 2]>, note: EngineNote) {
if !notes.iter().any(|n| n == &note) {
notes.push(note);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn worklist_capped_lowers_confidence() {
assert!(EngineNote::WorklistCapped { iterations: 10 }.lowers_confidence());
}
#[test]
fn inline_cache_reused_does_not_lower_confidence() {
assert!(!EngineNote::InlineCacheReused.lowers_confidence());
}
#[test]
fn serialization_uses_snake_case_tag() {
let note = EngineNote::WorklistCapped { iterations: 7 };
let s = serde_json::to_string(&note).unwrap();
assert!(s.contains("\"kind\":\"worklist_capped\""));
assert!(s.contains("\"iterations\":7"));
}
#[test]
fn push_unique_deduplicates() {
let mut v = smallvec::SmallVec::<[EngineNote; 2]>::new();
push_unique(&mut v, EngineNote::WorklistCapped { iterations: 1 });
push_unique(&mut v, EngineNote::WorklistCapped { iterations: 1 });
push_unique(&mut v, EngineNote::OriginsTruncated { dropped: 2 });
assert_eq!(v.len(), 2);
}
#[test]
fn direction_classification_is_exhaustive() {
// Budget caps ⇒ under-report: fixpoint aborted, results still sound.
assert_eq!(
EngineNote::WorklistCapped { iterations: 1 }.direction(),
LossDirection::UnderReport
);
assert_eq!(
EngineNote::OriginsTruncated { dropped: 1 }.direction(),
LossDirection::UnderReport
);
assert_eq!(
EngineNote::InFileFixpointCapped {
iterations: 1,
reason: CapHitReason::Unknown,
}
.direction(),
LossDirection::UnderReport
);
assert_eq!(
EngineNote::CrossFileFixpointCapped {
iterations: 1,
reason: CapHitReason::Unknown,
}
.direction(),
LossDirection::UnderReport
);
assert_eq!(
EngineNote::PointsToTruncated { dropped: 1 }.direction(),
LossDirection::UnderReport
);
// Widening ⇒ over-report: validation guards may have been lost.
assert_eq!(
EngineNote::PredicateStateWidened.direction(),
LossDirection::OverReport
);
assert_eq!(
EngineNote::PathEnvCapped.direction(),
LossDirection::OverReport
);
// Hard aborts ⇒ bail: IR or parse failed.
assert_eq!(
EngineNote::SsaLoweringBailed { reason: "x".into() }.direction(),
LossDirection::Bail
);
assert_eq!(
EngineNote::ParseTimeout { timeout_ms: 1 }.direction(),
LossDirection::Bail
);
// Informational ⇒ no credibility impact.
assert_eq!(
EngineNote::InlineCacheReused.direction(),
LossDirection::Informational
);
}
#[test]
fn loss_direction_order_is_worst_last() {
// combine() takes the max, so Bail must dominate OverReport must
// dominate UnderReport must dominate Informational.
assert!(LossDirection::Bail > LossDirection::OverReport);
assert!(LossDirection::OverReport > LossDirection::UnderReport);
assert!(LossDirection::UnderReport > LossDirection::Informational);
}
#[test]
fn combine_takes_the_worse_direction() {
assert_eq!(
LossDirection::UnderReport.combine(LossDirection::OverReport),
LossDirection::OverReport
);
assert_eq!(
LossDirection::OverReport.combine(LossDirection::UnderReport),
LossDirection::OverReport
);
assert_eq!(
LossDirection::Bail.combine(LossDirection::OverReport),
LossDirection::Bail
);
assert_eq!(
LossDirection::Informational.combine(LossDirection::Informational),
LossDirection::Informational
);
}
#[test]
fn worst_direction_empty_is_none() {
let notes: Vec<EngineNote> = vec![];
assert_eq!(worst_direction(&notes), None);
}
#[test]
fn worst_direction_informational_only_is_none() {
let notes = vec![EngineNote::InlineCacheReused, EngineNote::InlineCacheReused];
assert_eq!(worst_direction(&notes), None);
}
#[test]
fn worst_direction_mixed_picks_worst() {
let notes = vec![
EngineNote::InlineCacheReused,
EngineNote::WorklistCapped { iterations: 1 },
EngineNote::PredicateStateWidened,
];
assert_eq!(worst_direction(&notes), Some(LossDirection::OverReport));
}
#[test]
fn worst_direction_bail_dominates() {
let notes = vec![
EngineNote::PredicateStateWidened,
EngineNote::ParseTimeout { timeout_ms: 100 },
];
assert_eq!(worst_direction(&notes), Some(LossDirection::Bail));
}
#[test]
fn cap_hit_reason_too_few_samples_unknown() {
assert_eq!(CapHitReason::classify(&[]), CapHitReason::Unknown);
assert_eq!(CapHitReason::classify(&[5]), CapHitReason::Unknown);
}
#[test]
fn cap_hit_reason_detects_period_2_oscillation() {
let result = CapHitReason::classify(&[3, 7, 3, 7]);
match result {
CapHitReason::SuspectedOscillation { period, .. } => assert_eq!(period, 2),
other => panic!("expected SuspectedOscillation; got {other:?}"),
}
}
#[test]
fn cap_hit_reason_detects_plateau() {
let result = CapHitReason::classify(&[10, 5, 5]);
assert_eq!(result, CapHitReason::Plateau { delta: 5 });
}
#[test]
fn cap_hit_reason_plateau_at_zero_is_not_a_plateau() {
// Zero-delta means we converged; classifier should not flag.
let result = CapHitReason::classify(&[3, 0, 0]);
// Strictly decreasing tail → monotone-shrinking; not plateau.
match result {
CapHitReason::MonotoneShrinking { .. } => {}
other => panic!("expected MonotoneShrinking; got {other:?}"),
}
}
#[test]
fn cap_hit_reason_detects_monotone_shrinking() {
let result = CapHitReason::classify(&[10, 7, 4, 2]);
match result {
CapHitReason::MonotoneShrinking { trajectory } => {
assert_eq!(trajectory.as_slice(), &[10, 7, 4, 2]);
}
other => panic!("expected MonotoneShrinking; got {other:?}"),
}
}
#[test]
fn cap_hit_reason_non_monotone_non_oscillating_is_unknown() {
// Goes up then down without a clean period-2 pattern.
let result = CapHitReason::classify(&[3, 8, 2]);
assert_eq!(result, CapHitReason::Unknown);
}
#[test]
fn cap_hit_reason_serializes_snake_case_tag() {
let r = CapHitReason::Plateau { delta: 4 };
let s = serde_json::to_string(&r).unwrap();
assert!(s.contains("\"kind\":\"plateau\""), "got {s}");
assert!(s.contains("\"delta\":4"), "got {s}");
}
#[test]
fn in_file_fixpoint_capped_serde_backcompat() {
// Older serialized notes without the `reason` field must still
// deserialize (serde(default) → CapHitReason::Unknown).
let legacy = r#"{"kind":"in_file_fixpoint_capped","iterations":7}"#;
let parsed: EngineNote = serde_json::from_str(legacy).unwrap();
match parsed {
EngineNote::InFileFixpointCapped { iterations, reason } => {
assert_eq!(iterations, 7);
assert_eq!(reason, CapHitReason::Unknown);
}
other => panic!("expected InFileFixpointCapped; got {other:?}"),
}
}
#[test]
fn cross_file_fixpoint_capped_serde_backcompat() {
let legacy = r#"{"kind":"cross_file_fixpoint_capped","iterations":64}"#;
let parsed: EngineNote = serde_json::from_str(legacy).unwrap();
match parsed {
EngineNote::CrossFileFixpointCapped { iterations, reason } => {
assert_eq!(iterations, 64);
assert_eq!(reason, CapHitReason::Unknown);
}
other => panic!("expected CrossFileFixpointCapped; got {other:?}"),
}
}
#[test]
fn loss_direction_tag_stable() {
assert_eq!(LossDirection::UnderReport.tag(), "under-report");
assert_eq!(LossDirection::OverReport.tag(), "over-report");
assert_eq!(LossDirection::Bail.tag(), "bail");
assert_eq!(LossDirection::Informational.tag(), "informational");
}
}