mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-06 19:35:13 +02:00
* refactor: Update comments for clarity and add expectations.json files for performance metrics * feat: Implement FP guard for JS/TS local-collection receivers to suppress missing ownership checks * feat: Enhance Rust parameter handling to classify local collections and prevent false ownership checks * refactor: Simplify code formatting for better readability in multiple files * refactor: Improve UTF-8 sequence length handling and enhance clarity in loop iteration * feat: Update Java and Python patterns to include new security rules * refactor: Improve comment clarity and consistency across multiple Rust files * refactor: Simplify code formatting for improved readability in integration tests and module files * refactor: Improve comment formatting and enhance clarity in assertions across multiple files
408 lines
14 KiB
Rust
408 lines
14 KiB
Rust
//! Per-parameter [`AbstractTransfer`] channel unit tests.
|
|
//!
|
|
//! Covers three correctness surfaces:
|
|
//! * Serde round-trip for every transfer form (so DB-persisted summaries
|
|
//! are stable across restart).
|
|
//! * Direct `apply` behaviour on [`IntervalFact`] / [`StringFact`] inputs
|
|
//! (so the caller-side synthesis of a return abstract value is
|
|
//! predictable).
|
|
//! * Join semantics when multiple return paths or multiple parameters
|
|
//! contribute competing transforms.
|
|
//!
|
|
//! The pass-1 extraction and pass-2 call-site application integration are
|
|
//! covered by the fixture-driven integration tests in
|
|
//! `tests/fixtures/cross_file_abstract/` exercised through the main scan
|
|
//! harness; unit tests here exercise the primitives in isolation.
|
|
|
|
use nyx_scanner::abstract_interp::{
|
|
AbstractTransfer, AbstractValue, BitFact, IntervalFact, IntervalTransfer,
|
|
MAX_LITERAL_PREFIX_LEN, PathFact, StringFact, StringTransfer,
|
|
};
|
|
|
|
// ── Serde round-trip ───────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn serde_round_trip_default_top() {
|
|
let t = AbstractTransfer::default();
|
|
assert!(t.is_top());
|
|
let json = serde_json::to_string(&t).unwrap();
|
|
// Top is the default; serialisation skips both subdomain fields.
|
|
assert_eq!(json, "{}");
|
|
let back: AbstractTransfer = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(back, t);
|
|
assert!(back.is_top());
|
|
}
|
|
|
|
#[test]
|
|
fn serde_round_trip_interval_identity() {
|
|
let t = AbstractTransfer {
|
|
interval: IntervalTransfer::Identity,
|
|
string: StringTransfer::Unknown,
|
|
};
|
|
let json = serde_json::to_string(&t).unwrap();
|
|
let back: AbstractTransfer = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(back, t);
|
|
}
|
|
|
|
#[test]
|
|
fn serde_round_trip_interval_affine() {
|
|
let t = AbstractTransfer {
|
|
interval: IntervalTransfer::Affine { add: -7, mul: 3 },
|
|
string: StringTransfer::Unknown,
|
|
};
|
|
let json = serde_json::to_string(&t).unwrap();
|
|
let back: AbstractTransfer = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(back, t);
|
|
}
|
|
|
|
#[test]
|
|
fn serde_round_trip_interval_clamped() {
|
|
let t = AbstractTransfer {
|
|
interval: IntervalTransfer::Clamped {
|
|
lo: 1024,
|
|
hi: 65535,
|
|
},
|
|
string: StringTransfer::Unknown,
|
|
};
|
|
let json = serde_json::to_string(&t).unwrap();
|
|
let back: AbstractTransfer = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(back, t);
|
|
}
|
|
|
|
#[test]
|
|
fn serde_round_trip_string_identity() {
|
|
let t = AbstractTransfer {
|
|
interval: IntervalTransfer::Top,
|
|
string: StringTransfer::Identity,
|
|
};
|
|
let json = serde_json::to_string(&t).unwrap();
|
|
let back: AbstractTransfer = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(back, t);
|
|
}
|
|
|
|
#[test]
|
|
fn serde_round_trip_string_literal_prefix() {
|
|
let t = AbstractTransfer {
|
|
interval: IntervalTransfer::Top,
|
|
string: StringTransfer::LiteralPrefix("https://internal.example.com/".into()),
|
|
};
|
|
let json = serde_json::to_string(&t).unwrap();
|
|
let back: AbstractTransfer = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(back, t);
|
|
}
|
|
|
|
#[test]
|
|
fn serde_round_trip_combined() {
|
|
let t = AbstractTransfer {
|
|
interval: IntervalTransfer::Clamped { lo: 0, hi: 10 },
|
|
string: StringTransfer::LiteralPrefix("safe-".into()),
|
|
};
|
|
let json = serde_json::to_string(&t).unwrap();
|
|
let back: AbstractTransfer = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(back, t);
|
|
}
|
|
|
|
#[test]
|
|
fn deserialize_legacy_json_missing_fields() {
|
|
// An older SSA summary may carry no `abstract_transfer` entries;
|
|
// the parent's serde(default) handles that. The transfer record
|
|
// itself must also tolerate both fields missing (reducing to
|
|
// Top/Unknown).
|
|
let back: AbstractTransfer = serde_json::from_str("{}").unwrap();
|
|
assert!(back.is_top());
|
|
}
|
|
|
|
// ── Interval transfer apply ────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn interval_identity_forwards_input() {
|
|
let t = IntervalTransfer::Identity;
|
|
let input = IntervalFact {
|
|
lo: Some(42),
|
|
hi: Some(100),
|
|
};
|
|
let out = t.apply(&input);
|
|
assert_eq!(out.lo, Some(42));
|
|
assert_eq!(out.hi, Some(100));
|
|
}
|
|
|
|
#[test]
|
|
fn interval_top_produces_top() {
|
|
let t = IntervalTransfer::Top;
|
|
let input = IntervalFact::exact(5);
|
|
assert!(t.apply(&input).is_top());
|
|
}
|
|
|
|
#[test]
|
|
fn interval_affine_applies() {
|
|
// out = in * 2 + 3; input = [1, 5] → output = [5, 13]
|
|
let t = IntervalTransfer::Affine { add: 3, mul: 2 };
|
|
let input = IntervalFact {
|
|
lo: Some(1),
|
|
hi: Some(5),
|
|
};
|
|
let out = t.apply(&input);
|
|
assert_eq!(out.lo, Some(5));
|
|
assert_eq!(out.hi, Some(13));
|
|
}
|
|
|
|
#[test]
|
|
fn interval_clamped_ignores_input() {
|
|
let t = IntervalTransfer::Clamped {
|
|
lo: 1024,
|
|
hi: 65535,
|
|
};
|
|
// Caller argument is unknown; transfer still produces the bound.
|
|
let out = t.apply(&IntervalFact::top());
|
|
assert_eq!(out.lo, Some(1024));
|
|
assert_eq!(out.hi, Some(65535));
|
|
assert!(out.is_proven_bounded());
|
|
}
|
|
|
|
#[test]
|
|
fn interval_clamped_reverse_bounds_fall_back_to_top() {
|
|
// Malformed Clamped with lo > hi must not produce a bottom/empty
|
|
// interval (which would be incorrectly-refuted). Degrade to Top
|
|
// instead so caller-side meet stays sound.
|
|
let t = IntervalTransfer::Clamped { lo: 10, hi: 5 };
|
|
assert!(t.apply(&IntervalFact::top()).is_top());
|
|
}
|
|
|
|
// ── Interval transfer join ─────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn interval_join_same_identity() {
|
|
let a = IntervalTransfer::Identity;
|
|
let b = IntervalTransfer::Identity;
|
|
assert_eq!(a.join(&b), IntervalTransfer::Identity);
|
|
}
|
|
|
|
#[test]
|
|
fn interval_join_clamped_widens_range() {
|
|
let a = IntervalTransfer::Clamped { lo: 0, hi: 10 };
|
|
let b = IntervalTransfer::Clamped { lo: 5, hi: 20 };
|
|
assert_eq!(a.join(&b), IntervalTransfer::Clamped { lo: 0, hi: 20 });
|
|
}
|
|
|
|
#[test]
|
|
fn interval_join_identity_vs_clamped_is_top() {
|
|
// Different flow shapes cannot be combined into a single bounded
|
|
// form, conservative fallback is Top.
|
|
let a = IntervalTransfer::Identity;
|
|
let b = IntervalTransfer::Clamped { lo: 0, hi: 10 };
|
|
assert_eq!(a.join(&b), IntervalTransfer::Top);
|
|
}
|
|
|
|
#[test]
|
|
fn interval_join_top_absorbs() {
|
|
let a = IntervalTransfer::Top;
|
|
let b = IntervalTransfer::Clamped { lo: 0, hi: 10 };
|
|
assert_eq!(a.join(&b), IntervalTransfer::Top);
|
|
assert_eq!(b.join(&a), IntervalTransfer::Top);
|
|
}
|
|
|
|
// ── String transfer apply ──────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn string_identity_forwards_input() {
|
|
let t = StringTransfer::Identity;
|
|
let input = StringFact::from_prefix("http://x.com/");
|
|
let out = t.apply(&input);
|
|
assert_eq!(out.prefix.as_deref(), Some("http://x.com/"));
|
|
}
|
|
|
|
#[test]
|
|
fn string_unknown_produces_top() {
|
|
let t = StringTransfer::Unknown;
|
|
assert!(t.apply(&StringFact::exact("a")).is_top());
|
|
}
|
|
|
|
#[test]
|
|
fn string_literal_prefix_ignores_input() {
|
|
let t = StringTransfer::LiteralPrefix("https://safe.example.com/".into());
|
|
let out = t.apply(&StringFact::top());
|
|
assert_eq!(out.prefix.as_deref(), Some("https://safe.example.com/"));
|
|
}
|
|
|
|
#[test]
|
|
fn string_literal_prefix_truncates_oversized() {
|
|
let long = "a".repeat(MAX_LITERAL_PREFIX_LEN + 50);
|
|
let t = StringTransfer::literal_prefix(&long);
|
|
match &t {
|
|
StringTransfer::LiteralPrefix(p) => {
|
|
assert!(
|
|
p.len() <= MAX_LITERAL_PREFIX_LEN,
|
|
"constructor must enforce the size cap"
|
|
);
|
|
}
|
|
_ => panic!("expected LiteralPrefix, got {:?}", t),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn string_literal_prefix_empty_degrades_to_unknown() {
|
|
assert_eq!(StringTransfer::literal_prefix(""), StringTransfer::Unknown);
|
|
}
|
|
|
|
// ── String transfer join ───────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn string_join_same_literal_prefix() {
|
|
let a = StringTransfer::LiteralPrefix("https://safe.com/".into());
|
|
let b = StringTransfer::LiteralPrefix("https://safe.com/".into());
|
|
assert_eq!(a.join(&b), a);
|
|
}
|
|
|
|
#[test]
|
|
fn string_join_shared_prefix_keeps_lcp() {
|
|
let a = StringTransfer::LiteralPrefix("https://safe.com/a".into());
|
|
let b = StringTransfer::LiteralPrefix("https://safe.com/b".into());
|
|
match a.join(&b) {
|
|
StringTransfer::LiteralPrefix(p) => assert_eq!(p, "https://safe.com/"),
|
|
other => panic!("expected LCP; got {:?}", other),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn string_join_disjoint_prefix_is_unknown() {
|
|
let a = StringTransfer::LiteralPrefix("https://".into());
|
|
let b = StringTransfer::LiteralPrefix("file://".into());
|
|
// LCP is empty → widen to Unknown.
|
|
assert_eq!(a.join(&b), StringTransfer::Unknown);
|
|
}
|
|
|
|
#[test]
|
|
fn string_join_identity_vs_prefix_is_unknown() {
|
|
let a = StringTransfer::Identity;
|
|
let b = StringTransfer::LiteralPrefix("x".into());
|
|
assert_eq!(a.join(&b), StringTransfer::Unknown);
|
|
}
|
|
|
|
// ── AbstractTransfer.apply composition ─────────────────────────────────
|
|
|
|
#[test]
|
|
fn transfer_apply_combines_subdomains() {
|
|
let t = AbstractTransfer {
|
|
interval: IntervalTransfer::Identity,
|
|
string: StringTransfer::LiteralPrefix("https://safe.com/".into()),
|
|
};
|
|
let input = AbstractValue {
|
|
interval: IntervalFact::exact(8080),
|
|
string: StringFact::from_prefix("http://untrusted/"),
|
|
bits: BitFact::top(),
|
|
path: PathFact::top(),
|
|
};
|
|
let out = t.apply(&input);
|
|
// Interval identity forwards the caller-known bound.
|
|
assert_eq!(out.interval.lo, Some(8080));
|
|
assert_eq!(out.interval.hi, Some(8080));
|
|
// String literal-prefix overrides the caller-side input, the
|
|
// callee's structural fact wins.
|
|
assert_eq!(out.string.prefix.as_deref(), Some("https://safe.com/"));
|
|
// Bit subdomain is always Top on cross-file transfer by design.
|
|
assert!(out.bits.is_top());
|
|
}
|
|
|
|
#[test]
|
|
fn transfer_join_combines_subdomains() {
|
|
let a = AbstractTransfer {
|
|
interval: IntervalTransfer::Clamped { lo: 0, hi: 100 },
|
|
string: StringTransfer::LiteralPrefix("abc".into()),
|
|
};
|
|
let b = AbstractTransfer {
|
|
interval: IntervalTransfer::Clamped { lo: 50, hi: 200 },
|
|
string: StringTransfer::LiteralPrefix("abd".into()),
|
|
};
|
|
let j = a.join(&b);
|
|
assert_eq!(j.interval, IntervalTransfer::Clamped { lo: 0, hi: 200 });
|
|
assert_eq!(j.string, StringTransfer::LiteralPrefix("ab".into()));
|
|
}
|
|
|
|
// ── Pass-1 structural identity detection via scan harness ──────────────
|
|
//
|
|
// Drives a minimal two-file Python fixture through the fused pass-1
|
|
// extraction and checks that the identity-passthrough callee's summary
|
|
// carries an `AbstractTransfer::Identity` entry for its sole parameter.
|
|
// End-to-end rather than unit-level because the extraction depends on
|
|
// the real tree-sitter + SSA lowering pipeline.
|
|
|
|
use nyx_scanner::ast::analyse_file_fused;
|
|
use nyx_scanner::summary::GlobalSummaries;
|
|
use nyx_scanner::symbol::Lang;
|
|
use nyx_scanner::utils::config::{AnalysisMode, Config};
|
|
use std::path::Path;
|
|
|
|
fn test_config() -> Config {
|
|
let mut cfg = Config::default();
|
|
cfg.scanner.mode = AnalysisMode::Full;
|
|
cfg.scanner.read_vcsignore = false;
|
|
cfg.scanner.require_git_to_read_vcsignore = false;
|
|
cfg.scanner.enable_state_analysis = true;
|
|
cfg.scanner.enable_auth_analysis = true;
|
|
cfg.performance.worker_threads = Some(1);
|
|
cfg.performance.batch_size = 64;
|
|
cfg.performance.channel_multiplier = 1;
|
|
cfg
|
|
}
|
|
|
|
fn pass1(root: &Path, paths: &[std::path::PathBuf], cfg: &Config) -> GlobalSummaries {
|
|
let root_str = root.to_string_lossy();
|
|
let mut gs = GlobalSummaries::new();
|
|
for path in paths {
|
|
let bytes = std::fs::read(path).expect("fixture read");
|
|
let r = analyse_file_fused(&bytes, path, cfg, None, Some(root))
|
|
.expect("analyse_file_fused should succeed on a well-formed fixture");
|
|
for s in r.summaries {
|
|
let key = s.func_key(Some(&root_str));
|
|
gs.insert(key, s);
|
|
}
|
|
for (key, ssa) in r.ssa_summaries {
|
|
gs.insert_ssa(key, ssa);
|
|
}
|
|
for (key, body) in r.ssa_bodies {
|
|
gs.insert_body(key, body);
|
|
}
|
|
}
|
|
gs
|
|
}
|
|
|
|
#[test]
|
|
fn passthrough_callee_gets_identity_transfer() {
|
|
let tmp = tempfile::tempdir().expect("tempdir");
|
|
let root = tmp.path();
|
|
|
|
// Trivial passthrough: `fn passthrough(x): return x`.
|
|
let a_py = root.join("a.py");
|
|
std::fs::write(&a_py, "def passthrough(x):\n return x\n").expect("write a.py");
|
|
|
|
let cfg = test_config();
|
|
let gs = pass1(root, std::slice::from_ref(&a_py), &cfg);
|
|
|
|
let (_, summary) = gs
|
|
.snapshot_ssa()
|
|
.iter()
|
|
.find(|(k, _)| k.lang == Lang::Python && k.name == "passthrough")
|
|
.expect("SSA summary for passthrough");
|
|
|
|
// Exactly one entry; parameter 0 with Identity interval + string.
|
|
assert_eq!(
|
|
summary.abstract_transfer.len(),
|
|
1,
|
|
"passthrough has one param; got {:?}",
|
|
summary.abstract_transfer
|
|
);
|
|
let (idx, t) = &summary.abstract_transfer[0];
|
|
assert_eq!(*idx, 0);
|
|
assert_eq!(
|
|
t.interval,
|
|
IntervalTransfer::Identity,
|
|
"passthrough must produce Identity interval; got {:?}",
|
|
t
|
|
);
|
|
assert_eq!(
|
|
t.string,
|
|
StringTransfer::Identity,
|
|
"passthrough must produce Identity string; got {:?}",
|
|
t
|
|
);
|
|
}
|