1use crate::error::{Error, Result};
28use crate::field::provenance::json_escape;
29
30const EXTERNAL_MAGIC: &[u8; 8] = b"VOLECTX1";
32const EXTERNAL_FORMAT_VERSION: u8 = 1;
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
39pub enum ExternalOrigin {
40 #[default]
43 Harness,
44 Operator,
46 Catalog,
48}
49
50impl ExternalOrigin {
51 pub const fn name(self) -> &'static str {
53 match self {
54 ExternalOrigin::Harness => "harness",
55 ExternalOrigin::Operator => "operator",
56 ExternalOrigin::Catalog => "catalog",
57 }
58 }
59
60 const fn code(self) -> u8 {
62 match self {
63 ExternalOrigin::Harness => 0,
64 ExternalOrigin::Operator => 1,
65 ExternalOrigin::Catalog => 2,
66 }
67 }
68
69 fn from_code(b: u8) -> Result<Self> {
71 match b {
72 0 => Ok(ExternalOrigin::Harness),
73 1 => Ok(ExternalOrigin::Operator),
74 2 => Ok(ExternalOrigin::Catalog),
75 other => Err(Error::new(
76 crate::ErrorClass::UnsupportedVersion,
77 format!("external-context origin code {other} is not supported"),
78 )),
79 }
80 }
81
82 pub fn parse(s: &str) -> Result<Self> {
84 match s {
85 "harness" => Ok(ExternalOrigin::Harness),
86 "operator" => Ok(ExternalOrigin::Operator),
87 "catalog" => Ok(ExternalOrigin::Catalog),
88 other => Err(Error::usage(format!(
89 "--origin expects harness|operator|catalog, got {other:?}"
90 ))),
91 }
92 }
93}
94
95#[derive(Debug, Clone, Default, PartialEq, Eq)]
98pub struct ExternalLineage {
99 pub family: Option<String>,
102 pub member: Option<String>,
104 pub head: bool,
106 pub revision_family: Option<String>,
108}
109
110#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct ExternalContext {
117 pub dataset_id: Option<String>,
119 pub lineage: ExternalLineage,
121 pub origin: ExternalOrigin,
123 pub source: String,
125}
126
127fn put_opt_str(out: &mut Vec<u8>, s: Option<&str>) {
128 match s {
129 Some(v) => {
130 out.push(1);
131 out.extend_from_slice(&(v.len() as u32).to_le_bytes());
132 out.extend_from_slice(v.as_bytes());
133 }
134 None => out.push(0),
135 }
136}
137
138fn get_opt_str(bytes: &[u8], at: &mut usize) -> Result<Option<String>> {
139 let present = *bytes
140 .get(*at)
141 .ok_or_else(|| Error::usage("truncated external-context record"))?;
142 *at += 1;
143 if present == 0 {
144 return Ok(None);
145 }
146 let len_bytes = bytes
147 .get(*at..*at + 4)
148 .ok_or_else(|| Error::usage("truncated external-context record"))?;
149 let len = u32::from_le_bytes(len_bytes.try_into().unwrap()) as usize;
150 *at += 4;
151 let end = at
152 .checked_add(len)
153 .ok_or_else(|| Error::usage("external-context string overflow"))?;
154 let slice = bytes
155 .get(*at..end)
156 .ok_or_else(|| Error::usage("truncated external-context string"))?;
157 let s = core::str::from_utf8(slice)
158 .map_err(|_| Error::usage("external-context string is not UTF-8"))?
159 .to_string();
160 *at = end;
161 Ok(Some(s))
162}
163
164fn opt_json(v: Option<&str>) -> String {
165 match v {
166 Some(s) => format!("\"{}\"", json_escape(s)),
167 None => "null".to_string(),
168 }
169}
170
171impl ExternalContext {
172 pub fn encode_canonical(&self) -> Vec<u8> {
174 let mut out = Vec::with_capacity(128);
175 out.extend_from_slice(EXTERNAL_MAGIC);
176 out.push(EXTERNAL_FORMAT_VERSION);
177 out.push(self.origin.code());
178 put_opt_str(&mut out, self.dataset_id.as_deref());
179 put_opt_str(&mut out, self.lineage.family.as_deref());
180 put_opt_str(&mut out, self.lineage.member.as_deref());
181 out.push(u8::from(self.lineage.head));
182 put_opt_str(&mut out, self.lineage.revision_family.as_deref());
183 put_opt_str(&mut out, Some(self.source.as_str()));
184 out
185 }
186
187 pub fn decode_canonical(bytes: &[u8]) -> Result<ExternalContext> {
190 if bytes.len() < 10 || &bytes[0..8] != EXTERNAL_MAGIC {
191 return Err(Error::unsupported_version(
192 "external-context record: bad magic",
193 ));
194 }
195 if bytes[8] != EXTERNAL_FORMAT_VERSION {
196 return Err(Error::unsupported_version(format!(
197 "external-context record version {} is not supported",
198 bytes[8]
199 )));
200 }
201 let origin = ExternalOrigin::from_code(bytes[9])?;
202 let mut at = 10;
203 let dataset_id = get_opt_str(bytes, &mut at)?;
204 let family = get_opt_str(bytes, &mut at)?;
205 let member = get_opt_str(bytes, &mut at)?;
206 let head = match bytes.get(at) {
207 Some(0) => false,
208 Some(1) => true,
209 Some(_) => return Err(Error::usage("external-context head flag is not boolean")),
210 None => return Err(Error::usage("truncated external-context record")),
211 };
212 at += 1;
213 let revision_family = get_opt_str(bytes, &mut at)?;
214 let source = get_opt_str(bytes, &mut at)?.unwrap_or_default();
215 if at != bytes.len() {
216 return Err(Error::usage("external-context record has trailing bytes"));
217 }
218 Ok(ExternalContext {
219 dataset_id,
220 lineage: ExternalLineage {
221 family,
222 member,
223 head,
224 revision_family,
225 },
226 origin,
227 source,
228 })
229 }
230
231 pub fn answer_json(&self) -> String {
235 format!(
236 concat!(
237 "{{",
238 "\"family_id\":{},",
239 "\"member_id\":{},",
240 "\"is_head\":{},",
241 "\"dataset_id\":{},",
242 "\"revision_family\":{},",
243 "\"origin\":\"{}\",",
244 "\"source\":\"{}\"",
245 "}}"
246 ),
247 opt_json(self.lineage.family.as_deref()),
248 opt_json(self.lineage.member.as_deref()),
249 self.lineage.head,
250 opt_json(self.dataset_id.as_deref()),
251 opt_json(self.lineage.revision_family.as_deref()),
252 self.origin.name(),
253 json_escape(&self.source),
254 )
255 }
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261
262 fn sample() -> ExternalContext {
263 ExternalContext {
264 dataset_id: Some("real100-v1".to_string()),
265 lineage: ExternalLineage {
266 family: Some("rev-nist-fips-140".to_string()),
267 member: Some("nist-pdf-0017".to_string()),
268 head: true,
269 revision_family: None,
270 },
271 origin: ExternalOrigin::Harness,
272 source: "real100-v1/manifest.tsv".to_string(),
273 }
274 }
275
276 #[test]
277 fn round_trips_canonically() {
278 let ctx = sample();
279 let bytes = ctx.encode_canonical();
280 assert_eq!(ExternalContext::decode_canonical(&bytes).unwrap(), ctx);
281 assert_eq!(ctx.encode_canonical(), bytes);
283 }
284
285 #[test]
286 fn round_trips_with_absent_fields() {
287 let ctx = ExternalContext {
288 dataset_id: None,
289 lineage: ExternalLineage::default(),
290 origin: ExternalOrigin::Operator,
291 source: String::new(),
292 };
293 let bytes = ctx.encode_canonical();
294 assert_eq!(ExternalContext::decode_canonical(&bytes).unwrap(), ctx);
295 }
296
297 #[test]
298 fn rejects_bad_magic_version_and_trailing_bytes() {
299 let mut bytes = sample().encode_canonical();
300 bytes[0] ^= 0xff;
301 assert_eq!(
302 ExternalContext::decode_canonical(&bytes)
303 .unwrap_err()
304 .class(),
305 crate::ErrorClass::UnsupportedVersion
306 );
307 let mut bytes = sample().encode_canonical();
308 bytes[8] = 9;
309 assert_eq!(
310 ExternalContext::decode_canonical(&bytes)
311 .unwrap_err()
312 .class(),
313 crate::ErrorClass::UnsupportedVersion
314 );
315 let mut bytes = sample().encode_canonical();
316 bytes.push(0);
317 assert_eq!(
318 ExternalContext::decode_canonical(&bytes)
319 .unwrap_err()
320 .class(),
321 crate::ErrorClass::Usage
322 );
323 assert!(ExternalContext::decode_canonical(b"short").is_err());
324 }
325
326 #[test]
327 fn origin_parses_typed_and_rejects_unknown() {
328 assert_eq!(ExternalOrigin::parse("harness").unwrap().name(), "harness");
329 assert_eq!(
330 ExternalOrigin::parse("operator").unwrap().name(),
331 "operator"
332 );
333 assert_eq!(ExternalOrigin::parse("catalog").unwrap().name(), "catalog");
334 assert_eq!(
335 ExternalOrigin::parse("guess").unwrap_err().class(),
336 crate::ErrorClass::Usage
337 );
338 }
339
340 #[test]
341 fn answer_json_names_the_c4b_tuple() {
342 let json = sample().answer_json();
343 assert!(
344 json.contains("\"family_id\":\"rev-nist-fips-140\""),
345 "{json}"
346 );
347 assert!(json.contains("\"member_id\":\"nist-pdf-0017\""), "{json}");
348 assert!(json.contains("\"is_head\":true"), "{json}");
349 assert!(json.contains("\"origin\":\"harness\""), "{json}");
350 }
351}