1use once_cell::sync::Lazy;
2use regex::Regex;
3use serde_json::Value;
4
5static HASH_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^sha256:[0-9a-f]{64}$").unwrap());
6const ROLES: &[&str] = &["user", "assistant", "tool", "system"];
7const TOOL_STATUS: &[&str] = &["ok", "error"];
8
9#[derive(Debug, Clone)]
10pub struct VerifyFailure {
11 pub turn: usize,
12 pub reason: &'static str,
13 pub detail: Option<String>,
14}
15
16#[derive(Debug, Clone)]
17pub struct VerifyResult {
18 pub ok: bool,
19 pub failures: Vec<VerifyFailure>,
20}
21
22fn check_hash(v: Option<&Value>) -> bool {
23 v.and_then(|x| x.as_str())
24 .map(|s| HASH_RE.is_match(s))
25 .unwrap_or(false)
26}
27
28pub fn validate_turn(value: &Value) -> Result<(), String> {
29 let obj = value
30 .as_object()
31 .ok_or_else(|| "not an object".to_string())?;
32 if obj.get("version").and_then(|v| v.as_str()) != Some("scroll/0.1") {
33 return Err("version must be \"scroll/0.1\"".into());
34 }
35 match obj.get("turn").and_then(|v| v.as_u64()) {
36 Some(_) => {}
37 None => return Err("turn must be non-negative integer".into()),
38 }
39 let role = obj.get("role").and_then(|v| v.as_str()).unwrap_or("");
40 if !ROLES.contains(&role) {
41 return Err(format!("role must be one of {ROLES:?}"));
42 }
43 let model = obj
44 .get("model")
45 .and_then(|v| v.as_object())
46 .ok_or("model required")?;
47 if model
48 .get("vendor")
49 .and_then(|v| v.as_str())
50 .filter(|s| !s.is_empty())
51 .is_none()
52 || model
53 .get("id")
54 .and_then(|v| v.as_str())
55 .filter(|s| !s.is_empty())
56 .is_none()
57 {
58 return Err("model.vendor and model.id required".into());
59 }
60 let params = obj
61 .get("params")
62 .and_then(|v| v.as_object())
63 .ok_or("params required")?;
64 for k in ["temperature", "top_p"] {
65 if !params.get(k).map(|v| v.is_number()).unwrap_or(false) {
66 return Err(format!("params.{k} must be number"));
67 }
68 }
69 for k in ["seed", "max_tokens"] {
70 if let Some(v) = params.get(k) {
71 if v.as_i64().is_none() {
72 return Err(format!("params.{k} must be integer"));
73 }
74 }
75 }
76 let msgs = obj
77 .get("messages")
78 .and_then(|v| v.as_array())
79 .ok_or("messages must be array")?;
80 for m in msgs {
81 let mo = m.as_object().ok_or("message not object")?;
82 if !mo.get("role").map(|v| v.is_string()).unwrap_or(false) {
83 return Err("message.role must be string".into());
84 }
85 let c = mo.get("content");
86 if !c.map(|v| v.is_string() || v.is_array()).unwrap_or(false) {
87 return Err("message.content must be string or array".into());
88 }
89 }
90 for k in ["tool_calls", "tool_results"] {
91 if let Some(arr) = obj.get(k) {
92 let arr = arr.as_array().ok_or(format!("{k} must be array"))?;
93 for tc in arr {
94 let tco = tc.as_object().ok_or(format!("{k} item not object"))?;
95 if !tco.get("id").map(|v| v.is_string()).unwrap_or(false) {
96 return Err(format!("{k}.id must be string"));
97 }
98 if k == "tool_calls" {
99 if !tco.get("name").map(|v| v.is_string()).unwrap_or(false) {
100 return Err("tool_calls.name must be string".into());
101 }
102 if !check_hash(tco.get("args_hash")) {
103 return Err("tool_calls.args_hash invalid".into());
104 }
105 } else {
106 let st = tco.get("status").and_then(|v| v.as_str()).unwrap_or("");
107 if !TOOL_STATUS.contains(&st) {
108 return Err("tool_results.status must be ok|error".into());
109 }
110 if !check_hash(tco.get("response_hash")) {
111 return Err("tool_results.response_hash invalid".into());
112 }
113 }
114 }
115 }
116 }
117 if obj.get("timestamp_ns").and_then(|v| v.as_u64()).is_none() {
118 return Err("timestamp_ns must be non-negative integer".into());
119 }
120 if obj.contains_key("prev_hash") && !check_hash(obj.get("prev_hash")) {
121 return Err("prev_hash invalid".into());
122 }
123 Ok(())
124}
125
126pub fn validate_sealed_turn(value: &Value) -> Result<(), String> {
127 validate_turn(value)?;
128 let obj = value.as_object().unwrap();
129 if !check_hash(obj.get("hash")) {
130 return Err("hash invalid".into());
131 }
132 if let Some(sig) = obj.get("sig") {
133 let so = sig.as_object().ok_or("sig not object")?;
134 if so.get("alg").and_then(|v| v.as_str()) != Some("ed25519") {
135 return Err("sig.alg must be \"ed25519\"".into());
136 }
137 if !so.get("pubkey").map(|v| v.is_string()).unwrap_or(false)
138 || !so.get("sig").map(|v| v.is_string()).unwrap_or(false)
139 {
140 return Err("sig.pubkey and sig.sig required".into());
141 }
142 }
143 Ok(())
144}