use bytes::Bytes;
use secrecy::SecretString;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::error::{OlError, OL_4221_MALFORMED_BODY, OL_4223_VERDICT_TOO_LARGE};
use crate::runtime::proxy::MAX_VERDICT_BYTES;
use crate::runtime::webhook::{self, SignedHeaders};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SeverityHint {
Low,
Medium,
High,
Critical,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum VerdictHint {
Allow,
Approve,
Deny,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserFacing {
pub headline: String,
pub body: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub evidence: Vec<Evidence>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub remediation: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Evidence {
pub label: String,
#[serde(rename = "valueRedacted")]
pub value_redacted: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Verdict {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub risk_score: Option<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub severity_hint: Option<SeverityHint>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub verdict_hint: Option<VerdictHint>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rule_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rationale_summary: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub user_facing: Option<UserFacing>,
#[serde(default, skip_serializing_if = "Value::is_null")]
pub enrichment: Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub latency_ms: Option<u64>,
}
impl Default for Verdict {
fn default() -> Self {
Self {
risk_score: None,
severity_hint: None,
verdict_hint: None,
rule_id: None,
rationale_summary: None,
user_facing: None,
enrichment: Value::Null,
latency_ms: None,
}
}
}
pub fn validate_body_size(bytes: &[u8]) -> Result<(), OlError> {
if bytes.len() > MAX_VERDICT_BYTES {
return Err(OlError::new(
OL_4223_VERDICT_TOO_LARGE,
format!("verdict {} bytes > {} cap", bytes.len(), MAX_VERDICT_BYTES),
));
}
Ok(())
}
pub fn parse_lossy(bytes: &[u8]) -> Option<Verdict> {
serde_json::from_slice(bytes).ok()
}
pub fn sign(secret: &SecretString, body: &[u8]) -> Result<SignedHeaders, OlError> {
webhook::sign_response(secret, body)
}
pub fn ensure_json(bytes: &[u8]) -> Result<(), OlError> {
serde_json::from_slice::<Value>(bytes)
.map(|_| ())
.map_err(|e| {
OlError::new(
OL_4221_MALFORMED_BODY,
format!("tool body is not valid JSON: {e}"),
)
})
}
#[derive(Debug, Clone)]
pub struct SignedResponse {
pub body: Bytes,
pub headers: SignedHeaders,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn verdict_serializes_to_camel_case() {
let v = Verdict {
risk_score: Some(92),
severity_hint: Some(SeverityHint::High),
verdict_hint: Some(VerdictHint::Deny),
rule_id: Some("pii.ssn".into()),
rationale_summary: Some("SSN detected".into()),
user_facing: None,
enrichment: Value::Null,
latency_ms: Some(47),
};
let s = serde_json::to_string(&v).unwrap();
assert!(s.contains("\"riskScore\":92"));
assert!(s.contains("\"severityHint\":\"high\""));
assert!(s.contains("\"verdictHint\":\"deny\""));
assert!(s.contains("\"ruleId\":\"pii.ssn\""));
assert!(s.contains("\"rationaleSummary\":\"SSN detected\""));
assert!(s.contains("\"latencyMs\":47"));
}
#[test]
fn verdict_omits_none_fields_in_serialized_form() {
let v = Verdict::default();
let s = serde_json::to_string(&v).unwrap();
assert_eq!(s, "{}");
}
#[test]
fn verdict_round_trips_via_serde() {
let v = Verdict {
risk_score: Some(5),
severity_hint: Some(SeverityHint::Low),
verdict_hint: Some(VerdictHint::Allow),
rule_id: None,
rationale_summary: Some("no issue".into()),
user_facing: Some(UserFacing {
headline: "All clear".into(),
body: "Nothing to do.".into(),
evidence: vec![Evidence {
label: "ssn".into(),
value_redacted: "***-**-1234".into(),
}],
remediation: None,
}),
enrichment: serde_json::json!({"pii_types": ["ssn"]}),
latency_ms: Some(8),
};
let s = serde_json::to_vec(&v).unwrap();
let back: Verdict = serde_json::from_slice(&s).unwrap();
assert_eq!(back.risk_score, Some(5));
assert_eq!(back.user_facing.unwrap().evidence[0].label, "ssn");
}
#[test]
fn validate_body_size_rejects_oversize() {
let big = vec![b'x'; MAX_VERDICT_BYTES + 1];
let err = validate_body_size(&big).unwrap_err();
assert_eq!(err.code.code, "OL-4223");
}
#[test]
fn ensure_json_rejects_non_json() {
let err = ensure_json(b"<html>oops").unwrap_err();
assert_eq!(err.code.code, "OL-4221");
}
#[test]
fn ensure_json_accepts_object() {
ensure_json(b"{\"riskScore\":1}").unwrap();
}
}