1use std::collections::{BTreeMap, BTreeSet};
8use std::fmt;
9
10use serde::de::{MapAccess, SeqAccess, Visitor};
11use serde::{Deserialize, Serialize};
12use serde_json::{Map, Value};
13
14use crate::{
15 CapabilityId, ProtocolReference, SemanticVersion, Sha256Digest, ValidationError,
16 deserialize_schema_version, validate_bounded_opaque_identifier, validate_schema_version,
17};
18
19pub const MAX_INVOCATION_EVIDENCE_ITEMS: usize = 64;
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum InvocationSourceRoleV1 {
26 Input,
28 Context,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(deny_unknown_fields)]
35pub struct InvocationSourceBindingV1 {
36 pub source_ref: ProtocolReference,
37 pub digest: Sha256Digest,
38 pub role: InvocationSourceRoleV1,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
43#[serde(rename_all = "snake_case")]
44pub enum InvocationPolicyRoleV1 {
45 TaskRegion,
46 TaskModel,
47 PlanDecision,
48 InvocationAdmission,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(deny_unknown_fields)]
54pub struct InvocationPolicyBindingV1 {
55 pub policy_ref: ProtocolReference,
56 pub digest: Sha256Digest,
57 pub role: InvocationPolicyRoleV1,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(deny_unknown_fields)]
63pub struct InvocationCapabilityBindingV1 {
64 pub capability_id: CapabilityId,
65 pub capability_version: SemanticVersion,
66 pub manifest_digest: Sha256Digest,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(deny_unknown_fields)]
72pub struct InvocationEngineReceiptBindingV1 {
73 pub receipt_ref: ProtocolReference,
75 pub receipt_digest: Sha256Digest,
77}
78
79impl InvocationEngineReceiptBindingV1 {
80 fn validate(&self) -> Result<(), ValidationError> {
81 validate_manifest_reference(&self.receipt_ref, "receipt_ref")?;
82 let expected = format!("receipt:{}", self.receipt_digest.as_str());
83 if self.receipt_ref.as_str() != expected {
84 return Err(ValidationError::new(
85 "InvocationEngineReceiptBindingV1 receipt_ref must equal receipt:<receipt_digest>",
86 ));
87 }
88 Ok(())
89 }
90}
91
92fn validate_manifest_reference(
93 reference: &ProtocolReference,
94 field: &str,
95) -> Result<(), ValidationError> {
96 reject_manifest_feff(reference.as_str(), field)
97}
98
99fn reject_manifest_feff(value: &str, field: &str) -> Result<(), ValidationError> {
100 if value.contains('\u{feff}') {
101 return Err(ValidationError::new(format!(
102 "{field} must not contain U+FEFF"
103 )));
104 }
105 Ok(())
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110#[serde(deny_unknown_fields)]
111pub struct InvocationEvidenceManifestV1 {
112 #[serde(deserialize_with = "deserialize_schema_version")]
113 pub schema_version: u32,
114 pub invocation_ref: Sha256Digest,
116 pub engine_receipt: InvocationEngineReceiptBindingV1,
117 pub source_bindings: Vec<InvocationSourceBindingV1>,
119 pub policy_bindings: Vec<InvocationPolicyBindingV1>,
121 pub capability_bindings: Vec<InvocationCapabilityBindingV1>,
123}
124
125impl InvocationEvidenceManifestV1 {
126 pub const SCHEMA_VERSION: u32 = 1;
128
129 pub fn canonical_bytes(&self) -> Result<Vec<u8>, ValidationError> {
131 self.validate()?;
132 canonical_value(self)
133 }
134
135 pub fn canonical_json(&self) -> Result<String, ValidationError> {
137 String::from_utf8(self.canonical_bytes()?)
138 .map_err(|error| ValidationError::new(format!("manifest canonical UTF-8: {error}")))
139 }
140
141 pub fn from_canonical_bytes(bytes: &[u8]) -> Result<Self, ValidationError> {
143 let value = strict_json_value(bytes)?;
144 let manifest = serde_json::from_value::<Self>(value).map_err(|error| {
145 ValidationError::new(format!("decode invocation evidence manifest: {error}"))
146 })?;
147 if manifest.canonical_bytes()? != bytes {
148 return Err(ValidationError::new(
149 "invocation evidence manifest JSON is not canonical UTF-8, compactness or key order",
150 ));
151 }
152 Ok(manifest)
153 }
154
155 pub fn from_bytes(bytes: &[u8]) -> Result<Self, ValidationError> {
157 Self::from_canonical_bytes(bytes)
158 }
159
160 pub fn validate(&self) -> Result<(), ValidationError> {
162 validate_schema_version(self.schema_version)?;
163 self.engine_receipt.validate()?;
164 validate_source_bindings(&self.source_bindings)?;
165 validate_policy_bindings(&self.policy_bindings)?;
166 validate_capability_bindings(&self.capability_bindings)?;
167 Ok(())
168 }
169}
170
171fn validate_source_bindings(bindings: &[InvocationSourceBindingV1]) -> Result<(), ValidationError> {
172 if bindings.is_empty() || bindings.len() > MAX_INVOCATION_EVIDENCE_ITEMS {
173 return Err(ValidationError::new(format!(
174 "source_bindings must contain 1..={MAX_INVOCATION_EVIDENCE_ITEMS} entries"
175 )));
176 }
177 let mut refs = BTreeSet::new();
178 let mut digests = BTreeSet::new();
179 let mut input_count = 0usize;
180 for binding in bindings {
181 validate_manifest_reference(&binding.source_ref, "source_ref")?;
182 if binding.source_ref.as_str().trim().is_empty() {
183 return Err(ValidationError::new(
184 "InvocationSourceBindingV1 source_ref must not be empty",
185 ));
186 }
187 if !refs.insert(binding.source_ref.as_str()) {
188 return Err(ValidationError::new(
189 "source_bindings source_ref values must be unique",
190 ));
191 }
192 if !digests.insert(binding.digest.as_str()) {
193 return Err(ValidationError::new(
194 "source_bindings digest values must be unique",
195 ));
196 }
197 if binding.role == InvocationSourceRoleV1::Input {
198 input_count += 1;
199 }
200 }
201 if input_count != 1 {
202 return Err(ValidationError::new(
203 "source_bindings must contain exactly one input",
204 ));
205 }
206 Ok(())
207}
208
209fn validate_policy_bindings(bindings: &[InvocationPolicyBindingV1]) -> Result<(), ValidationError> {
210 if bindings.is_empty() || bindings.len() > 4 {
211 return Err(ValidationError::new(
212 "policy_bindings must contain 1..=4 entries",
213 ));
214 }
215 let mut refs = BTreeMap::new();
216 let mut digests = BTreeMap::new();
217 let mut binding_keys = BTreeSet::new();
218 let mut roles = BTreeSet::new();
219 let mut admission_ref = None;
220 let mut admission_digest = None;
221 for binding in bindings {
222 validate_manifest_reference(&binding.policy_ref, "policy_ref")?;
223 if binding.policy_ref.as_str().trim().is_empty() {
224 return Err(ValidationError::new(
225 "InvocationPolicyBindingV1 policy_ref must not be empty",
226 ));
227 }
228 let binding_key = (
229 binding.policy_ref.as_str(),
230 binding.digest.as_str(),
231 binding.role,
232 );
233 if !binding_keys.insert(binding_key) {
234 return Err(ValidationError::new(
235 "policy_bindings contains a duplicate identical binding",
236 ));
237 }
238 if !roles.insert(binding.role) {
239 return Err(ValidationError::new(
240 "policy_bindings role values must be unique",
241 ));
242 }
243 if binding.role == InvocationPolicyRoleV1::InvocationAdmission {
244 if refs.contains_key(binding.policy_ref.as_str())
245 || digests.contains_key(binding.digest.as_str())
246 {
247 return Err(ValidationError::new(
248 "invocation_admission must not alias another policy role",
249 ));
250 }
251 admission_ref = Some(binding.policy_ref.as_str());
252 admission_digest = Some(binding.digest.as_str());
253 } else if admission_ref == Some(binding.policy_ref.as_str())
254 || admission_digest == Some(binding.digest.as_str())
255 {
256 return Err(ValidationError::new(
257 "optional policy roles must not alias invocation_admission",
258 ));
259 }
260 if refs
261 .get(binding.policy_ref.as_str())
262 .is_some_and(|digest| *digest != binding.digest.as_str())
263 {
264 return Err(ValidationError::new(
265 "policy_bindings policy_ref values must keep one digest",
266 ));
267 }
268 refs.insert(binding.policy_ref.as_str(), binding.digest.as_str());
269 if digests
270 .get(binding.digest.as_str())
271 .is_some_and(|policy_ref| *policy_ref != binding.policy_ref.as_str())
272 {
273 return Err(ValidationError::new(
274 "policy_bindings digest values must keep one policy_ref",
275 ));
276 }
277 digests.insert(binding.digest.as_str(), binding.policy_ref.as_str());
278 }
279 if !roles.contains(&InvocationPolicyRoleV1::InvocationAdmission) {
280 return Err(ValidationError::new(
281 "policy_bindings must include exactly one invocation_admission role",
282 ));
283 }
284 Ok(())
285}
286
287fn validate_capability_bindings(
288 bindings: &[InvocationCapabilityBindingV1],
289) -> Result<(), ValidationError> {
290 if bindings.is_empty() || bindings.len() > MAX_INVOCATION_EVIDENCE_ITEMS {
291 return Err(ValidationError::new(format!(
292 "capability_bindings must contain 1..={MAX_INVOCATION_EVIDENCE_ITEMS} entries"
293 )));
294 }
295 let mut keys = BTreeSet::new();
296 let mut digests = BTreeSet::new();
297 for binding in bindings {
298 reject_manifest_feff(binding.capability_id.as_str(), "capability_id")?;
299 validate_bounded_opaque_identifier(
300 binding.capability_id.as_str(),
301 "InvocationCapabilityBindingV1 capability_id",
302 )?;
303 let key = (
304 binding.capability_id.as_str(),
305 binding.capability_version.as_str(),
306 );
307 if !keys.insert(key) {
308 return Err(ValidationError::new(
309 "capability_bindings capability_id/version pairs must be unique",
310 ));
311 }
312 if !digests.insert(binding.manifest_digest.as_str()) {
313 return Err(ValidationError::new(
314 "capability_bindings manifest_digest values must be unique",
315 ));
316 }
317 }
318 Ok(())
319}
320
321fn canonical_value<T: Serialize>(value: &T) -> Result<Vec<u8>, ValidationError> {
322 let value = serde_json::to_value(value).map_err(|error| {
323 ValidationError::new(format!("serialize invocation evidence manifest: {error}"))
324 })?;
325 serde_json::to_vec(&sort_json(value)).map_err(|error| {
326 ValidationError::new(format!(
327 "canonicalize invocation evidence manifest: {error}"
328 ))
329 })
330}
331
332fn sort_json(value: Value) -> Value {
333 match value {
334 Value::Object(object) => Value::Object(
335 object
336 .into_iter()
337 .map(|(key, value)| (key, sort_json(value)))
338 .collect::<BTreeMap<_, _>>()
339 .into_iter()
340 .collect(),
341 ),
342 Value::Array(values) => Value::Array(values.into_iter().map(sort_json).collect()),
343 scalar => scalar,
344 }
345}
346
347struct StrictJsonValue(Value);
349
350impl<'de> Deserialize<'de> for StrictJsonValue {
351 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
352 where
353 D: serde::Deserializer<'de>,
354 {
355 deserializer.deserialize_any(StrictJsonVisitor)
356 }
357}
358
359struct StrictJsonVisitor;
360
361impl<'de> Visitor<'de> for StrictJsonVisitor {
362 type Value = StrictJsonValue;
363
364 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
365 formatter.write_str("a canonical JSON value with unique object keys")
366 }
367
368 fn visit_bool<E>(self, value: bool) -> Result<Self::Value, E>
369 where
370 E: serde::de::Error,
371 {
372 Ok(StrictJsonValue(Value::Bool(value)))
373 }
374
375 fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
376 where
377 E: serde::de::Error,
378 {
379 Ok(StrictJsonValue(Value::Number(value.into())))
380 }
381
382 fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
383 where
384 E: serde::de::Error,
385 {
386 Ok(StrictJsonValue(Value::Number(value.into())))
387 }
388
389 fn visit_f64<E>(self, _value: f64) -> Result<Self::Value, E>
390 where
391 E: serde::de::Error,
392 {
393 Err(E::custom(
394 "floating-point JSON numbers are not canonical manifest values",
395 ))
396 }
397
398 fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
399 where
400 E: serde::de::Error,
401 {
402 Ok(StrictJsonValue(Value::String(value.to_owned())))
403 }
404
405 fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
406 where
407 E: serde::de::Error,
408 {
409 Ok(StrictJsonValue(Value::String(value)))
410 }
411
412 fn visit_none<E>(self) -> Result<Self::Value, E>
413 where
414 E: serde::de::Error,
415 {
416 Ok(StrictJsonValue(Value::Null))
417 }
418
419 fn visit_unit<E>(self) -> Result<Self::Value, E>
420 where
421 E: serde::de::Error,
422 {
423 Ok(StrictJsonValue(Value::Null))
424 }
425
426 fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
427 where
428 A: SeqAccess<'de>,
429 {
430 let mut values = Vec::new();
431 while let Some(value) = seq.next_element::<StrictJsonValue>()? {
432 values.push(value.0);
433 }
434 Ok(StrictJsonValue(Value::Array(values)))
435 }
436
437 fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
438 where
439 A: MapAccess<'de>,
440 {
441 let mut object = Map::new();
442 while let Some(key) = map.next_key::<String>()? {
443 if object.contains_key(&key) {
444 return Err(serde::de::Error::custom(format!(
445 "duplicate JSON object key {key:?}"
446 )));
447 }
448 let value = map.next_value::<StrictJsonValue>()?;
449 object.insert(key, value.0);
450 }
451 Ok(StrictJsonValue(Value::Object(object)))
452 }
453}
454
455fn strict_json_value(bytes: &[u8]) -> Result<Value, ValidationError> {
456 let mut deserializer = serde_json::Deserializer::from_slice(bytes);
457 let value = StrictJsonValue::deserialize(&mut deserializer)
458 .map_err(|error| ValidationError::new(format!("decode canonical manifest JSON: {error}")))?
459 .0;
460 deserializer.end().map_err(|error| {
461 ValidationError::new(format!("trailing canonical manifest JSON: {error}"))
462 })?;
463 Ok(value)
464}
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469
470 fn digest(hex: char) -> Sha256Digest {
471 Sha256Digest::new(format!("sha256:{}", hex.to_string().repeat(64)))
472 .expect("fixture digest should be valid")
473 }
474
475 fn reference(value: &str) -> ProtocolReference {
476 ProtocolReference::new(value).expect("fixture reference should be valid")
477 }
478
479 fn capability(value: &str) -> CapabilityId {
480 CapabilityId::new(value).expect("fixture capability should be valid")
481 }
482
483 fn version(value: &str) -> SemanticVersion {
484 SemanticVersion::new(value).expect("fixture version should be valid")
485 }
486
487 fn valid_manifest() -> InvocationEvidenceManifestV1 {
488 InvocationEvidenceManifestV1 {
489 schema_version: 1,
490 invocation_ref: digest('a'),
491 engine_receipt: InvocationEngineReceiptBindingV1 {
492 receipt_ref: reference(&format!("receipt:sha256:{}", "b".repeat(64))),
493 receipt_digest: digest('b'),
494 },
495 source_bindings: vec![
496 InvocationSourceBindingV1 {
497 source_ref: reference("source:input"),
498 digest: digest('c'),
499 role: InvocationSourceRoleV1::Input,
500 },
501 InvocationSourceBindingV1 {
502 source_ref: reference("source:context"),
503 digest: digest('d'),
504 role: InvocationSourceRoleV1::Context,
505 },
506 ],
507 policy_bindings: vec![
508 InvocationPolicyBindingV1 {
509 policy_ref: reference("policy:task-region"),
510 digest: digest('e'),
511 role: InvocationPolicyRoleV1::TaskRegion,
512 },
513 InvocationPolicyBindingV1 {
514 policy_ref: reference("policy:task-model"),
515 digest: digest('f'),
516 role: InvocationPolicyRoleV1::TaskModel,
517 },
518 InvocationPolicyBindingV1 {
519 policy_ref: reference("policy:plan-decision"),
520 digest: digest('1'),
521 role: InvocationPolicyRoleV1::PlanDecision,
522 },
523 InvocationPolicyBindingV1 {
524 policy_ref: reference("policy:invocation-admission"),
525 digest: digest('2'),
526 role: InvocationPolicyRoleV1::InvocationAdmission,
527 },
528 ],
529 capability_bindings: vec![InvocationCapabilityBindingV1 {
530 capability_id: capability("capability:engine"),
531 capability_version: version("1.0.0"),
532 manifest_digest: digest('3'),
533 }],
534 }
535 }
536
537 #[test]
538 fn valid_manifest_round_trips_and_is_canonical() {
539 let manifest = valid_manifest();
540 let bytes = manifest.canonical_bytes().expect("manifest canonicalizes");
541 let decoded = InvocationEvidenceManifestV1::from_canonical_bytes(&bytes)
542 .expect("canonical bytes decode");
543 assert_eq!(manifest, decoded);
544 assert_eq!(
545 serde_json::to_vec(&serde_json::from_slice::<Value>(&bytes).unwrap())
546 .unwrap()
547 .len(),
548 bytes.len()
549 );
550 }
551
552 #[test]
553 fn multibyte_reference_within_byte_bound_is_valid() {
554 let mut manifest = valid_manifest();
555 manifest.source_bindings[0].source_ref = reference("source:é");
556 manifest
557 .validate()
558 .expect("short multibyte reference is valid");
559 assert!(CapabilityId::new("é".repeat(128)).is_ok());
560 assert!(CapabilityId::new("é".repeat(129)).is_err());
561 }
562
563 #[test]
564 fn optional_policy_roles_are_valid_but_admission_is_mandatory() {
565 let mut manifest = valid_manifest();
566 manifest
567 .policy_bindings
568 .retain(|binding| binding.role == InvocationPolicyRoleV1::InvocationAdmission);
569 manifest
570 .validate()
571 .expect("admission-only policy set is valid");
572 manifest.policy_bindings.clear();
573 assert!(manifest.validate().is_err());
574 manifest.policy_bindings.push(InvocationPolicyBindingV1 {
575 policy_ref: reference("policy:task-region"),
576 digest: digest('e'),
577 role: InvocationPolicyRoleV1::TaskRegion,
578 });
579 assert!(manifest.validate().is_err());
580 }
581
582 #[test]
583 fn shared_policy_alias_is_valid_across_optional_roles_only() {
584 let mut manifest = valid_manifest();
585 let shared_ref = reference("policy:shared");
586 let shared_digest = digest('a');
587 manifest.policy_bindings = vec![
588 InvocationPolicyBindingV1 {
589 policy_ref: shared_ref.clone(),
590 digest: shared_digest.clone(),
591 role: InvocationPolicyRoleV1::PlanDecision,
592 },
593 InvocationPolicyBindingV1 {
594 policy_ref: reference("policy:task-model"),
595 digest: digest('b'),
596 role: InvocationPolicyRoleV1::TaskModel,
597 },
598 InvocationPolicyBindingV1 {
599 policy_ref: reference("policy:invocation-admission"),
600 digest: digest('c'),
601 role: InvocationPolicyRoleV1::InvocationAdmission,
602 },
603 ];
604 manifest
605 .validate()
606 .expect("one policy may serve two optional roles");
607
608 manifest.policy_bindings[2].policy_ref = shared_ref;
609 manifest.policy_bindings[2].digest = shared_digest;
610 assert!(manifest.validate().is_err());
611 }
612
613 #[test]
614 fn policy_alias_rejects_conflicting_digest_and_duplicate_role() {
615 let mut manifest = valid_manifest();
616 let shared_ref = reference("policy:shared");
617 manifest.policy_bindings = vec![
618 InvocationPolicyBindingV1 {
619 policy_ref: shared_ref.clone(),
620 digest: digest('a'),
621 role: InvocationPolicyRoleV1::PlanDecision,
622 },
623 InvocationPolicyBindingV1 {
624 policy_ref: shared_ref,
625 digest: digest('b'),
626 role: InvocationPolicyRoleV1::InvocationAdmission,
627 },
628 ];
629 assert!(manifest.validate().is_err());
630
631 let mut manifest = valid_manifest();
632 manifest.policy_bindings[1].role = manifest.policy_bindings[0].role;
633 assert!(manifest.validate().is_err());
634 }
635
636 #[test]
637 fn shared_references_reject_whitespace_only_and_c1_controls() {
638 assert!(ProtocolReference::new(" \t\n").is_err());
639 assert!(ProtocolReference::new("\u{0085}").is_err());
640 assert!(CapabilityId::new(" \t\n").is_err());
641 assert!(CapabilityId::new("\u{0085}").is_err());
642 }
643
644 #[test]
645 fn manifest_protocol_references_reject_feff_on_all_paths() {
646 let mut manifest = valid_manifest();
647 manifest.source_bindings[0].source_ref = reference("source:\u{feff}");
648 assert!(manifest.validate().is_err());
649
650 let mut manifest = valid_manifest();
651 manifest.policy_bindings[0].policy_ref = reference("policy:\u{feff}");
652 assert!(manifest.validate().is_err());
653
654 let mut manifest = valid_manifest();
655 manifest.engine_receipt.receipt_ref = reference(&format!(
656 "receipt:\u{feff}{}",
657 manifest.engine_receipt.receipt_digest.as_str()
658 ));
659 assert!(manifest.validate().is_err());
660 }
661
662 #[test]
663 fn manifest_capability_ids_reject_feff() {
664 let mut manifest = valid_manifest();
665 manifest.capability_bindings[0].capability_id = capability("capability:\u{feff}");
666 assert!(manifest.validate().is_err());
667 }
668
669 #[test]
670 fn canonical_decoder_rejects_whitespace_key_order_and_duplicates() {
671 let canonical = valid_manifest().canonical_bytes().expect("canonicalizes");
672 let pretty =
673 serde_json::to_vec_pretty(&serde_json::from_slice::<Value>(&canonical).unwrap())
674 .expect("pretty JSON");
675 assert!(InvocationEvidenceManifestV1::from_canonical_bytes(&pretty).is_err());
676
677 let mut reordered = canonical.clone();
678 reordered.reverse();
679 assert!(InvocationEvidenceManifestV1::from_canonical_bytes(&reordered).is_err());
680
681 let duplicate = br#"{"schema_version":1,"schema_version":1}"#;
682 assert!(InvocationEvidenceManifestV1::from_canonical_bytes(duplicate).is_err());
683 }
684
685 #[test]
686 fn validation_rejects_receipt_ref_mismatch() {
687 let mut manifest = valid_manifest();
688 manifest.engine_receipt.receipt_ref = reference(
689 "receipt:sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
690 );
691 assert!(manifest.validate().is_err());
692 }
693
694 #[test]
695 fn validation_rejects_missing_or_duplicate_input() {
696 let mut manifest = valid_manifest();
697 manifest.source_bindings[0].role = InvocationSourceRoleV1::Context;
698 assert!(manifest.validate().is_err());
699 manifest.source_bindings[0].role = InvocationSourceRoleV1::Input;
700 manifest.source_bindings[1].role = InvocationSourceRoleV1::Input;
701 assert!(manifest.validate().is_err());
702 }
703
704 #[test]
705 fn validation_rejects_policy_role_gaps_and_duplicates() {
706 let mut manifest = valid_manifest();
707 manifest.policy_bindings[0].role = InvocationPolicyRoleV1::TaskModel;
708 assert!(manifest.validate().is_err());
709 manifest = valid_manifest();
710 manifest.policy_bindings[0].policy_ref = manifest.policy_bindings[1].policy_ref.clone();
711 assert!(manifest.validate().is_err());
712 }
713
714 #[test]
715 fn validation_rejects_duplicate_capability_key() {
716 let mut manifest = valid_manifest();
717 manifest
718 .capability_bindings
719 .push(manifest.capability_bindings[0].clone());
720 assert!(manifest.validate().is_err());
721 }
722
723 #[test]
724 fn unknown_fields_are_denied_at_every_binding_level() {
725 let mut value = serde_json::to_value(valid_manifest()).expect("serialize fixture");
726 value["engine_receipt"]["future"] = Value::Bool(true);
727 assert!(serde_json::from_value::<InvocationEvidenceManifestV1>(value).is_err());
728 }
729
730 #[test]
731 fn schema_declares_mandatory_three_stage_conformance() {
732 let schema: Value = serde_json::from_slice(include_bytes!(
733 "../../../../docs/contracts/invocation-evidence-manifest-v1.schema.json"
734 ))
735 .expect("manifest schema should be valid JSON");
736 assert_eq!(schema["x-conformance"]["mode"], "three_stage");
737 assert_eq!(schema["x-conformance"]["schema_validation_required"], true);
738 assert_eq!(
739 schema["x-conformance"]["semantic_validation_required"],
740 true
741 );
742 assert_eq!(
743 schema["x-conformance"]["utf8_bounds"]["keyword"],
744 "x-maxUtf8Bytes"
745 );
746 assert_eq!(
747 schema["x-conformance"]["utf8_bounds"]["schema_enforcement"],
748 "annotation_only"
749 );
750 assert_eq!(
751 schema["x-conformance"]["cross_artifact_join_required"],
752 true
753 );
754 assert_eq!(
755 schema["x-conformance"]["stages"][2]["name"],
756 "cross_artifact_join"
757 );
758 assert_eq!(schema["x-conformance"]["stages"][2]["required"], true);
759 assert_eq!(
760 schema["x-conformance"]["stages"][2]["protocol_decoder_sufficient"],
761 false
762 );
763 let requirements = schema["x-conformance"]["stages"][2]["requirements"]
764 .as_array()
765 .expect("cross-artifact requirements should be an array");
766 assert!(
767 requirements
768 .iter()
769 .any(|requirement| { requirement == "resolve_and_verify_policy_artifact_bytes" })
770 );
771 assert!(requirements.iter().any(|requirement| {
772 requirement == "policy_aliases_repeat_only_exact_ref_and_digest_across_optional_roles"
773 }));
774 assert!(requirements.iter().any(|requirement| {
775 requirement == "policy_conflicting_ref_digest_and_duplicate_role_bindings_rejected"
776 }));
777 assert!(
778 requirements
779 .iter()
780 .any(|requirement| requirement == "policy_duplicate_identical_bindings_rejected")
781 );
782 }
783
784 #[test]
785 fn canonical_golden_and_negative_fixtures_are_exhaustive() {
786 let valid =
787 include_bytes!("../../../../docs/contracts/invocation-evidence-manifest/v1/valid.json");
788 let valid = valid.strip_suffix(b"\n").unwrap_or(valid);
789 let canonical = include_bytes!(
790 "../../../../docs/contracts/invocation-evidence-manifest/v1/canonical.json"
791 );
792 let canonical = canonical.strip_suffix(b"\n").unwrap_or(canonical);
793 let decoded = InvocationEvidenceManifestV1::from_canonical_bytes(valid)
794 .expect("valid fixture should decode canonically");
795 assert_eq!(
796 decoded.canonical_bytes().expect("canonical bytes"),
797 canonical
798 );
799 let optional = include_bytes!(
800 "../../../../docs/contracts/invocation-evidence-manifest/v1/valid-optional-policy.json"
801 );
802 let optional = optional.strip_suffix(b"\n").unwrap_or(optional);
803 let optional_manifest = InvocationEvidenceManifestV1::from_canonical_bytes(optional)
804 .expect("admission-only policy fixture should decode");
805 assert_eq!(optional_manifest.policy_bindings.len(), 1);
806 let shared = include_bytes!(
807 "../../../../docs/contracts/invocation-evidence-manifest/v1/valid-shared-policy-alias.json"
808 );
809 let shared = shared.strip_suffix(b"\n").unwrap_or(shared);
810 let shared_manifest = InvocationEvidenceManifestV1::from_canonical_bytes(shared)
811 .expect("shared policy alias fixture should decode");
812 assert_eq!(shared_manifest.policy_bindings.len(), 3);
813 let multibyte = include_bytes!(
814 "../../../../docs/contracts/invocation-evidence-manifest/v1/valid-multibyte.json"
815 );
816 let multibyte = multibyte.strip_suffix(b"\n").unwrap_or(multibyte);
817 InvocationEvidenceManifestV1::from_canonical_bytes(multibyte)
818 .expect("short multibyte reference fixture should decode");
819
820 for (name, expected) in [
821 ("valid-u2028-reference.json", "source:\u{2028}value"),
822 ("valid-u2029-reference.json", "policy:\u{2029}decision"),
823 ] {
824 let bytes: &[u8] = match name {
825 "valid-u2028-reference.json" => include_bytes!(
826 "../../../../docs/contracts/invocation-evidence-manifest/v1/valid-u2028-reference.json"
827 ),
828 "valid-u2029-reference.json" => include_bytes!(
829 "../../../../docs/contracts/invocation-evidence-manifest/v1/valid-u2029-reference.json"
830 ),
831 _ => unreachable!(),
832 };
833 let bytes = bytes.strip_suffix(b"\n").unwrap_or(bytes);
834 let manifest = InvocationEvidenceManifestV1::from_canonical_bytes(bytes)
835 .expect("U+2028/U+2029 reference fixture should decode");
836 assert!(
837 manifest
838 .source_bindings
839 .iter()
840 .any(|binding| binding.source_ref.as_str() == expected)
841 || manifest
842 .policy_bindings
843 .iter()
844 .any(|binding| binding.policy_ref.as_str() == expected)
845 );
846 }
847
848 let exact_reference = include_bytes!(
849 "../../../../docs/contracts/invocation-evidence-manifest/v1/valid-reference-1024.json"
850 );
851 let exact_reference = exact_reference
852 .strip_suffix(b"\n")
853 .unwrap_or(exact_reference);
854 let exact_manifest = InvocationEvidenceManifestV1::from_canonical_bytes(exact_reference)
855 .expect("exact 1024-byte ProtocolReference fixture should decode");
856 assert_eq!(
857 exact_manifest.source_bindings[0].source_ref.as_str().len(),
858 1024
859 );
860
861 let overlong = include_bytes!(
862 "../../../../docs/contracts/invocation-evidence-manifest/v1/invalid-utf8-byte-bound.json"
863 );
864 let overlong = overlong.strip_suffix(b"\n").unwrap_or(overlong);
865 assert!(InvocationEvidenceManifestV1::from_canonical_bytes(overlong).is_err());
866
867 let overlong_reference = include_bytes!(
868 "../../../../docs/contracts/invocation-evidence-manifest/v1/invalid-reference-1025.json"
869 );
870 let overlong_reference = overlong_reference
871 .strip_suffix(b"\n")
872 .unwrap_or(overlong_reference);
873 assert!(InvocationEvidenceManifestV1::from_canonical_bytes(overlong_reference).is_err());
874
875 for name in [
876 "invalid-unknown-field.json",
877 "invalid-receipt-ref-mismatch.json",
878 "invalid-source-input-count.json",
879 "invalid-policy-role.json",
880 "invalid-capability-key.json",
881 "invalid-duplicate-source-ref.json",
882 "invalid-duplicate-source-digest.json",
883 "invalid-duplicate-policy-digest.json",
884 "invalid-duplicate-policy-role.json",
885 "invalid-duplicate-policy-ref.json",
886 "invalid-duplicate-capability-manifest-digest.json",
887 "invalid-missing-invocation-admission.json",
888 "invalid-whitespace-reference.json",
889 "invalid-c1-control-reference.json",
890 "invalid-feff-source-reference.json",
891 "invalid-feff-policy-reference.json",
892 "invalid-feff-receipt-reference.json",
893 "invalid-feff-capability-id.json",
894 "invalid-schema-version-float.json",
895 "invalid-policy-alias-conflicting-digest.json",
896 "invalid-policy-alias-duplicate-role.json",
897 "invalid-policy-alias-admission.json",
898 ] {
899 let bytes: &[u8] = match name {
900 "invalid-unknown-field.json" => include_bytes!(
901 "../../../../docs/contracts/invocation-evidence-manifest/v1/invalid-unknown-field.json"
902 ),
903 "invalid-receipt-ref-mismatch.json" => include_bytes!(
904 "../../../../docs/contracts/invocation-evidence-manifest/v1/invalid-receipt-ref-mismatch.json"
905 ),
906 "invalid-source-input-count.json" => include_bytes!(
907 "../../../../docs/contracts/invocation-evidence-manifest/v1/invalid-source-input-count.json"
908 ),
909 "invalid-policy-role.json" => include_bytes!(
910 "../../../../docs/contracts/invocation-evidence-manifest/v1/invalid-policy-role.json"
911 ),
912 "invalid-capability-key.json" => include_bytes!(
913 "../../../../docs/contracts/invocation-evidence-manifest/v1/invalid-capability-key.json"
914 ),
915 "invalid-duplicate-source-ref.json" => include_bytes!(
916 "../../../../docs/contracts/invocation-evidence-manifest/v1/invalid-duplicate-source-ref.json"
917 ),
918 "invalid-duplicate-source-digest.json" => include_bytes!(
919 "../../../../docs/contracts/invocation-evidence-manifest/v1/invalid-duplicate-source-digest.json"
920 ),
921 "invalid-duplicate-policy-digest.json" => include_bytes!(
922 "../../../../docs/contracts/invocation-evidence-manifest/v1/invalid-duplicate-policy-digest.json"
923 ),
924 "invalid-duplicate-policy-role.json" => include_bytes!(
925 "../../../../docs/contracts/invocation-evidence-manifest/v1/invalid-duplicate-policy-role.json"
926 ),
927 "invalid-duplicate-policy-ref.json" => include_bytes!(
928 "../../../../docs/contracts/invocation-evidence-manifest/v1/invalid-duplicate-policy-ref.json"
929 ),
930 "invalid-duplicate-capability-manifest-digest.json" => include_bytes!(
931 "../../../../docs/contracts/invocation-evidence-manifest/v1/invalid-duplicate-capability-manifest-digest.json"
932 ),
933 "invalid-missing-invocation-admission.json" => include_bytes!(
934 "../../../../docs/contracts/invocation-evidence-manifest/v1/invalid-missing-invocation-admission.json"
935 ),
936 "invalid-whitespace-reference.json" => include_bytes!(
937 "../../../../docs/contracts/invocation-evidence-manifest/v1/invalid-whitespace-reference.json"
938 ),
939 "invalid-c1-control-reference.json" => include_bytes!(
940 "../../../../docs/contracts/invocation-evidence-manifest/v1/invalid-c1-control-reference.json"
941 ),
942 "invalid-feff-source-reference.json" => include_bytes!(
943 "../../../../docs/contracts/invocation-evidence-manifest/v1/invalid-feff-source-reference.json"
944 ),
945 "invalid-feff-policy-reference.json" => include_bytes!(
946 "../../../../docs/contracts/invocation-evidence-manifest/v1/invalid-feff-policy-reference.json"
947 ),
948 "invalid-feff-receipt-reference.json" => include_bytes!(
949 "../../../../docs/contracts/invocation-evidence-manifest/v1/invalid-feff-receipt-reference.json"
950 ),
951 "invalid-feff-capability-id.json" => include_bytes!(
952 "../../../../docs/contracts/invocation-evidence-manifest/v1/invalid-feff-capability-id.json"
953 ),
954 "invalid-schema-version-float.json" => include_bytes!(
955 "../../../../docs/contracts/invocation-evidence-manifest/v1/invalid-schema-version-float.json"
956 ),
957 "invalid-policy-alias-conflicting-digest.json" => include_bytes!(
958 "../../../../docs/contracts/invocation-evidence-manifest/v1/invalid-policy-alias-conflicting-digest.json"
959 ),
960 "invalid-policy-alias-duplicate-role.json" => include_bytes!(
961 "../../../../docs/contracts/invocation-evidence-manifest/v1/invalid-policy-alias-duplicate-role.json"
962 ),
963 "invalid-policy-alias-admission.json" => include_bytes!(
964 "../../../../docs/contracts/invocation-evidence-manifest/v1/invalid-policy-alias-admission.json"
965 ),
966 _ => unreachable!(),
967 };
968 let bytes = bytes.strip_suffix(b"\n").unwrap_or(bytes);
969 assert!(
970 InvocationEvidenceManifestV1::from_canonical_bytes(bytes).is_err(),
971 "{name} must fail closed"
972 );
973 }
974 }
975}