1use std::collections::BTreeSet;
7
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10
11use crate::{
12 ContextDispositionV1, DataClassification, EngineContextPlanRequestV1,
13 EngineContextPlanResponseV1, ProtocolReference, Sha256Digest, SourceId, UtcTimestamp,
14 ValidationError,
15};
16
17pub const MAX_ENGINE_SOURCE_PLAN_REQUEST_BYTES: usize = 1024 * 1024;
18pub const MAX_ENGINE_SOURCE_PLAN_SOURCES: usize = 64;
19pub const MAX_ENGINE_SOURCE_CONTENT_BYTES: usize = 64 * 1024;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum EngineContextSourceTypeV1 {
24 Filesystem,
25 IssueTracker,
26 RelationalDatabase,
27 Other,
28}
29
30#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32pub enum EngineContextSourcePermissionV1 {
33 Permitted,
34 Denied,
35 #[default]
36 Unknown,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(deny_unknown_fields)]
42pub struct EngineContextSourceDescriptorV1 {
43 pub object_ref: ProtocolReference,
44 pub source_id: SourceId,
45 pub source_type: EngineContextSourceTypeV1,
46 pub content_digest: Sha256Digest,
47 pub revision: Option<ProtocolReference>,
48 pub owner: Option<ProtocolReference>,
49 pub observed_at: Option<UtcTimestamp>,
50 pub valid_until: Option<UtcTimestamp>,
51 pub classification: Option<DataClassification>,
52 #[serde(default)]
53 pub permission: EngineContextSourcePermissionV1,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(deny_unknown_fields)]
58pub struct EngineContextSourceV1 {
59 pub descriptor: EngineContextSourceDescriptorV1,
60 pub content: String,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(deny_unknown_fields)]
66pub struct EngineContextSourcePlanRequestV1 {
67 pub planning: EngineContextPlanRequestV1,
68 pub sources: Vec<EngineContextSourceV1>,
69}
70
71impl EngineContextSourcePlanRequestV1 {
72 pub fn validate_payload(&self) -> Result<(), ValidationError> {
73 self.planning.validate_payload()?;
74 if self.sources.len() > MAX_ENGINE_SOURCE_PLAN_SOURCES {
75 return Err(ValidationError::new("too many Engine source candidates"));
76 }
77 let mut references = BTreeSet::new();
78 for source in &self.sources {
79 if source.content.len() > MAX_ENGINE_SOURCE_CONTENT_BYTES
80 || source.content.trim().is_empty()
81 || !references.insert(&source.descriptor.object_ref)
82 || source.descriptor.content_digest != sha256_digest(source.content.as_bytes())?
83 || matches!((&source.descriptor.observed_at, &source.descriptor.valid_until),
84 (Some(observed), Some(until)) if until <= observed)
85 {
86 return Err(ValidationError::new("invalid Engine source binding"));
87 }
88 }
89 Ok(())
90 }
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
95#[serde(deny_unknown_fields)]
96pub struct EngineContextSourcePlanResponseV1 {
97 pub result: EngineContextPlanResponseV1,
98 pub source_bindings: Vec<EngineContextSourceDescriptorV1>,
99 pub binding_digest: Sha256Digest,
101}
102
103impl EngineContextSourcePlanResponseV1 {
104 pub fn new(
105 result: EngineContextPlanResponseV1,
106 mut source_bindings: Vec<EngineContextSourceDescriptorV1>,
107 ) -> Result<Self, ValidationError> {
108 source_bindings.sort_by(|left, right| left.object_ref.cmp(&right.object_ref));
109 let binding_digest = binding_digest(&result, &source_bindings)?;
110 let response = Self {
111 result,
112 source_bindings,
113 binding_digest,
114 };
115 response.validate_binding()?;
116 Ok(response)
117 }
118
119 pub fn validate_binding(&self) -> Result<(), ValidationError> {
120 self.result.plan.validate()?;
121 if self.result.schema_version != 1
122 || self.result.transport_version != 1
123 || self.result.engine_interface_version.as_str() != "1.0.0"
124 || self.result.plan.projection_digest.is_none()
125 || self
126 .source_bindings
127 .windows(2)
128 .any(|pair| pair[0].object_ref >= pair[1].object_ref)
129 {
130 return Err(ValidationError::new(
131 "invalid Engine source response envelope",
132 ));
133 }
134 let selected = self
135 .result
136 .plan
137 .selections
138 .iter()
139 .filter(|selection| selection.disposition == ContextDispositionV1::Selected)
140 .collect::<Vec<_>>();
141 if selected.len() != self.source_bindings.len()
142 || self.source_bindings.iter().any(|binding| {
143 !selected.iter().any(|selection| {
144 selection.source_ref == binding.object_ref.as_str()
145 && selection.provider == binding.source_id.as_str()
146 && selection.sha256_digest.as_deref()
147 == Some(binding.content_digest.as_str())
148 })
149 })
150 {
151 return Err(ValidationError::new(
152 "Engine source selection binding mismatch",
153 ));
154 }
155 if binding_digest(&self.result, &self.source_bindings)? != self.binding_digest {
156 return Err(ValidationError::new(
157 "Engine source binding digest mismatch",
158 ));
159 }
160 Ok(())
161 }
162}
163
164fn binding_digest(
165 result: &EngineContextPlanResponseV1,
166 sources: &[EngineContextSourceDescriptorV1],
167) -> Result<Sha256Digest, ValidationError> {
168 let value = serde_json::to_value((result, sources))
169 .map_err(|_| ValidationError::new("invalid Engine source binding encoding"))?;
170 let bytes = serde_json::to_vec(&crate::entitlement::sort_json(value))
171 .map_err(|_| ValidationError::new("invalid Engine source binding encoding"))?;
172 sha256_digest(&bytes)
173}
174
175fn sha256_digest(bytes: &[u8]) -> Result<Sha256Digest, ValidationError> {
176 let hex = Sha256::digest(bytes)
177 .iter()
178 .map(|byte| format!("{byte:02x}"))
179 .collect::<String>();
180 Sha256Digest::new(format!("sha256:{hex}"))
181}