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