use serde::{Deserialize, Serialize};
use crate::models::Severity;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BranchLabel {
RealBug,
Benign,
}
impl BranchLabel {
#[must_use]
pub fn opposite(self) -> Self {
match self {
Self::RealBug => Self::Benign,
Self::Benign => Self::RealBug,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AlternativeBranch {
pub label: BranchLabel,
pub severity: Severity,
pub title: String,
pub description: String,
#[serde(default)]
pub suggested_fix: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum PredictionReasonKind {
BundledCode,
NonProductionPath,
MultiDetectorAgreement {
count: u32,
},
TestFixtureFile,
HierarchicalLevel { level_name: String, z_score: f64 },
KeywordArgument { name: String, value: String },
FirstArgIdentifier { name: String },
EnclosingScope { scope_kind: String, name: String },
ImportPresence { module: String },
FilePath { hint: String },
StructuralPattern { description: String },
Custom { description: String },
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PredictionReason {
#[serde(flatten)]
pub kind: PredictionReasonKind,
pub weight: f32,
pub note: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ResolutionKind {
KeywordArgument { name: String, value: String },
SourceAnnotation { syntax: String },
StructuralPattern {
#[serde(rename = "pattern_description")]
description: String,
},
ImportPresence { module: String },
EnclosingScope { scope_kind: String, name: String },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResolutionSignal {
#[serde(flatten)]
pub kind: ResolutionKind,
pub description: String,
#[serde(default)]
pub example: Option<String>,
pub collapses_to: BranchLabel,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn branch_label_opposite_is_involutive() {
assert_eq!(BranchLabel::RealBug.opposite(), BranchLabel::Benign);
assert_eq!(BranchLabel::Benign.opposite(), BranchLabel::RealBug);
assert_eq!(
BranchLabel::RealBug.opposite().opposite(),
BranchLabel::RealBug
);
}
#[test]
fn branch_label_serializes_snake_case() {
assert_eq!(
serde_json::to_string(&BranchLabel::RealBug).expect("serialize"),
"\"real_bug\""
);
assert_eq!(
serde_json::to_string(&BranchLabel::Benign).expect("serialize"),
"\"benign\""
);
}
#[test]
fn branch_label_roundtrips() {
for original in [BranchLabel::RealBug, BranchLabel::Benign] {
let json = serde_json::to_string(&original).expect("serialize");
let parsed: BranchLabel = serde_json::from_str(&json).expect("deserialize");
assert_eq!(original, parsed);
}
}
#[test]
fn alternative_branch_roundtrips() {
let original = AlternativeBranch {
label: BranchLabel::RealBug,
severity: Severity::High,
title: "If `s` carries sensitive data, SHA-1 is broken".into(),
description: "Use SHA-256 or SHA-3 for security-sensitive hashing.".into(),
suggested_fix: Some("hashlib.sha256(s)".into()),
};
let json = serde_json::to_string(&original).expect("serialize");
let parsed: AlternativeBranch = serde_json::from_str(&json).expect("deserialize");
assert_eq!(original, parsed);
}
#[test]
fn alternative_branch_serializes_suggested_fix_as_null_when_none() {
let alt = AlternativeBranch {
label: BranchLabel::Benign,
severity: Severity::Info,
title: "Annotated".into(),
description: "No fix needed.".into(),
suggested_fix: None,
};
let json = serde_json::to_value(&alt).expect("serialize");
assert_eq!(
json["suggested_fix"],
serde_json::Value::Null,
"suggested_fix should be null when None, got: {json}"
);
}
#[test]
fn prediction_reason_kind_serializes_with_flat_tag() {
let reason = PredictionReasonKind::KeywordArgument {
name: "usedforsecurity".into(),
value: "False".into(),
};
let json = serde_json::to_value(&reason).expect("serialize");
assert_eq!(json["kind"], "keyword_argument");
assert_eq!(json["name"], "usedforsecurity");
assert_eq!(json["value"], "False");
}
#[test]
fn prediction_reason_kind_unit_variants_serialize_as_kind_only() {
let json = serde_json::to_value(&PredictionReasonKind::BundledCode).expect("serialize");
assert_eq!(json, json!({"kind": "bundled_code"}));
let json =
serde_json::to_value(&PredictionReasonKind::NonProductionPath).expect("serialize");
assert_eq!(json, json!({"kind": "non_production_path"}));
let json = serde_json::to_value(&PredictionReasonKind::TestFixtureFile).expect("serialize");
assert_eq!(json, json!({"kind": "test_fixture_file"}));
}
#[test]
fn prediction_reason_full_shape_roundtrips() {
let original = PredictionReason {
kind: PredictionReasonKind::EnclosingScope {
scope_kind: "class".into(),
name: "DigestAuth".into(),
},
weight: 0.4,
note: "Class name suggests RFC 7616 Digest authentication.".into(),
};
let json = serde_json::to_value(&original).expect("serialize");
assert_eq!(json["kind"], "enclosing_scope");
assert_eq!(json["scope_kind"], "class");
assert_eq!(json["name"], "DigestAuth");
assert!((json["weight"].as_f64().expect("weight is f64") - 0.4).abs() < 1e-6);
assert_eq!(
json["note"],
"Class name suggests RFC 7616 Digest authentication."
);
let parsed: PredictionReason = serde_json::from_value(json).expect("deserialize roundtrip");
assert_eq!(original, parsed);
}
#[test]
fn prediction_reason_existing_signal_aliases_match_strings() {
for (variant, expected_tag) in [
(PredictionReasonKind::BundledCode, "bundled_code"),
(
PredictionReasonKind::NonProductionPath,
"non_production_path",
),
(
PredictionReasonKind::MultiDetectorAgreement { count: 3 },
"multi_detector_agreement",
),
(PredictionReasonKind::TestFixtureFile, "test_fixture_file"),
] {
let json = serde_json::to_value(&variant).expect("serialize");
assert_eq!(
json["kind"], expected_tag,
"variant {variant:?} must serialize with kind={expected_tag:?} \
to align with confidence_enrichment::ConfidenceSignal.signal"
);
}
}
#[test]
fn prediction_reason_hierarchical_level_matches_predictive_label() {
let reason = PredictionReasonKind::HierarchicalLevel {
level_name: "L4 Architectural".into(),
z_score: 3.2,
};
let json = serde_json::to_value(&reason).expect("serialize");
assert_eq!(json["kind"], "hierarchical_level");
assert_eq!(json["level_name"], "L4 Architectural");
assert!((json["z_score"].as_f64().expect("z_score is f64") - 3.2).abs() < 1e-6);
}
#[test]
fn resolution_signal_collapses_to_serialized() {
let signal = ResolutionSignal {
kind: ResolutionKind::KeywordArgument {
name: "usedforsecurity".into(),
value: "False".into(),
},
description: "Python 3.9+ stdlib non-security annotation.".into(),
example: Some("hashlib.sha1(s, usedforsecurity=False)".into()),
collapses_to: BranchLabel::Benign,
};
let json = serde_json::to_value(&signal).expect("serialize");
assert_eq!(json["kind"], "keyword_argument");
assert_eq!(json["collapses_to"], "benign");
assert_eq!(json["example"], "hashlib.sha1(s, usedforsecurity=False)");
}
#[test]
fn resolution_signal_serializes_example_as_null_when_none() {
let signal = ResolutionSignal {
kind: ResolutionKind::SourceAnnotation {
syntax: "# repotoire:ignore[InsecureCryptoDetector]".into(),
},
description: "Suppress this finding on this line.".into(),
example: None,
collapses_to: BranchLabel::Benign,
};
let json = serde_json::to_value(&signal).expect("serialize");
assert_eq!(
json["example"],
serde_json::Value::Null,
"example should be null when None, got: {json}"
);
}
#[test]
fn resolution_signal_parses_legacy_payload_without_example_field() {
let json = r##"{
"kind": "source_annotation",
"syntax": "# repotoire:protocol-required",
"description": "Mark as protocol-required",
"collapses_to": "benign"
}"##;
let parsed: ResolutionSignal =
serde_json::from_str(json).expect("deserialize without example");
assert_eq!(parsed.example, None);
assert_eq!(parsed.collapses_to, BranchLabel::Benign);
}
#[test]
fn custom_variant_is_documented_escape_hatch_only() {
let custom = PredictionReasonKind::Custom {
description: "Unknown signal from external plugin".into(),
};
let json = serde_json::to_value(&custom).expect("serialize");
assert_eq!(json["kind"], "custom");
assert_eq!(json["description"], "Unknown signal from external plugin");
let parsed: PredictionReasonKind = serde_json::from_value(json).expect("roundtrip");
assert_eq!(parsed, custom);
let variant_name = match &custom {
PredictionReasonKind::Custom { .. } => "Custom",
_ => "not Custom",
};
assert_eq!(
variant_name, "Custom",
"the escape-hatch variant must be named `Custom`; \
see PredictionReasonKind::Custom docstring for the policy"
);
}
fn assert_resolution_signal_roundtrips(kind: ResolutionKind) {
let original = ResolutionSignal {
kind,
description: "TRIPWIRE_PARENT_DESCRIPTION".into(),
example: Some("TRIPWIRE_EXAMPLE".into()),
collapses_to: BranchLabel::Benign,
};
let json = serde_json::to_value(&original)
.unwrap_or_else(|e| panic!("serialize failed for {:?}: {e}", original.kind));
assert_eq!(
json["description"], "TRIPWIRE_PARENT_DESCRIPTION",
"parent `description` was overwritten by a flattened \
variant field for {:?}; JSON: {json}",
original.kind
);
let parsed: ResolutionSignal = serde_json::from_value(json.clone()).unwrap_or_else(|e| {
panic!(
"deserialize failed for {:?}: {e}; JSON: {json}",
original.kind
)
});
assert_eq!(
parsed, original,
"round-trip mismatch for {:?}",
original.kind
);
}
#[test]
fn resolution_kind_every_variant_roundtrips_through_signal() {
let cases = vec![
ResolutionKind::KeywordArgument {
name: "usedforsecurity".into(),
value: "False".into(),
},
ResolutionKind::SourceAnnotation {
syntax: "# repotoire:protocol-required[RFC7616]".into(),
},
ResolutionKind::StructuralPattern {
description: "TRIPWIRE_INNER_DESCRIPTION_DIFFERENT_FROM_PARENT".into(),
},
ResolutionKind::ImportPresence {
module: "defusedxml.ElementTree".into(),
},
ResolutionKind::EnclosingScope {
scope_kind: "class".into(),
name: "DigestAuth".into(),
},
];
let example = &cases[0];
match example {
ResolutionKind::KeywordArgument { .. }
| ResolutionKind::SourceAnnotation { .. }
| ResolutionKind::StructuralPattern { .. }
| ResolutionKind::ImportPresence { .. }
| ResolutionKind::EnclosingScope { .. } => {}
}
for case in cases {
assert_resolution_signal_roundtrips(case);
}
}
#[test]
fn resolution_kind_structural_pattern_inner_description_does_not_collide() {
let signal = ResolutionSignal {
kind: ResolutionKind::StructuralPattern {
description: "INNER_VARIANT_VALUE".into(),
},
description: "PARENT_PROSE_VALUE".into(),
example: None,
collapses_to: BranchLabel::RealBug,
};
let json = serde_json::to_value(&signal).expect("serialize");
assert_eq!(json["description"], "PARENT_PROSE_VALUE");
assert_eq!(json["pattern_description"], "INNER_VARIANT_VALUE");
let parsed: ResolutionSignal = serde_json::from_value(json).expect("deserialize");
assert_eq!(parsed, signal);
}
fn assert_prediction_reason_roundtrips(kind: PredictionReasonKind) {
let original = PredictionReason {
kind,
weight: -0.42,
note: "TRIPWIRE_PARENT_NOTE".into(),
};
let json = serde_json::to_value(&original)
.unwrap_or_else(|e| panic!("serialize failed for {:?}: {e}", original.kind));
assert_eq!(
json["note"], "TRIPWIRE_PARENT_NOTE",
"parent `note` was overwritten by a flattened variant field \
for {:?}; JSON: {json}",
original.kind
);
assert!(
(json["weight"].as_f64().expect("weight is f64") - (-0.42)).abs() < 1e-6,
"parent `weight` was overwritten or mistyped for {:?}; JSON: {json}",
original.kind
);
let parsed: PredictionReason = serde_json::from_value(json.clone()).unwrap_or_else(|e| {
panic!(
"deserialize failed for {:?}: {e}; JSON: {json}",
original.kind
)
});
assert_eq!(
parsed, original,
"round-trip mismatch for {:?}",
original.kind
);
}
#[test]
fn prediction_reason_kind_every_variant_roundtrips_through_reason() {
let cases = vec![
PredictionReasonKind::BundledCode,
PredictionReasonKind::NonProductionPath,
PredictionReasonKind::MultiDetectorAgreement { count: 3 },
PredictionReasonKind::TestFixtureFile,
PredictionReasonKind::HierarchicalLevel {
level_name: "L4 Architectural".into(),
z_score: 3.2,
},
PredictionReasonKind::KeywordArgument {
name: "verify".into(),
value: "False".into(),
},
PredictionReasonKind::FirstArgIdentifier {
name: "password".into(),
},
PredictionReasonKind::EnclosingScope {
scope_kind: "function".into(),
name: "load_user".into(),
},
PredictionReasonKind::ImportPresence {
module: "defusedxml".into(),
},
PredictionReasonKind::FilePath {
hint: "matches scripts/ glob".into(),
},
PredictionReasonKind::StructuralPattern {
description: "first arg identifier matches sensitive lexicon".into(),
},
PredictionReasonKind::Custom {
description: "Unknown plugin signal".into(),
},
];
let example = &cases[0];
match example {
PredictionReasonKind::BundledCode
| PredictionReasonKind::NonProductionPath
| PredictionReasonKind::MultiDetectorAgreement { .. }
| PredictionReasonKind::TestFixtureFile
| PredictionReasonKind::HierarchicalLevel { .. }
| PredictionReasonKind::KeywordArgument { .. }
| PredictionReasonKind::FirstArgIdentifier { .. }
| PredictionReasonKind::EnclosingScope { .. }
| PredictionReasonKind::ImportPresence { .. }
| PredictionReasonKind::FilePath { .. }
| PredictionReasonKind::StructuralPattern { .. }
| PredictionReasonKind::Custom { .. } => {}
}
for case in cases {
assert_prediction_reason_roundtrips(case);
}
}
}