use crate::state::lattice::{AbstractDomain, Lattice};
use serde::{Deserialize, Serialize};
pub const MAX_PREFIX_LOCK_LEN: usize = 128;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Tri {
No,
Yes,
Maybe,
}
impl Tri {
pub fn top() -> Self {
Tri::Maybe
}
pub fn is_top(&self) -> bool {
matches!(self, Tri::Maybe)
}
pub fn join(&self, other: &Self) -> Self {
match (*self, *other) {
(a, b) if a == b => a,
_ => Tri::Maybe,
}
}
pub fn meet_checked(&self, other: &Self) -> Option<Self> {
match (*self, *other) {
(Tri::Maybe, x) | (x, Tri::Maybe) => Some(x),
(a, b) if a == b => Some(a),
_ => None,
}
}
pub fn widen(&self, other: &Self) -> Self {
if self == other { *self } else { Tri::Maybe }
}
pub fn leq(&self, other: &Self) -> bool {
match (*self, *other) {
(_, Tri::Maybe) => true,
(a, b) => a == b,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct PathFact {
pub dotdot: Tri,
pub absolute: Tri,
pub normalized: Tri,
pub prefix_lock: Option<String>,
is_bottom: bool,
}
impl Default for PathFact {
fn default() -> Self {
Self::top()
}
}
impl PathFact {
pub fn top() -> Self {
Self {
dotdot: Tri::Maybe,
absolute: Tri::Maybe,
normalized: Tri::Maybe,
prefix_lock: None,
is_bottom: false,
}
}
pub fn bottom() -> Self {
Self {
dotdot: Tri::Maybe,
absolute: Tri::Maybe,
normalized: Tri::Maybe,
prefix_lock: None,
is_bottom: true,
}
}
pub fn is_top(&self) -> bool {
!self.is_bottom
&& self.dotdot == Tri::Maybe
&& self.absolute == Tri::Maybe
&& self.normalized == Tri::Maybe
&& self.prefix_lock.is_none()
}
pub fn is_bottom(&self) -> bool {
self.is_bottom
}
pub fn with_dotdot_cleared(mut self) -> Self {
self.dotdot = Tri::No;
self
}
pub fn with_absolute_cleared(mut self) -> Self {
self.absolute = Tri::No;
self
}
pub fn with_normalized(mut self) -> Self {
self.normalized = Tri::Yes;
self.dotdot = Tri::No;
self
}
pub fn with_prefix_lock(mut self, root: &str) -> Self {
if root.is_empty() {
return self;
}
self.prefix_lock = Some(truncate_prefix_lock(root));
self
}
pub fn is_path_safe(&self) -> bool {
!self.is_bottom && self.dotdot == Tri::No && self.absolute == Tri::No
}
pub fn is_path_traversal_safe(&self) -> bool {
if self.is_bottom || self.dotdot != Tri::No {
return false;
}
self.absolute == Tri::No || self.prefix_lock.is_some()
}
pub fn prefix_locked_under(&self, root: &str) -> bool {
match &self.prefix_lock {
Some(p) => p.starts_with(root) || root.starts_with(p.as_str()),
None => false,
}
}
pub fn join(&self, other: &Self) -> Self {
if self.is_bottom {
return other.clone();
}
if other.is_bottom {
return self.clone();
}
let prefix_lock = match (&self.prefix_lock, &other.prefix_lock) {
(Some(a), Some(b)) => {
let lcp = longest_common_prefix(a, b);
if lcp.is_empty() {
None
} else {
Some(truncate_prefix_lock(&lcp))
}
}
_ => None,
};
Self {
dotdot: self.dotdot.join(&other.dotdot),
absolute: self.absolute.join(&other.absolute),
normalized: self.normalized.join(&other.normalized),
prefix_lock,
is_bottom: false,
}
}
pub fn meet(&self, other: &Self) -> Self {
if self.is_bottom || other.is_bottom {
return Self::bottom();
}
let (dotdot, abs, norm) = match (
self.dotdot.meet_checked(&other.dotdot),
self.absolute.meet_checked(&other.absolute),
self.normalized.meet_checked(&other.normalized),
) {
(Some(a), Some(b), Some(c)) => (a, b, c),
_ => return Self::bottom(),
};
let prefix_lock = match (&self.prefix_lock, &other.prefix_lock) {
(Some(a), Some(b)) => {
if a.starts_with(b.as_str()) {
Some(a.clone())
} else if b.starts_with(a.as_str()) {
Some(b.clone())
} else {
return Self::bottom();
}
}
(Some(a), None) => Some(a.clone()),
(None, Some(b)) => Some(b.clone()),
(None, None) => None,
};
Self {
dotdot,
absolute: abs,
normalized: norm,
prefix_lock,
is_bottom: false,
}
}
pub fn widen(&self, other: &Self) -> Self {
if self.is_bottom {
return other.clone();
}
if other.is_bottom {
return self.clone();
}
let prefix_lock = if self.prefix_lock == other.prefix_lock {
self.prefix_lock.clone()
} else {
None
};
Self {
dotdot: self.dotdot.widen(&other.dotdot),
absolute: self.absolute.widen(&other.absolute),
normalized: self.normalized.widen(&other.normalized),
prefix_lock,
is_bottom: false,
}
}
pub fn leq(&self, other: &Self) -> bool {
if self.is_bottom {
return true;
}
if other.is_bottom {
return false;
}
let prefix_ok = match (&self.prefix_lock, &other.prefix_lock) {
(_, None) => true,
(None, Some(_)) => false,
(Some(a), Some(b)) => a.starts_with(b.as_str()),
};
prefix_ok
&& self.dotdot.leq(&other.dotdot)
&& self.absolute.leq(&other.absolute)
&& self.normalized.leq(&other.normalized)
}
}
impl Lattice for PathFact {
fn bot() -> Self {
Self::bottom()
}
fn join(&self, other: &Self) -> Self {
self.join(other)
}
fn leq(&self, other: &Self) -> bool {
self.leq(other)
}
}
impl AbstractDomain for PathFact {
fn top() -> Self {
Self::top()
}
fn meet(&self, other: &Self) -> Self {
self.meet(other)
}
fn widen(&self, other: &Self) -> Self {
self.widen(other)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PathRejection {
DotDot,
AbsoluteSlash,
IsAbsolute,
None,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PathAssertion {
PrefixLock(String),
None,
}
pub const OPAQUE_PREFIX_LOCK: &str = "__nyx_opaque_prefix__";
pub fn classify_path_rejection(text: &str) -> PathRejection {
let trimmed = text.trim();
if trimmed.is_empty() {
return PathRejection::None;
}
let axes = classify_path_rejection_axes(trimmed);
if axes.is_empty() {
return PathRejection::None;
}
axes[0]
}
pub fn classify_path_rejection_axes(text: &str) -> smallvec::SmallVec<[PathRejection; 3]> {
let mut out: smallvec::SmallVec<[PathRejection; 3]> = smallvec::SmallVec::new();
for clause in split_top_level_or(text) {
let clause = clause.trim();
if has_negated_filepath_is_local(clause) {
for axis in [PathRejection::DotDot, PathRejection::IsAbsolute] {
if !out.contains(&axis) {
out.push(axis);
}
}
continue;
}
let cls = classify_path_rejection_atom(clause);
if !matches!(cls, PathRejection::None) && !out.contains(&cls) {
out.push(cls);
}
}
out
}
pub(crate) fn cond_has_pre_negated_islocal_clause(text: &str) -> bool {
for clause in split_top_level_or(text) {
if has_negated_filepath_is_local(clause.trim()) {
return true;
}
}
false
}
fn has_negated_filepath_is_local(clause: &str) -> bool {
let trimmed = clause.trim();
let inner = trimmed
.strip_prefix('(')
.and_then(|s| s.strip_suffix(')'))
.unwrap_or(trimmed)
.trim();
let after_not = match inner.strip_prefix('!') {
Some(rest) => rest.trim_start(),
None => return false,
};
let compact: String = after_not.chars().filter(|c| !c.is_whitespace()).collect();
compact.starts_with("filepath.IsLocal(")
}
fn classify_path_rejection_atom(clause: &str) -> PathRejection {
if let Some(needle) = extract_contains_arg(clause)
&& needle == ".."
{
return PathRejection::DotDot;
}
if has_python_dotdot_in(clause) {
return PathRejection::DotDot;
}
if let Some(needle) = extract_starts_with_arg(clause)
&& (needle == "/" || needle == "\\")
{
return PathRejection::AbsoluteSlash;
}
if clause.contains(".is_absolute()")
|| clause.contains(".isAbsolute()")
|| clause.contains("os.path.isabs(")
|| clause.contains("filepath.IsAbs(")
{
return PathRejection::IsAbsolute;
}
if has_first_char_absolute_check(clause) {
return PathRejection::AbsoluteSlash;
}
PathRejection::None
}
fn has_first_char_absolute_check(clause: &str) -> bool {
let bytes = clause.as_bytes();
let mut i = 0usize;
while i + 2 < bytes.len() {
if bytes[i] == b'[' && bytes[i + 1] == b'0' && bytes[i + 2] == b']' {
let lo = i.saturating_sub(32);
let hi = (i + 3 + 32).min(bytes.len());
let window = &bytes[lo..hi];
let has_op = window.windows(2).any(|w| w == b"==" || w == b"!=");
let has_lit = window.windows(3).any(|w| w == b"'/'")
|| window.windows(4).any(|w| w == b"'\\\\'")
|| window.windows(3).any(|w| w == b"\"/\"")
|| window.windows(4).any(|w| w == b"\"\\\\\"");
if has_op && has_lit {
return true;
}
}
i += 1;
}
false
}
fn has_python_dotdot_in(clause: &str) -> bool {
let bytes = clause.as_bytes();
let mut i = 0;
while i + 4 < bytes.len() {
if bytes[i] == b'"' && bytes[i + 1] == b'.' && bytes[i + 2] == b'.' && bytes[i + 3] == b'"'
{
let mut j = i + 4;
while j < bytes.len() && bytes[j].is_ascii_whitespace() {
j += 1;
}
if j + 2 <= bytes.len() && &bytes[j..j + 2] == b"in" {
let after = bytes.get(j + 2).copied();
if after
.map(|c| !c.is_ascii_alphanumeric() && c != b'_')
.unwrap_or(true)
{
return true;
}
}
}
i += 1;
}
false
}
fn split_top_level_or(text: &str) -> smallvec::SmallVec<[&str; 4]> {
let mut out: smallvec::SmallVec<[&str; 4]> = smallvec::SmallVec::new();
let bytes = text.as_bytes();
let mut depth: i32 = 0;
let mut in_quote: Option<u8> = None;
let mut last = 0usize;
let mut i = 0usize;
while i < bytes.len() {
let b = bytes[i];
if let Some(q) = in_quote {
if b == b'\\' && i + 1 < bytes.len() {
i += 2;
continue;
}
if b == q {
in_quote = None;
}
i += 1;
continue;
}
match b {
b'"' | b'\'' => {
in_quote = Some(b);
i += 1;
continue;
}
b'(' | b'[' | b'{' => {
depth += 1;
i += 1;
continue;
}
b')' | b']' | b'}' => {
depth -= 1;
i += 1;
continue;
}
b'|' if depth == 0 && i + 1 < bytes.len() && bytes[i + 1] == b'|' => {
out.push(&text[last..i]);
last = i + 2;
i += 2;
continue;
}
b'o' | b'O'
if depth == 0
&& i + 2 < bytes.len()
&& (bytes[i + 1] == b'r' || bytes[i + 1] == b'R')
&& bytes[i + 2].is_ascii_whitespace()
&& (i == 0 || bytes[i - 1].is_ascii_whitespace()) =>
{
out.push(&text[last..i]);
last = i + 2;
i += 2;
continue;
}
_ => {
i += 1;
}
}
}
out.push(&text[last..]);
out
}
pub fn classify_path_assertion(text: &str) -> PathAssertion {
let trimmed = text.trim();
match extract_starts_with_arg(trimmed) {
Some(needle) if needle.len() >= 2 => PathAssertion::PrefixLock(needle),
Some(_) => PathAssertion::None,
None if has_starts_with_call_with_nonempty_arg(trimmed) => {
PathAssertion::PrefixLock(OPAQUE_PREFIX_LOCK.to_string())
}
None => PathAssertion::None,
}
}
pub fn is_structural_variant_ctor(callee: &str) -> bool {
let trimmed = callee.trim();
if trimmed.is_empty() {
return false;
}
let segments: smallvec::SmallVec<[&str; 4]> =
trimmed.split("::").filter(|s| !s.is_empty()).collect();
let is_upper_ident = |s: &str| -> bool {
match s.chars().next() {
Some(c) if c.is_ascii_uppercase() => {
s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
}
_ => false,
}
};
if segments.is_empty() {
return false;
}
if segments.len() == 1 {
return is_upper_ident(segments[0]);
}
let leaf = segments[segments.len() - 1];
let parent = segments[segments.len() - 2];
is_upper_ident(leaf) || is_upper_ident(parent)
}
pub fn classify_path_primitive(callee: &str, input_fact: &PathFact) -> Option<PathFact> {
classify_path_primitive_rust(callee, input_fact)
}
pub fn classify_path_primitive_for_lang(
lang: crate::symbol::Lang,
callee: &str,
input_fact: &PathFact,
) -> Option<PathFact> {
use crate::symbol::Lang;
match lang {
Lang::Rust => classify_path_primitive_rust(callee, input_fact),
Lang::Python => classify_path_primitive_python(callee, input_fact),
Lang::JavaScript | Lang::TypeScript => classify_path_primitive_js(callee, input_fact),
Lang::Go => classify_path_primitive_go(callee, input_fact),
Lang::Java => classify_path_primitive_java(callee, input_fact),
Lang::Ruby => classify_path_primitive_ruby(callee, input_fact),
Lang::Php => classify_path_primitive_php(callee, input_fact),
Lang::C | Lang::Cpp => classify_path_primitive_c_cpp(callee, input_fact),
}
}
pub fn is_structural_variant_ctor_for_lang(lang: crate::symbol::Lang, callee: &str) -> bool {
match lang {
crate::symbol::Lang::Rust => is_structural_variant_ctor(callee),
_ => false,
}
}
pub fn is_zero_arg_allocator_for_lang(lang: crate::symbol::Lang, _callee: &str) -> bool {
let _ = lang;
false
}
pub fn classify_path_primitive_rust(callee: &str, input_fact: &PathFact) -> Option<PathFact> {
let leaf = rightmost_segment(callee);
match leaf {
"canonicalize" => {
let mut f = input_fact.clone();
f.normalized = Tri::Yes;
f.dotdot = Tri::No;
f.absolute = Tri::Yes;
Some(f)
}
"new" | "from" => {
if callee_contains_segment(callee, "Path") || callee_contains_segment(callee, "PathBuf")
{
Some(input_fact.clone())
} else {
None
}
}
"to_string" | "to_owned" | "clone" | "into" | "as_ref" | "as_str" | "as_path" => {
Some(input_fact.clone())
}
_ => None,
}
}
pub fn classify_path_primitive_python(callee: &str, input_fact: &PathFact) -> Option<PathFact> {
let leaf = rightmost_segment(callee);
match leaf {
"normpath" => {
let mut f = input_fact.clone();
f.dotdot = Tri::No;
f.normalized = Tri::Yes;
Some(f)
}
"realpath" | "resolve" => {
let mut f = input_fact.clone();
f.normalized = Tri::Yes;
f.dotdot = Tri::No;
f.absolute = Tri::Yes;
Some(f)
}
"abspath" => {
let mut f = input_fact.clone();
f.absolute = Tri::Yes;
Some(f)
}
"fspath" | "PurePath" | "PurePosixPath" | "PureWindowsPath" => Some(input_fact.clone()),
_ => None,
}
}
pub fn classify_path_primitive_js(callee: &str, input_fact: &PathFact) -> Option<PathFact> {
let leaf = rightmost_segment(callee);
match leaf {
"normalize" => {
let mut f = input_fact.clone();
f.dotdot = Tri::No;
f.normalized = Tri::Yes;
Some(f)
}
"resolve" => {
let mut f = input_fact.clone();
f.normalized = Tri::Yes;
f.dotdot = Tri::No;
f.absolute = Tri::Yes;
Some(f)
}
_ => None,
}
}
pub fn classify_path_primitive_go(callee: &str, input_fact: &PathFact) -> Option<PathFact> {
let leaf = rightmost_segment(callee);
match leaf {
"Clean" => {
let mut f = input_fact.clone();
f.dotdot = Tri::No;
f.normalized = Tri::Yes;
Some(f)
}
"Abs" => {
let mut f = input_fact.clone();
f.normalized = Tri::Yes;
f.dotdot = Tri::No;
f.absolute = Tri::Yes;
Some(f)
}
_ => None,
}
}
pub fn classify_path_primitive_java(callee: &str, input_fact: &PathFact) -> Option<PathFact> {
let leaf = rightmost_segment(callee);
match leaf {
"normalize" => {
let mut f = input_fact.clone();
f.dotdot = Tri::No;
f.normalized = Tri::Yes;
Some(f)
}
"toAbsolutePath" => {
let mut f = input_fact.clone();
f.absolute = Tri::Yes;
Some(f)
}
"toRealPath" => {
let mut f = input_fact.clone();
f.normalized = Tri::Yes;
f.dotdot = Tri::No;
f.absolute = Tri::Yes;
Some(f)
}
_ => None,
}
}
pub fn classify_path_primitive_ruby(callee: &str, input_fact: &PathFact) -> Option<PathFact> {
let leaf = rightmost_segment(callee);
match leaf {
"expand_path" => {
let mut f = input_fact.clone();
f.normalized = Tri::Yes;
f.dotdot = Tri::No;
f.absolute = Tri::Yes;
Some(f)
}
"cleanpath" => {
let mut f = input_fact.clone();
f.dotdot = Tri::No;
f.normalized = Tri::Yes;
Some(f)
}
_ => None,
}
}
pub fn classify_path_primitive_php(callee: &str, input_fact: &PathFact) -> Option<PathFact> {
let leaf = rightmost_segment(callee);
match leaf {
"realpath" => {
let mut f = input_fact.clone();
f.normalized = Tri::Yes;
f.dotdot = Tri::No;
f.absolute = Tri::Yes;
Some(f)
}
"basename" => {
let mut f = input_fact.clone();
f.dotdot = Tri::No;
f.absolute = Tri::No;
Some(f)
}
_ => None,
}
}
pub fn classify_path_primitive_c_cpp(callee: &str, input_fact: &PathFact) -> Option<PathFact> {
let leaf = rightmost_segment(callee);
match leaf {
"realpath" | "canonical" => {
let mut f = input_fact.clone();
f.normalized = Tri::Yes;
f.dotdot = Tri::No;
f.absolute = Tri::Yes;
Some(f)
}
_ => None,
}
}
fn rightmost_segment(s: &str) -> &str {
let after_colons = s.rsplit("::").next().unwrap_or(s);
after_colons.rsplit('.').next().unwrap_or(after_colons)
}
fn callee_contains_segment(callee: &str, seg: &str) -> bool {
callee.split([':', '.']).any(|s| s == seg)
}
fn extract_contains_arg(text: &str) -> Option<String> {
for method in [".contains(", ".includes(", ".include?("] {
if let Some(idx) = text.find(method)
&& let Some(s) = extract_first_string_literal(&text[idx + method.len()..])
{
return Some(s);
}
}
for prefix in [
"strings.Contains(",
"strings.HasPrefix(",
"strings.Index(",
"strstr(",
] {
if let Some(idx) = text.find(prefix) {
let inner = &text[idx + prefix.len()..];
if let Some(comma_idx) = top_level_comma(inner) {
let after_comma = &inner[comma_idx + 1..];
if let Some(s) = extract_first_string_literal(after_comma) {
return Some(s);
}
}
}
}
None
}
fn extract_starts_with_arg(text: &str) -> Option<String> {
for method in [
".starts_with(",
".start_with?(",
".startsWith(",
".startswith(",
] {
if let Some(idx) = text.find(method)
&& let Some(s) = extract_first_string_literal(&text[idx + method.len()..])
{
return Some(s);
}
}
if let Some(idx) = text.find("strings.HasPrefix(") {
let inner = &text[idx + "strings.HasPrefix(".len()..];
if let Some(comma_idx) = top_level_comma(inner) {
let after_comma = &inner[comma_idx + 1..];
if let Some(s) = extract_first_string_literal(after_comma) {
return Some(s);
}
}
}
None
}
fn has_starts_with_call_with_nonempty_arg(text: &str) -> bool {
for method in [
".starts_with(",
".start_with?(",
".startsWith(",
".startswith(",
] {
if let Some(idx) = text.find(method) {
let after = &text[idx + method.len()..];
if first_non_ws_byte(after).is_some_and(|b| b != b')') {
return true;
}
}
}
if let Some(idx) = text.find(".start_with?") {
let rest = &text[idx + ".start_with?".len()..];
let after = rest.trim_start();
if !after.is_empty() {
let first = after.as_bytes()[0];
if !matches!(first, b'(' | b'&' | b'|' | b')' | b']' | b';' | b',') {
return true;
}
}
}
if let Some(idx) = text.find("strings.HasPrefix(") {
let inner = &text[idx + "strings.HasPrefix(".len()..];
if let Some(comma_idx) = top_level_comma(inner) {
let after_comma = inner[comma_idx + 1..].trim_start();
if !after_comma.is_empty() && !after_comma.starts_with(')') {
return true;
}
}
}
false
}
fn first_non_ws_byte(text: &str) -> Option<u8> {
text.bytes().find(|b| !b.is_ascii_whitespace())
}
fn top_level_comma(text: &str) -> Option<usize> {
let bytes = text.as_bytes();
let mut depth: i32 = 0;
let mut in_quote: Option<u8> = None;
let mut i = 0usize;
while i < bytes.len() {
let b = bytes[i];
if let Some(q) = in_quote {
if b == b'\\' && i + 1 < bytes.len() {
i += 2;
continue;
}
if b == q {
in_quote = None;
}
i += 1;
continue;
}
match b {
b'"' | b'\'' => {
in_quote = Some(b);
i += 1;
}
b'(' | b'[' | b'{' => {
depth += 1;
i += 1;
}
b')' | b']' | b'}' => {
depth -= 1;
i += 1;
}
b',' if depth == 0 => return Some(i),
_ => i += 1,
}
}
None
}
fn extract_first_string_literal(after_open: &str) -> Option<String> {
let bytes = after_open.as_bytes();
let mut i = 0;
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
if i >= bytes.len() {
return None;
}
let quote = bytes[i];
if quote != b'"' && quote != b'\'' {
return None;
}
i += 1;
let mut out = Vec::new();
while i < bytes.len() {
let b = bytes[i];
if b == b'\\' && i + 1 < bytes.len() {
match bytes[i + 1] {
b'n' => out.push(b'\n'),
b'r' => out.push(b'\r'),
b't' => out.push(b'\t'),
c => out.push(c),
}
i += 2;
continue;
}
if b == quote {
return String::from_utf8(out).ok();
}
out.push(b);
i += 1;
}
None
}
fn truncate_prefix_lock(s: &str) -> String {
if s.len() <= MAX_PREFIX_LOCK_LEN {
s.to_string()
} else {
let mut end = MAX_PREFIX_LOCK_LEN;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
s[..end].to_string()
}
}
fn longest_common_prefix(a: &str, b: &str) -> String {
a.bytes()
.zip(b.bytes())
.take_while(|(x, y)| x == y)
.map(|(x, _)| x as char)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tri_join_idempotent() {
for v in [Tri::No, Tri::Yes, Tri::Maybe] {
assert_eq!(v.join(&v), v);
}
}
#[test]
fn tri_join_commutative() {
let pairs = [
(Tri::No, Tri::Yes),
(Tri::No, Tri::Maybe),
(Tri::Yes, Tri::Maybe),
];
for (a, b) in pairs {
assert_eq!(a.join(&b), b.join(&a));
}
}
#[test]
fn tri_join_disagreement_is_top() {
assert_eq!(Tri::No.join(&Tri::Yes), Tri::Maybe);
}
#[test]
fn tri_join_with_top_is_top() {
assert_eq!(Tri::No.join(&Tri::Maybe), Tri::Maybe);
assert_eq!(Tri::Yes.join(&Tri::Maybe), Tri::Maybe);
}
#[test]
fn tri_meet_top_is_identity() {
assert_eq!(Tri::No.meet_checked(&Tri::Maybe), Some(Tri::No));
assert_eq!(Tri::Maybe.meet_checked(&Tri::Yes), Some(Tri::Yes));
}
#[test]
fn tri_meet_contradiction_is_none() {
assert_eq!(Tri::No.meet_checked(&Tri::Yes), None);
assert_eq!(Tri::Yes.meet_checked(&Tri::No), None);
}
#[test]
fn tri_meet_agree() {
assert_eq!(Tri::No.meet_checked(&Tri::No), Some(Tri::No));
assert_eq!(Tri::Yes.meet_checked(&Tri::Yes), Some(Tri::Yes));
}
#[test]
fn tri_widen_drops_on_change() {
assert_eq!(Tri::No.widen(&Tri::Yes), Tri::Maybe);
assert_eq!(Tri::No.widen(&Tri::No), Tri::No);
}
#[test]
fn tri_leq_top_greatest() {
assert!(Tri::No.leq(&Tri::Maybe));
assert!(Tri::Yes.leq(&Tri::Maybe));
assert!(!Tri::Maybe.leq(&Tri::No));
}
#[test]
fn default_is_top() {
let f = PathFact::default();
assert!(f.is_top());
assert!(!f.is_bottom());
assert!(!f.is_path_safe());
}
#[test]
fn bottom_detection() {
let b = PathFact::bottom();
assert!(b.is_bottom());
assert!(!b.is_top());
assert!(!b.is_path_safe());
}
#[test]
fn is_path_safe_requires_both_axes() {
let mut f = PathFact::default().with_dotdot_cleared();
assert!(!f.is_path_safe(), "dotdot=No alone is insufficient");
f = f.with_absolute_cleared();
assert!(f.is_path_safe());
}
#[test]
fn is_path_safe_truth_table() {
let cases = [
(Tri::No, Tri::No, true),
(Tri::No, Tri::Yes, false),
(Tri::No, Tri::Maybe, false),
(Tri::Yes, Tri::No, false),
(Tri::Maybe, Tri::No, false),
(Tri::Maybe, Tri::Maybe, false),
];
for (dd, abs, expected) in cases {
let f = PathFact {
dotdot: dd,
absolute: abs,
normalized: Tri::Maybe,
prefix_lock: None,
is_bottom: false,
};
assert_eq!(
f.is_path_safe(),
expected,
"is_path_safe({:?}, {:?}) should be {expected}",
dd,
abs
);
}
}
#[test]
fn with_normalized_clears_dotdot() {
let f = PathFact::default().with_normalized();
assert_eq!(f.dotdot, Tri::No);
assert_eq!(f.normalized, Tri::Yes);
assert_eq!(f.absolute, Tri::Maybe);
}
#[test]
fn with_prefix_lock_ignores_empty() {
let f = PathFact::default().with_prefix_lock("");
assert!(f.prefix_lock.is_none());
}
#[test]
fn with_prefix_lock_truncates() {
let huge = "/".to_string() + &"a".repeat(MAX_PREFIX_LOCK_LEN * 2);
let f = PathFact::default().with_prefix_lock(&huge);
assert!(
f.prefix_lock.as_deref().unwrap().len() <= MAX_PREFIX_LOCK_LEN,
"prefix_lock must be bounded"
);
}
#[test]
fn c_or_chain_rejection_full() {
let axes = classify_path_rejection_axes(
"strstr(s, \"..\") != NULL || s[0] == '/' || s[0] == '\\\\'",
);
assert!(
axes.contains(&PathRejection::DotDot),
"expected DotDot in {:?}",
axes
);
assert!(
axes.contains(&PathRejection::AbsoluteSlash),
"expected AbsoluteSlash in {:?}",
axes
);
}
#[test]
fn classify_subscript_first_char_absolute() {
assert_eq!(
classify_path_rejection_atom("s[0] == '/'"),
PathRejection::AbsoluteSlash
);
assert_eq!(
classify_path_rejection_atom("s[0] == '\\\\'"),
PathRejection::AbsoluteSlash
);
assert_eq!(
classify_path_rejection_atom("'/' == in[0]"),
PathRejection::AbsoluteSlash
);
assert_eq!(
classify_path_rejection_atom("s[0] != '\\\\'"),
PathRejection::AbsoluteSlash
);
assert_eq!(
classify_path_rejection_atom("s[0] == c"),
PathRejection::None
);
assert_eq!(classify_path_rejection_atom("s[0]"), PathRejection::None);
let s = format!("{}s[0] == '/'", "—".repeat(20));
assert_eq!(
classify_path_rejection_atom(&s),
PathRejection::AbsoluteSlash
);
let s2 = format!("s[0] == '/'{}", "—".repeat(20));
assert_eq!(
classify_path_rejection_atom(&s2),
PathRejection::AbsoluteSlash
);
}
#[test]
fn prefix_locked_under_works() {
let f = PathFact::default().with_prefix_lock("/var/app/uploads/");
assert!(f.prefix_locked_under("/var/app/"));
assert!(f.prefix_locked_under("/var/app/uploads/"));
assert!(!f.prefix_locked_under("/etc/"));
assert!(!PathFact::default().prefix_locked_under("/var/app/"));
}
#[test]
fn join_idempotent() {
let f = PathFact::default()
.with_dotdot_cleared()
.with_absolute_cleared();
assert_eq!(f.join(&f), f);
}
#[test]
fn join_commutative() {
let a = PathFact::default().with_dotdot_cleared();
let b = PathFact::default().with_absolute_cleared();
assert_eq!(a.join(&b), b.join(&a));
}
#[test]
fn join_associative() {
let a = PathFact::default().with_dotdot_cleared();
let b = PathFact::default().with_absolute_cleared();
let c = PathFact::default().with_normalized();
assert_eq!(a.join(&b).join(&c), a.join(&b.join(&c)));
}
#[test]
fn join_with_bottom_identity() {
let a = PathFact::default().with_dotdot_cleared();
assert_eq!(a.join(&PathFact::bottom()), a);
assert_eq!(PathFact::bottom().join(&a), a);
}
#[test]
fn join_disagreement_yields_maybe() {
let a = PathFact::default().with_dotdot_cleared(); let b = PathFact {
dotdot: Tri::Yes,
..Default::default()
};
let j = a.join(&b);
assert_eq!(j.dotdot, Tri::Maybe);
}
#[test]
fn join_prefix_locks_lcp() {
let a = PathFact::default().with_prefix_lock("/var/app/uploads/");
let b = PathFact::default().with_prefix_lock("/var/app/static/");
let j = a.join(&b);
assert_eq!(j.prefix_lock.as_deref(), Some("/var/app/"));
}
#[test]
fn join_prefix_locks_disjoint_drops() {
let a = PathFact::default().with_prefix_lock("/var/app/");
let b = PathFact::default().with_prefix_lock("/etc/");
let j = a.join(&b);
assert_eq!(j.prefix_lock.as_deref(), Some("/"));
let c = PathFact::default().with_prefix_lock("home/");
let d = PathFact::default().with_prefix_lock("etc/");
assert!(c.join(&d).prefix_lock.is_none());
}
#[test]
fn meet_top_is_identity() {
let a = PathFact::default()
.with_dotdot_cleared()
.with_absolute_cleared();
assert_eq!(a.meet(&PathFact::top()), a);
assert_eq!(PathFact::top().meet(&a), a);
}
#[test]
fn meet_refines() {
let a = PathFact::default().with_dotdot_cleared();
let b = PathFact::default().with_absolute_cleared();
let m = a.meet(&b);
assert_eq!(m.dotdot, Tri::No);
assert_eq!(m.absolute, Tri::No);
assert!(m.is_path_safe());
}
#[test]
fn meet_contradiction_is_bottom() {
let a = PathFact::default().with_dotdot_cleared(); let b = PathFact {
dotdot: Tri::Yes,
..Default::default()
};
assert!(a.meet(&b).is_bottom());
}
#[test]
fn meet_prefix_locks_picks_longer() {
let a = PathFact::default().with_prefix_lock("/var/app/");
let b = PathFact::default().with_prefix_lock("/var/app/uploads/");
let m = a.meet(&b);
assert_eq!(m.prefix_lock.as_deref(), Some("/var/app/uploads/"));
}
#[test]
fn meet_prefix_locks_disjoint_is_bottom() {
let a = PathFact::default().with_prefix_lock("/var/app/");
let b = PathFact::default().with_prefix_lock("/etc/");
assert!(a.meet(&b).is_bottom());
}
#[test]
fn widen_stable() {
let a = PathFact::default()
.with_dotdot_cleared()
.with_absolute_cleared();
assert_eq!(a.widen(&a), a);
}
#[test]
fn widen_drops_on_change() {
let a = PathFact::default().with_dotdot_cleared();
let b = PathFact {
dotdot: Tri::Yes,
..Default::default()
};
let w = a.widen(&b);
assert_eq!(w.dotdot, Tri::Maybe);
}
#[test]
fn widen_chain_terminates() {
let mut cur = PathFact::default().with_dotdot_cleared();
let target = PathFact {
dotdot: Tri::Yes,
absolute: Tri::Yes,
normalized: Tri::Yes,
prefix_lock: None,
is_bottom: false,
};
for _ in 0..8 {
cur = cur.widen(&target);
}
assert_eq!(cur.dotdot, Tri::Maybe);
assert_eq!(cur, cur.widen(&target), "must have stabilised");
}
#[test]
fn widen_prefix_drops_on_change() {
let a = PathFact::default().with_prefix_lock("/var/app/v1/");
let b = PathFact::default().with_prefix_lock("/var/app/v2/");
assert!(a.widen(&b).prefix_lock.is_none());
}
#[test]
fn leq_top_greatest() {
let a = PathFact::default().with_dotdot_cleared();
assert!(a.leq(&PathFact::top()));
assert!(!PathFact::top().leq(&a));
}
#[test]
fn leq_bottom_least() {
assert!(PathFact::bottom().leq(&PathFact::default()));
assert!(!PathFact::default().leq(&PathFact::bottom()));
}
#[test]
fn leq_refinement() {
let refined = PathFact::default()
.with_dotdot_cleared()
.with_absolute_cleared();
let coarse = PathFact::default().with_dotdot_cleared();
assert!(refined.leq(&coarse));
assert!(!coarse.leq(&refined));
}
#[test]
fn rejection_contains_dotdot() {
assert_eq!(
classify_path_rejection("user.contains(\"..\")"),
PathRejection::DotDot
);
}
#[test]
fn rejection_axes_disjunction_covers_all_clauses() {
let axes = classify_path_rejection_axes(
"s.contains(\"..\") || s.starts_with('/') || s.starts_with('\\\\')",
);
assert!(
axes.contains(&PathRejection::DotDot),
"expected DotDot in {axes:?}"
);
assert!(
axes.contains(&PathRejection::AbsoluteSlash),
"expected AbsoluteSlash in {axes:?}"
);
}
#[test]
fn rejection_axes_deduplicates() {
let axes = classify_path_rejection_axes("a.starts_with('/') || b.starts_with(\"\\\\\")");
assert_eq!(
axes.iter()
.filter(|a| matches!(a, PathRejection::AbsoluteSlash))
.count(),
1
);
}
#[test]
fn rejection_contains_other_needle_is_none() {
assert_eq!(
classify_path_rejection("name.contains(\";\")"),
PathRejection::None
);
}
#[test]
fn rejection_starts_with_slash() {
assert_eq!(
classify_path_rejection("p.starts_with('/')"),
PathRejection::AbsoluteSlash
);
assert_eq!(
classify_path_rejection("p.starts_with(\"/\")"),
PathRejection::AbsoluteSlash
);
}
#[test]
fn rejection_starts_with_backslash() {
assert_eq!(
classify_path_rejection("p.starts_with(\"\\\\\")"),
PathRejection::AbsoluteSlash
);
}
#[test]
fn rejection_is_absolute() {
assert_eq!(
classify_path_rejection("Path::new(s).is_absolute()"),
PathRejection::IsAbsolute
);
assert_eq!(
classify_path_rejection("p.is_absolute()"),
PathRejection::IsAbsolute
);
}
#[test]
fn assertion_prefix_lock() {
match classify_path_assertion("p.starts_with(\"/var/app/\")") {
PathAssertion::PrefixLock(r) => assert_eq!(r, "/var/app/"),
other => panic!("expected PrefixLock, got {other:?}"),
}
}
#[test]
fn assertion_single_char_not_lock() {
assert_eq!(
classify_path_assertion("p.starts_with('/')"),
PathAssertion::None
);
}
#[test]
fn assertion_opaque_prefix_lock_method_call_arg() {
assert_eq!(
classify_path_assertion("filename.start_with? @config.resolve_swagger_root(env)"),
PathAssertion::PrefixLock(OPAQUE_PREFIX_LOCK.to_string())
);
}
#[test]
fn assertion_opaque_prefix_lock_paren_method_call() {
assert_eq!(
classify_path_assertion("filename.start_with?(@config.root)"),
PathAssertion::PrefixLock(OPAQUE_PREFIX_LOCK.to_string())
);
}
#[test]
fn assertion_opaque_prefix_lock_python_startswith() {
assert_eq!(
classify_path_assertion("p.startswith(safe_root)"),
PathAssertion::PrefixLock(OPAQUE_PREFIX_LOCK.to_string())
);
}
#[test]
fn assertion_opaque_prefix_lock_js_starts_with() {
assert_eq!(
classify_path_assertion("resolved.startsWith(uploadsDir)"),
PathAssertion::PrefixLock(OPAQUE_PREFIX_LOCK.to_string())
);
}
#[test]
fn assertion_opaque_prefix_lock_go_hasprefix() {
assert_eq!(
classify_path_assertion("strings.HasPrefix(p, safeRoot)"),
PathAssertion::PrefixLock(OPAQUE_PREFIX_LOCK.to_string())
);
}
#[test]
fn assertion_no_lock_on_empty_arg() {
assert_eq!(
classify_path_assertion("r.starts_with()"),
PathAssertion::None
);
}
#[test]
fn is_path_traversal_safe_relative_dotdot_free() {
let f = PathFact::default()
.with_dotdot_cleared()
.with_absolute_cleared();
assert!(f.is_path_traversal_safe());
}
#[test]
fn is_path_traversal_safe_canonicalised_with_prefix_lock() {
let f = PathFact::default()
.with_dotdot_cleared()
.with_prefix_lock("__nyx_opaque_prefix__");
assert!(!f.is_path_safe(), "absolute axis still Maybe blocks strict");
let mut f2 = f.clone();
f2.absolute = Tri::Yes;
assert!(!f2.is_path_safe(), "absolute=Yes blocks strict predicate");
assert!(
f2.is_path_traversal_safe(),
"prefix_lock + dotdot=No is sufficient under relaxed predicate"
);
}
#[test]
fn is_path_traversal_safe_rejects_dotdot_maybe() {
let f = PathFact::default().with_prefix_lock("/var/app/");
assert!(!f.is_path_traversal_safe());
}
#[test]
fn is_path_traversal_safe_rejects_absolute_without_lock() {
let mut f = PathFact::default().with_dotdot_cleared();
f.absolute = Tri::Yes;
assert!(!f.is_path_traversal_safe());
}
#[test]
fn is_path_traversal_safe_rejects_bottom() {
assert!(!PathFact::bottom().is_path_traversal_safe());
}
#[test]
fn primitive_canonicalize_normalises() {
let f = classify_path_primitive("fs::canonicalize", &PathFact::top()).unwrap();
assert_eq!(f.dotdot, Tri::No);
assert_eq!(f.normalized, Tri::Yes);
assert_eq!(f.absolute, Tri::Yes);
}
#[test]
fn primitive_method_canonicalize_normalises() {
let f = classify_path_primitive("canonicalize", &PathFact::top()).unwrap();
assert_eq!(f.normalized, Tri::Yes);
}
#[test]
fn primitive_path_new_passthrough() {
let input = PathFact::default()
.with_dotdot_cleared()
.with_absolute_cleared();
let f = classify_path_primitive("Path::new", &input).unwrap();
assert_eq!(f, input, "Path::new passes PathFact through unchanged");
}
#[test]
fn primitive_pathbuf_from_passthrough() {
let input = PathFact::default().with_dotdot_cleared();
let f = classify_path_primitive("PathBuf::from", &input).unwrap();
assert_eq!(f, input);
}
#[test]
fn primitive_unknown_returns_none() {
assert!(classify_path_primitive("unknown_fn", &PathFact::top()).is_none());
assert!(classify_path_primitive("vec::new", &PathFact::top()).is_none());
}
#[test]
fn variant_ctor_recognises_upper_camel_leaf() {
assert!(is_structural_variant_ctor("Some"));
assert!(is_structural_variant_ctor("Ok"));
assert!(is_structural_variant_ctor("Err"));
assert!(is_structural_variant_ctor("Box::new"));
assert!(is_structural_variant_ctor("std::option::Option::Some"));
assert!(is_structural_variant_ctor("MyResult::Ok"));
assert!(is_structural_variant_ctor("Wrapper"));
}
#[test]
fn variant_ctor_rejects_lowercase_leaf() {
assert!(!is_structural_variant_ctor("foo"));
assert!(!is_structural_variant_ctor("bar::baz"));
assert!(!is_structural_variant_ctor("std::env::var"));
assert!(!is_structural_variant_ctor("to_string"));
}
#[test]
fn variant_ctor_rejects_empty_or_garbled() {
assert!(!is_structural_variant_ctor(""));
assert!(!is_structural_variant_ctor("::"));
assert!(!is_structural_variant_ctor("123"));
}
#[test]
fn merge_path_fact_dedups_by_predicate_hash() {
use crate::summary::ssa_summary::{PathFactReturnEntry, merge_path_fact_return_paths};
use smallvec::SmallVec;
let mut acc: SmallVec<[PathFactReturnEntry; 2]> = SmallVec::new();
let f1 = PathFact::top().with_dotdot_cleared();
let f2 = PathFact::top().with_absolute_cleared();
merge_path_fact_return_paths(
&mut acc,
&[PathFactReturnEntry {
predicate_hash: 42,
known_true: 0,
known_false: 0,
path_fact: f1.clone(),
variant_inner_fact: None,
}],
);
merge_path_fact_return_paths(
&mut acc,
&[PathFactReturnEntry {
predicate_hash: 42,
known_true: 0,
known_false: 0,
path_fact: f2.clone(),
variant_inner_fact: None,
}],
);
assert_eq!(acc.len(), 1, "same predicate hash collapses to one entry");
let joined = f1.join(&f2);
assert_eq!(
acc[0].path_fact, joined,
"facts join on predicate-hash collision"
);
}
#[test]
fn merge_path_fact_distinct_hashes_kept_separate() {
use crate::summary::ssa_summary::{PathFactReturnEntry, merge_path_fact_return_paths};
use smallvec::SmallVec;
let mut acc: SmallVec<[PathFactReturnEntry; 2]> = SmallVec::new();
merge_path_fact_return_paths(
&mut acc,
&[
PathFactReturnEntry {
predicate_hash: 1,
known_true: 0,
known_false: 0,
path_fact: PathFact::top().with_dotdot_cleared(),
variant_inner_fact: None,
},
PathFactReturnEntry {
predicate_hash: 2,
known_true: 0,
known_false: 0,
path_fact: PathFact::top(),
variant_inner_fact: Some(PathFact::top().with_absolute_cleared()),
},
],
);
assert_eq!(acc.len(), 2);
}
#[test]
fn merge_path_fact_overflow_caps_at_bound() {
use crate::summary::ssa_summary::{
MAX_PATH_FACT_RETURN_ENTRIES, PathFactReturnEntry, merge_path_fact_return_paths,
};
use smallvec::SmallVec;
let mut acc: SmallVec<[PathFactReturnEntry; 2]> = SmallVec::new();
for i in 0..(MAX_PATH_FACT_RETURN_ENTRIES * 2) {
merge_path_fact_return_paths(
&mut acc,
&[PathFactReturnEntry {
predicate_hash: i as u64 + 100,
known_true: 0,
known_false: 0,
path_fact: PathFact::top().with_dotdot_cleared(),
variant_inner_fact: None,
}],
);
}
assert!(
acc.len() <= MAX_PATH_FACT_RETURN_ENTRIES,
"overflow growth stays bounded: got {}",
acc.len()
);
assert!(
acc.iter().any(|e| e.predicate_hash == 0),
"collapse sentinel must persist"
);
}
#[test]
fn leq_consistent_with_join() {
let a = PathFact::default().with_dotdot_cleared();
let b = PathFact::default()
.with_dotdot_cleared()
.with_absolute_cleared();
assert!(b.leq(&a));
assert_eq!(b.join(&a), a);
}
}