1use serde::{Deserialize, Serialize};
4use std::collections::BTreeSet;
5
6const MAX_JSON_INTEGER: u64 = 9_007_199_254_740_991;
7
8fn require(condition: bool, message: &str) -> Result<(), String> {
9 condition.then_some(()).ok_or_else(|| message.to_string())
10}
11
12#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
13#[serde(try_from = "String")]
14pub struct Id(String);
15impl TryFrom<String> for Id {
16 type Error = String;
17 fn try_from(value: String) -> Result<Self, String> {
18 require(
19 !value.is_empty()
20 && value.len() <= 128
21 && value.as_bytes()[0].is_ascii_alphanumeric()
22 && value
23 .bytes()
24 .all(|c| c.is_ascii_alphanumeric() || b"_.-".contains(&c)),
25 "invalid gate identifier",
26 )?;
27 Ok(Self(value))
28 }
29}
30impl Id {
31 pub fn as_str(&self) -> &str {
32 &self.0
33 }
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
37#[serde(try_from = "String")]
38pub struct Digest(String);
39impl TryFrom<String> for Digest {
40 type Error = String;
41 fn try_from(value: String) -> Result<Self, String> {
42 require(
43 value.strip_prefix("sha256:").is_some_and(|hex| {
44 hex.len() == 64
45 && hex
46 .bytes()
47 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
48 }),
49 "invalid SHA-256 digest",
50 )?;
51 Ok(Self(value))
52 }
53}
54impl Digest {
55 pub fn of(bytes: &[u8]) -> Self {
56 use sha2::{Digest as _, Sha256};
57 Self(format!(
58 "sha256:{}",
59 Sha256::digest(bytes)
60 .iter()
61 .map(|b| format!("{b:02x}"))
62 .collect::<String>()
63 ))
64 }
65 pub fn as_str(&self) -> &str {
66 &self.0
67 }
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
71#[serde(try_from = "String")]
72pub struct WirePath(String);
73impl TryFrom<String> for WirePath {
74 type Error = String;
75 fn try_from(value: String) -> Result<Self, String> {
76 require(
77 !value.is_empty() && value.len() <= 1024,
78 "invalid wire path length",
79 )?;
80 for component in value.split('/') {
81 require(
82 !component.is_empty()
83 && component != "."
84 && component != ".."
85 && !component.starts_with(' ')
86 && component.chars().all(|c| {
87 !c.is_control()
88 && !crate::presentation::ambiguous(c)
89 && !"\\:<>\"|?*".contains(c)
90 })
91 && !component.ends_with(['.', ' ']),
92 "invalid wire path component",
93 )?;
94 let base = component
95 .split('.')
96 .next()
97 .unwrap_or("")
98 .to_ascii_uppercase();
99 require(
100 !["CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$"].contains(&base.as_str())
101 && !["COM¹", "COM²", "COM³", "LPT¹", "LPT²", "LPT³"].contains(&base.as_str())
102 && !(base.len() == 4
103 && (base.starts_with("COM") || base.starts_with("LPT"))
104 && matches!(base.as_bytes()[3], b'1'..=b'9')),
105 "reserved wire path component",
106 )?;
107 }
108 Ok(Self(value))
109 }
110}
111impl WirePath {
112 pub fn as_str(&self) -> &str {
113 &self.0
114 }
115}
116
117pub(crate) fn validate_paths<'a>(paths: impl IntoIterator<Item = &'a str>) -> Result<(), String> {
120 let paths: Vec<_> = paths.into_iter().collect();
121 let mut spelling = std::collections::BTreeMap::new();
122 let mut leaves = BTreeSet::new();
123 for path in &paths {
124 require(
125 leaves.insert(path.to_lowercase()),
126 "duplicate filesystem path",
127 )?;
128 let mut prefix = String::new();
129 for part in path.split('/') {
130 if !prefix.is_empty() {
131 prefix.push('/');
132 }
133 prefix.push_str(part);
134 if let Some(prior) = spelling.insert(prefix.to_lowercase(), prefix.clone()) {
135 require(prior == prefix, "case-ambiguous filesystem component")?;
136 }
137 }
138 }
139 for path in paths {
140 for (index, _) in path.match_indices('/') {
141 require(
142 !leaves.contains(&path[..index].to_lowercase()),
143 "file/directory path conflict",
144 )?;
145 }
146 }
147 Ok(())
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
151#[serde(rename_all = "kebab-case")]
152pub enum Stage {
153 PlanApproval,
154 CommandPermission,
155 MilestoneValidation,
156 FinalGate,
157 Merge,
158}
159
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161#[serde(deny_unknown_fields)]
162pub struct GitObject {
163 pub algorithm: String,
164 pub value: String,
165}
166impl GitObject {
167 fn validate(&self) -> Result<(), String> {
168 let length = match self.algorithm.as_str() {
169 "sha1" => 40,
170 "sha256" => 64,
171 _ => return Err("unknown Git object algorithm".into()),
172 };
173 require(
174 self.value.len() == length
175 && self
176 .value
177 .bytes()
178 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)),
179 "invalid Git object digest",
180 )
181 }
182}
183
184#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
185#[serde(rename_all = "camelCase", deny_unknown_fields)]
186pub struct Binding {
187 pub subject_digest: Digest,
188 pub plan_digest: Digest,
189 pub policy_digest: Digest,
190 pub registration_digest: Digest,
191 pub workspace_id: Id,
192}
193
194#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
195#[serde(untagged)]
196pub enum PeerRequestId {
197 Text(String),
198 Number(u64),
199}
200
201#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
202#[serde(
203 tag = "kind",
204 rename_all = "kebab-case",
205 rename_all_fields = "camelCase",
206 deny_unknown_fields
207)]
208pub enum Subject {
209 Plan {
210 revision: u64,
211 plan_digest: Digest,
212 base_commit: GitObject,
213 },
214 Invocation {
215 run_id: Id,
216 peer_session_id: String,
217 tool_call_id: String,
218 peer_request_id: PeerRequestId,
219 action_digest: Digest,
220 options_digest: Digest,
221 cwd_id: Id,
222 },
223 Milestone {
224 milestone_id: Id,
225 start_commit: GitObject,
226 snapshot_digest: Digest,
227 criteria_digest: Digest,
228 },
229 Deliverable {
230 base_commit: GitObject,
231 snapshot_digest: Digest,
232 feature_receipt_digest: Digest,
233 },
234 Integration {
235 live_base_commit: GitObject,
236 candidate_commit: GitObject,
237 integration_tree: GitObject,
238 snapshot_digest: Digest,
239 },
240}
241impl Subject {
242 pub fn stage(&self) -> Stage {
243 match self {
244 Self::Plan { .. } => Stage::PlanApproval,
245 Self::Invocation { .. } => Stage::CommandPermission,
246 Self::Milestone { .. } => Stage::MilestoneValidation,
247 Self::Deliverable { .. } => Stage::FinalGate,
248 Self::Integration { .. } => Stage::Merge,
249 }
250 }
251 fn validate(&self) -> Result<(), String> {
252 match self {
253 Self::Plan {
254 revision,
255 base_commit,
256 ..
257 } => {
258 require(
259 (1..=MAX_JSON_INTEGER).contains(revision),
260 "invalid plan revision",
261 )?;
262 base_commit.validate()
263 }
264 Self::Invocation {
265 peer_session_id,
266 tool_call_id,
267 peer_request_id,
268 ..
269 } => {
270 bounded_text(peer_session_id, 256)?;
271 bounded_text(tool_call_id, 256)?;
272 match peer_request_id {
273 PeerRequestId::Text(text) => bounded_text(text, 256),
274 PeerRequestId::Number(n) => {
275 require(*n <= MAX_JSON_INTEGER, "invalid peer request id")
276 }
277 }
278 }
279 Self::Milestone { start_commit, .. } => start_commit.validate(),
280 Self::Deliverable { base_commit, .. } => base_commit.validate(),
281 Self::Integration {
282 live_base_commit,
283 candidate_commit,
284 integration_tree,
285 ..
286 } => {
287 live_base_commit.validate()?;
288 candidate_commit.validate()?;
289 integration_tree.validate()
290 }
291 }
292 }
293}
294
295#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
296#[serde(deny_unknown_fields)]
297pub struct ArtifactRef {
298 pub path: WirePath,
299 pub digest: Digest,
300 pub bytes: u64,
301}
302impl ArtifactRef {
303 fn validate(&self, input: bool) -> Result<(), String> {
304 require(
305 self.bytes <= 64 * 1024 * 1024,
306 "artifact exceeds wire byte limit",
307 )?;
308 require(
309 if input {
310 self.path.as_str().starts_with("inputs/")
311 } else {
312 !self.path.as_str().starts_with("outputs/")
313 },
314 "artifact path has the wrong input/output root",
315 )
316 }
317}
318
319#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
320#[serde(rename_all = "camelCase", deny_unknown_fields)]
321pub struct Limits {
322 pub wall_time_ms: u64,
323 pub write_time_ms: u64,
324 pub max_frame_bytes: u64,
325 pub max_stdout_bytes: u64,
326 pub max_stderr_bytes: u64,
327 pub max_artifact_bytes: u64,
328 pub max_artifacts: u64,
329}
330impl Limits {
331 fn validate(&self) -> Result<(), String> {
332 require(
333 (1..=3_600_000).contains(&self.wall_time_ms)
334 && (1..=60_000).contains(&self.write_time_ms),
335 "invalid gate time limits",
336 )?;
337 require(
338 [
339 self.max_frame_bytes,
340 self.max_stdout_bytes,
341 self.max_stderr_bytes,
342 ]
343 .iter()
344 .all(|n| (1..=1_048_576).contains(n)),
345 "invalid gate stream limits",
346 )?;
347 require(
348 (1..=67_108_864).contains(&self.max_artifact_bytes) && self.max_artifacts <= 128,
349 "invalid gate artifact limits",
350 )
351 }
352}
353
354#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
355#[serde(rename_all = "camelCase", deny_unknown_fields)]
356pub struct Parameters {
357 pub schema_version: u64,
358 pub evaluation_id: Id,
359 pub attempt_id: Id,
360 pub gate_id: Id,
361 pub mission_id: Id,
362 pub binding: Binding,
363 pub stage: Stage,
364 pub subject: Subject,
365 pub evidence: ArtifactRef,
366 pub deadline: String,
367 pub limits: Limits,
368}
369#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
370#[serde(deny_unknown_fields)]
371pub struct Request {
372 pub jsonrpc: String,
373 pub id: Id,
374 pub method: String,
375 pub params: Parameters,
376}
377impl Request {
378 pub fn from_bytes(bytes: &[u8]) -> Result<Self, String> {
379 require(bytes.len() <= 1_048_576, "request exceeds frame limit")?;
380 let request: Self = decode(bytes)?;
381 request.validate()?;
382 Ok(request)
383 }
384 pub fn validate(&self) -> Result<(), String> {
385 require(
386 self.jsonrpc == "2.0"
387 && self.method == "gate/evaluate"
388 && self.params.schema_version == 1,
389 "unsupported gate request protocol/version",
390 )?;
391 require(
392 self.id == self.params.attempt_id,
393 "JSON-RPC id must equal attemptId",
394 )?;
395 require(
396 self.params.stage == self.params.subject.stage(),
397 "stage/subject mismatch",
398 )?;
399 self.params.subject.validate()?;
400 self.params.evidence.validate(true)?;
401 self.params.limits.validate()?;
402 require(
403 self.params.deadline.len() == 20
404 && self
405 .params
406 .deadline
407 .bytes()
408 .enumerate()
409 .all(|(i, b)| match i {
410 4 | 7 => b == b'-',
411 10 => b == b'T',
412 13 | 16 => b == b':',
413 19 => b == b'Z',
414 _ => b.is_ascii_digit(),
415 }),
416 "deadline must have UTC second precision",
417 )?;
418 chrono::DateTime::parse_from_rfc3339(&self.params.deadline)
419 .map_err(|_| "invalid gate deadline".to_string())?;
420 Ok(())
421 }
422}
423
424#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
425#[serde(rename_all = "kebab-case")]
426pub enum ArtifactRole {
427 Scope,
428 Criteria,
429 Subject,
430 Policy,
431 Registration,
432 Plan,
433 Action,
434 PermissionOptions,
435 FeatureReceipts,
436 Diff,
437 SnapshotInventory,
438 Source,
439 CheckReceipt,
440 PriorFinding,
441 Context,
442}
443#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
444#[serde(rename_all = "kebab-case")]
445pub enum ProducerKind {
446 Engine,
447 IndependentChecker,
448 Worker,
449}
450#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
451#[serde(rename_all = "camelCase", deny_unknown_fields)]
452pub struct Producer {
453 pub kind: ProducerKind,
454 #[serde(
455 default,
456 deserialize_with = "present",
457 skip_serializing_if = "Option::is_none"
458 )]
459 pub run_id: Option<Id>,
460}
461#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
462#[serde(deny_unknown_fields)]
463pub struct InputArtifact {
464 pub id: Id,
465 pub role: ArtifactRole,
466 pub content: ArtifactRef,
467 pub producer: Producer,
468}
469#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
470#[serde(deny_unknown_fields)]
471pub struct LogRange {
472 pub first: u64,
473 pub last: u64,
474}
475#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
476#[serde(rename_all = "camelCase", deny_unknown_fields)]
477pub struct Manifest {
478 pub schema_version: u64,
479 pub mission_id: Id,
480 pub binding: Binding,
481 pub artifacts: Vec<InputArtifact>,
482 #[serde(
483 default,
484 deserialize_with = "present",
485 skip_serializing_if = "Option::is_none"
486 )]
487 pub source_log_range: Option<LogRange>,
488}
489impl Manifest {
490 pub fn from_bytes(bytes: &[u8]) -> Result<Self, String> {
491 require(bytes.len() <= 67_108_864, "manifest exceeds byte limit")?;
492 let manifest: Self = decode(bytes)?;
493 require(
494 manifest.schema_version == 1
495 && !manifest.artifacts.is_empty()
496 && manifest.artifacts.len() <= 100_000,
497 "unsupported or empty evidence manifest",
498 )?;
499 if let Some(range) = &manifest.source_log_range {
500 require(
501 range.first <= range.last && range.last <= MAX_JSON_INTEGER,
502 "invalid source log range",
503 )?;
504 }
505 let mut ids = BTreeSet::new();
506 let mut paths = BTreeSet::new();
507 for artifact in &manifest.artifacts {
508 artifact.content.validate(true)?;
509 require(
510 ids.insert(&artifact.id)
511 && paths.insert(artifact.content.path.as_str().to_lowercase()),
512 "duplicate artifact ID or case-ambiguous path",
513 )?;
514 }
515 require(
516 manifest.artifacts.windows(2).all(|a| a[0].id < a[1].id),
517 "manifest artifacts must be sorted by ID",
518 )?;
519 validate_paths(manifest.artifacts.iter().map(|a| a.content.path.as_str()))?;
520 Ok(manifest)
521 }
522}
523
524#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
525#[serde(rename_all = "camelCase", deny_unknown_fields)]
526pub struct Anchor {
527 pub artifact_id: Id,
528 pub digest: Digest,
529 #[serde(
530 default,
531 deserialize_with = "present",
532 skip_serializing_if = "Option::is_none"
533 )]
534 pub line_start: Option<u64>,
535 #[serde(
536 default,
537 deserialize_with = "present",
538 skip_serializing_if = "Option::is_none"
539 )]
540 pub line_end: Option<u64>,
541}
542#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
543#[serde(rename_all = "lowercase")]
544pub enum Severity {
545 Info,
546 Low,
547 Medium,
548 High,
549 Critical,
550}
551#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
552#[serde(deny_unknown_fields)]
553pub struct Finding {
554 pub id: Id,
555 pub severity: Severity,
556 pub summary: String,
557 pub evidence: Vec<Anchor>,
558}
559#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
560#[serde(rename_all = "lowercase")]
561pub enum Status {
562 Judged,
563 Escalate,
564}
565#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
566#[serde(rename_all = "lowercase")]
567pub enum Verdict {
568 Pass,
569 Fail,
570}
571#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
572#[serde(rename_all = "camelCase", deny_unknown_fields)]
573pub struct EvaluationResult {
574 pub schema_version: u64,
575 pub evaluation_id: Id,
576 pub attempt_id: Id,
577 pub binding: Binding,
578 pub evidence_digest: Digest,
579 pub status: Status,
580 #[serde(
581 default,
582 deserialize_with = "present",
583 skip_serializing_if = "Option::is_none"
584 )]
585 pub verdict: Option<Verdict>,
586 pub rationale: String,
587 pub artifacts: Vec<ArtifactRef>,
588 #[serde(
589 default,
590 deserialize_with = "present",
591 skip_serializing_if = "Option::is_none"
592 )]
593 pub findings: Option<Vec<Finding>>,
594 #[serde(
595 default,
596 deserialize_with = "present",
597 skip_serializing_if = "Option::is_none"
598 )]
599 pub confidence: Option<f64>,
600}
601impl EvaluationResult {
602 fn validate(&self) -> Result<(), String> {
603 require(
604 self.schema_version == 1 && ((self.status == Status::Judged) == self.verdict.is_some()),
605 "invalid judged/escalate result",
606 )?;
607 bounded_text(&self.rationale, 8192)?;
608 require(
609 self.artifacts.len() <= 128 && self.findings.as_ref().is_none_or(|f| f.len() <= 128),
610 "too many result artifacts/findings",
611 )?;
612 let mut paths = BTreeSet::new();
613 for artifact in &self.artifacts {
614 artifact.validate(false)?;
615 require(
616 paths.insert(artifact.path.as_str().to_lowercase()),
617 "duplicate output artifact path",
618 )?;
619 }
620 validate_paths(self.artifacts.iter().map(|a| a.path.as_str()))?;
621 if let Some(confidence) = self.confidence {
622 require(
623 confidence.is_finite() && (0.0..=1.0).contains(&confidence),
624 "invalid confidence",
625 )?;
626 }
627 let mut ids = BTreeSet::new();
628 for finding in self.findings.iter().flatten() {
629 require(ids.insert(&finding.id), "duplicate finding ID")?;
630 bounded_text(&finding.summary, 4096)?;
631 require(
632 !finding.evidence.is_empty() && finding.evidence.len() <= 32,
633 "finding requires bounded evidence anchors",
634 )?;
635 for anchor in &finding.evidence {
636 match (anchor.line_start, anchor.line_end) {
637 (None, None) => {}
638 (Some(start), Some(end)) => require(
639 start >= 1 && start <= end && end <= MAX_JSON_INTEGER,
640 "invalid finding line range",
641 )?,
642 _ => return Err("finding line bounds must be paired".into()),
643 }
644 }
645 }
646 Ok(())
647 }
648}
649#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
650#[serde(rename_all = "camelCase", deny_unknown_fields)]
651pub struct ErrorData {
652 #[serde(
653 default,
654 deserialize_with = "present",
655 skip_serializing_if = "Option::is_none"
656 )]
657 pub evaluation_id: Option<Id>,
658 #[serde(
659 default,
660 deserialize_with = "present",
661 skip_serializing_if = "Option::is_none"
662 )]
663 pub attempt_id: Option<Id>,
664}
665#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
666#[serde(deny_unknown_fields)]
667pub struct RpcError {
668 pub code: i64,
669 pub message: String,
670 #[serde(
671 default,
672 deserialize_with = "present",
673 skip_serializing_if = "Option::is_none"
674 )]
675 pub data: Option<ErrorData>,
676}
677#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
678#[serde(deny_unknown_fields)]
679pub struct SuccessResponse {
680 pub jsonrpc: String,
681 pub id: Id,
682 pub result: EvaluationResult,
683}
684#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
685#[serde(deny_unknown_fields)]
686pub struct ErrorResponse {
687 pub jsonrpc: String,
688 #[serde(deserialize_with = "required_nullable")]
689 pub id: Option<Id>,
690 pub error: RpcError,
691}
692#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
693#[serde(untagged)]
694pub enum Response {
695 Result(Box<SuccessResponse>),
696 Error(ErrorResponse),
697}
698impl Response {
699 pub fn from_bytes(bytes: &[u8]) -> Result<Self, String> {
700 require(bytes.len() <= 1_048_576, "response exceeds frame limit")?;
701 let response: Self = decode(bytes)?;
702 match &response {
703 Self::Result(r) => {
704 require(r.jsonrpc == "2.0", "invalid JSON-RPC version")?;
705 r.result.validate()?;
706 }
707 Self::Error(r) => {
708 require(
709 r.jsonrpc == "2.0"
710 && [
711 -32700, -32600, -32601, -32602, -32603, 1001, 1002, 1003, 1004,
712 ]
713 .contains(&r.error.code),
714 "invalid gate RPC error",
715 )?;
716 require(
717 r.id.is_some() || [-32700, -32600].contains(&r.error.code),
718 "null ID only identifies a malformed request error",
719 )?;
720 bounded_text(&r.error.message, 4096)?;
721 }
722 }
723 Ok(response)
724 }
725 pub fn correlate(&self, request: &Request) -> Result<(), String> {
726 match self {
727 Self::Result(r) => require(
728 r.id == request.id
729 && r.result.evaluation_id == request.params.evaluation_id
730 && r.result.attempt_id == request.params.attempt_id
731 && r.result.binding == request.params.binding
732 && r.result.evidence_digest == request.params.evidence.digest,
733 "gate response correlation or evidence binding mismatch",
734 ),
735 Self::Error(_) => Err("evaluator could not judge the evidence".into()),
736 }
737 }
738}
739
740fn bounded_text(text: &str, max: usize) -> Result<(), String> {
741 require(
742 !text.is_empty() && text.chars().count() <= max,
743 "invalid bounded text length",
744 )
745}
746pub(super) fn decode<T: serde::de::DeserializeOwned>(bytes: &[u8]) -> Result<T, String> {
747 let mut value =
748 crate::strict_json::parse(bytes).map_err(|e| format!("invalid gate JSON: {e}"))?;
749 normalize_integral_numbers(&mut value);
750 serde_json::from_value(value).map_err(|e| format!("invalid gate wire shape: {e}"))
751}
752
753fn normalize_integral_numbers(value: &mut serde_json::Value) {
756 match value {
757 serde_json::Value::Array(values) => values.iter_mut().for_each(normalize_integral_numbers),
758 serde_json::Value::Object(values) => {
759 values.values_mut().for_each(normalize_integral_numbers)
760 }
761 serde_json::Value::Number(number) if number.is_f64() => {
762 if let Some(n) = number
763 .as_f64()
764 .filter(|n| n.fract() == 0.0 && n.abs() <= MAX_JSON_INTEGER as f64)
765 {
766 *value = serde_json::Value::from(n as i64);
767 }
768 }
769 _ => {}
770 }
771}
772
773fn present<'de, D: serde::Deserializer<'de>, T: Deserialize<'de>>(
775 deserializer: D,
776) -> Result<Option<T>, D::Error> {
777 T::deserialize(deserializer).map(Some)
778}
779fn required_nullable<'de, D: serde::Deserializer<'de>>(
780 deserializer: D,
781) -> Result<Option<Id>, D::Error> {
782 Option::<Id>::deserialize(deserializer)
783}
784
785#[cfg(test)]
786mod tests {
787 use super::*;
788 use serde_json::{json, Value};
789 fn fixture(stage: &str, file: &str) -> Vec<u8> {
790 std::fs::read(
791 std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
792 .join("schemas/fixtures/gate-v1")
793 .join(stage)
794 .join(file),
795 )
796 .unwrap()
797 }
798 #[test]
799 fn gate_eval_v1_all_shipped_stage_fixtures_decode_and_correlate() {
800 for stage in [
801 "plan-approval",
802 "command-permission",
803 "milestone-validation",
804 "final-gate",
805 "merge",
806 ] {
807 let request = Request::from_bytes(&fixture(stage, "request.json")).unwrap();
808 let manifest = Manifest::from_bytes(&fixture(stage, "inputs/manifest.json")).unwrap();
809 assert_eq!(manifest.binding, request.params.binding);
810 for verdict in ["pass.json", "fail.json", "escalate.json"] {
811 let response = Response::from_bytes(&fixture(stage, verdict)).unwrap();
812 response.correlate(&request).unwrap();
813 }
814 }
815 }
816 #[test]
817 fn gate_eval_v1_rejects_authority_fields_nulls_versions_and_correlations() {
818 let original: Value = serde_json::from_slice(&fixture("final-gate", "pass.json")).unwrap();
819 let request = Request::from_bytes(&fixture("final-gate", "request.json")).unwrap();
820 for (key, value) in [
821 ("actor", json!("human:spoof")),
822 ("verdict", Value::Null),
823 ("findings", Value::Null),
824 ("confidence", Value::Null),
825 ("schemaVersion", json!(2)),
826 ] {
827 let mut invalid = original.clone();
828 invalid["result"][key] = value;
829 assert!(
830 Response::from_bytes(&serde_json::to_vec(&invalid).unwrap()).is_err(),
831 "{key}"
832 );
833 }
834 let mut wrong = original.clone();
835 wrong["result"]["attemptId"] = json!("another-attempt");
836 assert!(Response::from_bytes(&serde_json::to_vec(&wrong).unwrap())
837 .unwrap()
838 .correlate(&request)
839 .is_err());
840 assert!(Response::from_bytes(
841 br#"{"jsonrpc":"2.0","error":{"code":-32700,"message":"bad"}}"#
842 )
843 .is_err());
844 assert!(Response::from_bytes(
845 br#"{"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"bad"}}"#
846 )
847 .is_ok());
848 assert!(Response::from_bytes(
849 br#"{"jsonrpc":"2.0","id":null,"error":{"code":1004,"message":"bad"}}"#
850 )
851 .is_err());
852 let mut numeric_request: Value =
853 serde_json::from_slice(&fixture("final-gate", "request.json")).unwrap();
854 numeric_request["params"]["schemaVersion"] = json!(1.0);
855 assert!(Request::from_bytes(&serde_json::to_vec(&numeric_request).unwrap()).is_ok());
856 let mut wrong_request: Value =
857 serde_json::from_slice(&fixture("final-gate", "request.json")).unwrap();
858 wrong_request["params"]["stage"] = json!("merge");
859 assert!(Request::from_bytes(&serde_json::to_vec(&wrong_request).unwrap()).is_err());
860 }
861 #[test]
862 fn gate_eval_v1_rejects_aliases_at_parent_components_and_output_prefixes() {
863 assert!(validate_paths(["inputs/A/one", "inputs/a/two"]).is_err());
864 assert!(validate_paths(["inputs/file", "inputs/file/child"]).is_err());
865 let mut result: Value =
866 serde_json::from_slice(&fixture("final-gate", "pass.json")).unwrap();
867 result["result"]["artifacts"] =
868 json!([{"path":"outputs/claim.txt","digest":Digest::of(b""),"bytes":0}]);
869 assert!(Response::from_bytes(&serde_json::to_vec(&result).unwrap()).is_err());
870 }
871 #[test]
872 fn gate_eval_v1_rejects_ambiguous_paths_and_duplicate_json() {
873 for path in [
874 "../x",
875 "inputs/../x",
876 "/absolute",
877 "inputs/CON.txt",
878 "inputs/nul",
879 "inputs/a.",
880 "a\\b",
881 "inputs/a:b",
882 "inputs/a\n",
883 ] {
884 assert!(WirePath::try_from(path.to_string()).is_err(), "{path}");
885 }
886 assert_eq!(
887 Digest::of(b"abc").as_str(),
888 "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
889 );
890 assert!(Response::from_bytes(br#"{"jsonrpc":"2.0","jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"bad"}}"#).is_err());
891 }
892}