Skip to main content

agent_toolprint/
types.rs

1//! Receipt + Envelope validation against SPEC §2/§3.
2
3use once_cell::sync::Lazy;
4use regex::Regex;
5use serde_json::Value;
6
7use crate::error::Error;
8
9pub const PROTOCOL_VERSION: &str = "tp/0.1";
10pub const PAYLOAD_TYPE: &str = "application/vnd.agent-toolprint+json";
11
12static HASH_HEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"^sha256:[0-9a-f]{64}$").unwrap());
13static BASE64: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[A-Za-z0-9+/]*={0,2}$").unwrap());
14static NONCE32: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[A-Za-z0-9+/]{43}=$").unwrap());
15static DID_KEY: Lazy<Regex> =
16    Lazy::new(|| Regex::new(r"^did:key:z[1-9A-HJ-NP-Za-km-z]+$").unwrap());
17static UUID_RE: Lazy<Regex> = Lazy::new(|| {
18    Regex::new(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$").unwrap()
19});
20static RFC3339: Lazy<Regex> = Lazy::new(|| {
21    Regex::new(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$").unwrap()
22});
23
24const RECEIPT_ALLOWED: &[&str] = &[
25    "v", "id", "ts", "agent", "tool", "call", "result", "nonce", "parent",
26];
27const RECEIPT_REQUIRED: &[&str] = &["v", "id", "ts", "agent", "tool", "call", "result", "nonce"];
28const PARTY_ALLOWED: &[&str] = &["did", "key_id"];
29const CALL_ALLOWED: &[&str] = &["name", "args_hash"];
30const RESULT_ALLOWED: &[&str] = &["status", "response_hash"];
31const ENVELOPE_ALLOWED: &[&str] = &["payloadType", "payload", "signatures"];
32const SIG_ALLOWED: &[&str] = &["keyid", "sig"];
33
34fn ensure_object<'a>(
35    value: &'a Value,
36    where_: &str,
37) -> Result<&'a serde_json::Map<String, Value>, Error> {
38    value
39        .as_object()
40        .ok_or_else(|| Error::Invalid(format!("{where_}: expected object")))
41}
42
43fn ensure_string<'a>(value: &'a Value, where_: &str) -> Result<&'a str, Error> {
44    value
45        .as_str()
46        .ok_or_else(|| Error::Invalid(format!("{where_}: expected string")))
47}
48
49fn check_pattern(s: &str, re: &Regex, where_: &str) -> Result<(), Error> {
50    if re.is_match(s) {
51        Ok(())
52    } else {
53        Err(Error::Invalid(format!(
54            "{where_}: does not match required pattern"
55        )))
56    }
57}
58
59fn check_keys(
60    obj: &serde_json::Map<String, Value>,
61    allowed: &[&str],
62    where_: &str,
63) -> Result<(), Error> {
64    for k in obj.keys() {
65        if !allowed.contains(&k.as_str()) {
66            return Err(Error::Invalid(format!("{where_}: unknown key {k:?}")));
67        }
68    }
69    Ok(())
70}
71
72fn validate_party(value: &Value, where_: &str) -> Result<(), Error> {
73    let obj = ensure_object(value, where_)?;
74    check_keys(obj, PARTY_ALLOWED, where_)?;
75    let did = obj
76        .get("did")
77        .ok_or_else(|| Error::Invalid(format!("{where_}: missing 'did'")))?;
78    let kid = obj
79        .get("key_id")
80        .ok_or_else(|| Error::Invalid(format!("{where_}: missing 'key_id'")))?;
81    let did_s = ensure_string(did, &format!("{where_}.did"))?;
82    check_pattern(did_s, &DID_KEY, &format!("{where_}.did"))?;
83    let kid_s = ensure_string(kid, &format!("{where_}.key_id"))?;
84    if kid_s.is_empty() {
85        return Err(Error::Invalid(format!(
86            "{where_}.key_id: must be non-empty"
87        )));
88    }
89    Ok(())
90}
91
92pub fn validate_receipt(value: &Value) -> Result<(), Error> {
93    let obj = ensure_object(value, "receipt")?;
94    check_keys(obj, RECEIPT_ALLOWED, "receipt")?;
95    for req in RECEIPT_REQUIRED {
96        if !obj.contains_key(*req) {
97            return Err(Error::Invalid(format!(
98                "receipt: missing required key {req:?}"
99            )));
100        }
101    }
102    if obj["v"].as_str() != Some(PROTOCOL_VERSION) {
103        return Err(Error::Invalid(format!(
104            "receipt.v: expected {PROTOCOL_VERSION:?}, got {:?}",
105            obj["v"]
106        )));
107    }
108    let id = ensure_string(&obj["id"], "receipt.id")?;
109    check_pattern(id, &UUID_RE, "receipt.id")?;
110    let ts = ensure_string(&obj["ts"], "receipt.ts")?;
111    check_pattern(ts, &RFC3339, "receipt.ts")?;
112    validate_party(&obj["agent"], "receipt.agent")?;
113    validate_party(&obj["tool"], "receipt.tool")?;
114
115    let call = ensure_object(&obj["call"], "receipt.call")?;
116    check_keys(call, CALL_ALLOWED, "receipt.call")?;
117    let name = ensure_string(
118        call.get("name")
119            .ok_or_else(|| Error::Invalid("receipt.call: missing 'name'".into()))?,
120        "receipt.call.name",
121    )?;
122    if name.is_empty() {
123        return Err(Error::Invalid(
124            "receipt.call.name: must be non-empty".into(),
125        ));
126    }
127    let args_hash = ensure_string(
128        call.get("args_hash")
129            .ok_or_else(|| Error::Invalid("receipt.call: missing 'args_hash'".into()))?,
130        "receipt.call.args_hash",
131    )?;
132    check_pattern(args_hash, &HASH_HEX, "receipt.call.args_hash")?;
133
134    let result = ensure_object(&obj["result"], "receipt.result")?;
135    check_keys(result, RESULT_ALLOWED, "receipt.result")?;
136    let status = ensure_string(
137        result
138            .get("status")
139            .ok_or_else(|| Error::Invalid("receipt.result: missing 'status'".into()))?,
140        "receipt.result.status",
141    )?;
142    if status != "ok" && status != "error" {
143        return Err(Error::Invalid(format!(
144            "receipt.result.status: must be 'ok' or 'error', got {status:?}"
145        )));
146    }
147    let resp_hash = ensure_string(
148        result
149            .get("response_hash")
150            .ok_or_else(|| Error::Invalid("receipt.result: missing 'response_hash'".into()))?,
151        "receipt.result.response_hash",
152    )?;
153    check_pattern(resp_hash, &HASH_HEX, "receipt.result.response_hash")?;
154
155    let nonce = ensure_string(&obj["nonce"], "receipt.nonce")?;
156    check_pattern(nonce, &NONCE32, "receipt.nonce")?;
157
158    if let Some(parent) = obj.get("parent") {
159        let p = ensure_string(parent, "receipt.parent")?;
160        check_pattern(p, &UUID_RE, "receipt.parent")?;
161    }
162    Ok(())
163}
164
165pub fn validate_envelope(value: &Value) -> Result<(), Error> {
166    let obj = ensure_object(value, "envelope")?;
167    check_keys(obj, ENVELOPE_ALLOWED, "envelope")?;
168    for req in ENVELOPE_ALLOWED {
169        if !obj.contains_key(*req) {
170            return Err(Error::Invalid(format!("envelope: missing key {req:?}")));
171        }
172    }
173    if obj["payloadType"].as_str() != Some(PAYLOAD_TYPE) {
174        return Err(Error::Invalid(format!(
175            "envelope.payloadType: expected {PAYLOAD_TYPE:?}, got {:?}",
176            obj["payloadType"]
177        )));
178    }
179    let payload = ensure_string(&obj["payload"], "envelope.payload")?;
180    check_pattern(payload, &BASE64, "envelope.payload")?;
181
182    let sigs = obj["signatures"]
183        .as_array()
184        .ok_or_else(|| Error::Invalid("envelope.signatures: expected array".into()))?;
185    if sigs.is_empty() || sigs.len() > 2 {
186        return Err(Error::Invalid(format!(
187            "envelope.signatures: must have 1 or 2 entries, got {}",
188            sigs.len()
189        )));
190    }
191    for (i, s) in sigs.iter().enumerate() {
192        let where_ = format!("envelope.signatures[{i}]");
193        let so = ensure_object(s, &where_)?;
194        check_keys(so, SIG_ALLOWED, &where_)?;
195        let kid = ensure_string(
196            so.get("keyid")
197                .ok_or_else(|| Error::Invalid(format!("{where_}: missing 'keyid'")))?,
198            &format!("{where_}.keyid"),
199        )?;
200        if kid.is_empty() {
201            return Err(Error::Invalid(format!("{where_}.keyid: must be non-empty")));
202        }
203        let sig = ensure_string(
204            so.get("sig")
205                .ok_or_else(|| Error::Invalid(format!("{where_}: missing 'sig'")))?,
206            &format!("{where_}.sig"),
207        )?;
208        check_pattern(sig, &BASE64, &format!("{where_}.sig"))?;
209    }
210    Ok(())
211}