a3s_code_core/evaluation/
identity.rs1use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5use thiserror::Error;
6
7pub const EXECUTION_TARGET_SCHEMA_V1: &str = "a3s.code.execution-target.v1";
8pub const EXECUTION_FRAME_SCHEMA_V1: &str = "a3s.code.execution-frame.v1";
9pub const EVALUATION_MAX_ID_BYTES: usize = 256;
10const MAX_BRANCH_BYTES: usize = 256;
11
12#[derive(Debug, Clone, PartialEq, Eq, Error)]
13pub enum IdentityError {
14 #[error("unsupported identity schema")]
15 UnsupportedSchema,
16 #[error("identity field `{0}` is invalid")]
17 InvalidField(&'static str),
18 #[error("identity digest is invalid")]
19 InvalidDigest,
20 #[error("identity serialization failed: {0}")]
21 Serialization(String),
22}
23
24#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
26#[serde(deny_unknown_fields)]
27pub struct ExecutionTargetV1 {
28 pub schema: String,
29 pub session_id: String,
30 pub run_id: String,
31}
32
33impl ExecutionTargetV1 {
34 pub const fn schema() -> &'static str {
35 EXECUTION_TARGET_SCHEMA_V1
36 }
37
38 pub fn new(session_id: impl Into<String>, run_id: impl Into<String>) -> Self {
39 Self {
40 schema: EXECUTION_TARGET_SCHEMA_V1.to_string(),
41 session_id: session_id.into(),
42 run_id: run_id.into(),
43 }
44 }
45
46 pub fn validate(&self) -> Result<(), IdentityError> {
47 if self.schema != EXECUTION_TARGET_SCHEMA_V1 {
48 return Err(IdentityError::UnsupportedSchema);
49 }
50 validate_id("session_id", &self.session_id)?;
51 validate_id("run_id", &self.run_id)
52 }
53
54 pub fn digest(&self) -> Result<String, IdentityError> {
55 self.validate()?;
56 digest_json("a3s.code.execution-target.identity.v1", self)
57 .map_err(|error| IdentityError::Serialization(error.to_string()))
58 }
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(deny_unknown_fields)]
66pub struct ExecutionFrameV1 {
67 pub schema: String,
68 pub target: ExecutionTargetV1,
69 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub parent: Option<ExecutionTargetV1>,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub branch: Option<String>,
73 pub generation: u64,
74}
75
76impl ExecutionFrameV1 {
77 pub fn root(target: ExecutionTargetV1) -> Self {
78 Self {
79 schema: EXECUTION_FRAME_SCHEMA_V1.to_string(),
80 target,
81 parent: None,
82 branch: None,
83 generation: 0,
84 }
85 }
86
87 pub fn child(target: ExecutionTargetV1, parent: ExecutionTargetV1) -> Self {
88 Self {
89 schema: EXECUTION_FRAME_SCHEMA_V1.to_string(),
90 target,
91 parent: Some(parent),
92 branch: None,
93 generation: 0,
94 }
95 }
96
97 pub fn validate(&self) -> Result<(), IdentityError> {
98 if self.schema != EXECUTION_FRAME_SCHEMA_V1 {
99 return Err(IdentityError::UnsupportedSchema);
100 }
101 self.target.validate()?;
102 if let Some(parent) = &self.parent {
103 parent.validate()?;
104 if parent == &self.target {
105 return Err(IdentityError::InvalidField("parent"));
106 }
107 }
108 if let Some(branch) = &self.branch {
109 if branch.is_empty()
110 || branch.len() > MAX_BRANCH_BYTES
111 || branch.contains('\0')
112 || branch.contains(['\r', '\n'])
113 {
114 return Err(IdentityError::InvalidField("branch"));
115 }
116 }
117 Ok(())
118 }
119
120 pub fn digest(&self) -> Result<String, IdentityError> {
121 self.validate()?;
122 digest_json("a3s.code.execution-frame.identity.v1", self)
123 .map_err(|error| IdentityError::Serialization(error.to_string()))
124 }
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
129#[serde(deny_unknown_fields)]
130pub struct EventCursorV1 {
131 pub sequence: u64,
132}
133
134impl EventCursorV1 {
135 pub const fn new(sequence: u64) -> Self {
136 Self { sequence }
137 }
138
139 pub fn next(self) -> Option<Self> {
140 self.sequence.checked_add(1).map(Self::new)
141 }
142}
143
144pub fn digest_bytes(domain: &str, bytes: &[u8]) -> String {
145 let mut hasher = Sha256::new();
146 hasher.update(domain.as_bytes());
147 hasher.update([0]);
148 hasher.update(bytes);
149 let digest = hasher.finalize();
150 format!("sha256:{digest:x}")
151}
152
153pub fn digest_json<T: Serialize>(domain: &str, value: &T) -> Result<String, serde_json::Error> {
154 let bytes = serde_json::to_vec(value)?;
155 Ok(digest_bytes(domain, &bytes))
156}
157
158pub fn validate_digest(value: &str) -> Result<(), IdentityError> {
159 let Some(hex) = value.strip_prefix("sha256:") else {
160 return Err(IdentityError::InvalidDigest);
161 };
162 if hex.len() != 64
163 || !hex
164 .bytes()
165 .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
166 {
167 return Err(IdentityError::InvalidDigest);
168 }
169 Ok(())
170}
171
172fn validate_id(field: &'static str, value: &str) -> Result<(), IdentityError> {
173 if value.is_empty()
174 || value.len() > EVALUATION_MAX_ID_BYTES
175 || value.contains('\0')
176 || value.contains(['\r', '\n'])
177 {
178 return Err(IdentityError::InvalidField(field));
179 }
180 Ok(())
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186
187 #[test]
188 fn identity_digest_is_domain_separated_and_canonical() {
189 let target = ExecutionTargetV1::new("session-1", "run-1");
190 let first = target.digest().unwrap();
191 let second = digest_json("a3s.code.execution-target.identity.v1", &target).unwrap();
192 assert_eq!(first, second);
193 assert_ne!(
194 digest_bytes("domain-a", b"same"),
195 digest_bytes("domain-b", b"same")
196 );
197 assert!(validate_digest(&first).is_ok());
198 assert!(validate_digest(&first.to_ascii_uppercase()).is_err());
199 }
200
201 #[test]
202 fn frame_rejects_self_parent_and_cursor_overflow() {
203 let target = ExecutionTargetV1::new("session-1", "run-1");
204 let frame = ExecutionFrameV1 {
205 schema: EXECUTION_FRAME_SCHEMA_V1.to_string(),
206 target: target.clone(),
207 parent: Some(target),
208 branch: None,
209 generation: 0,
210 };
211 assert!(matches!(
212 frame.validate(),
213 Err(IdentityError::InvalidField("parent"))
214 ));
215 assert_eq!(EventCursorV1::new(u64::MAX).next(), None);
216 }
217
218 #[test]
219 fn target_rejects_trailing_line_endings_in_identity_fields() {
220 for run_id in ["run-1\n", "run-1\r", "run-1\r\n"] {
221 let target = ExecutionTargetV1::new("session-1", run_id);
222 assert_eq!(
223 target.validate(),
224 Err(IdentityError::InvalidField("run_id"))
225 );
226 }
227 }
228}