1use std::collections::BTreeSet;
17
18use serde::{Deserialize, Serialize};
19
20use crate::{
21 ENGINE_PRESENTER_REF_PREFIX, MAX_ENGINE_SOURCE_MATERIALIZED_CONTEXT_BYTES,
22 MAX_ENGINE_SOURCE_PLAN_SOURCES, MAX_IDENTIFIER_LENGTH, SourceId, ValidationError,
23};
24
25pub const ENGINE_PRESENTED_USE_V1_SCHEMA_ID: &str = "leanctx.engine-presented-use/v1";
27pub const ENGINE_RECOVERY_ID_HEX_CHARS: usize = 64;
29
30const MAX_TEXT_BYTES: usize = MAX_ENGINE_SOURCE_MATERIALIZED_CONTEXT_BYTES;
32const MAX_SOURCE_CONSTRAINT_BYTES: usize =
36 MAX_ENGINE_SOURCE_PLAN_SOURCES * (2 * MAX_IDENTIFIER_LENGTH + 3);
37pub const MAX_ENGINE_PRESENTED_USE_REQUEST_BYTES: usize =
41 2 * MAX_ENGINE_SOURCE_MATERIALIZED_CONTEXT_BYTES + MAX_SOURCE_CONSTRAINT_BYTES + 1024;
42
43const PRESENTER_REF_HEX_CHARS: usize = 32;
44const MAX_REASON_CODE_BYTES: usize = 48;
45
46fn lower_hex(text: &str, length: usize) -> bool {
47 text.len() == length
48 && text
49 .bytes()
50 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
51}
52
53fn is_recovery_id(text: &str) -> bool {
54 lower_hex(text, ENGINE_RECOVERY_ID_HEX_CHARS)
55}
56
57fn is_reason(reason: &str) -> bool {
60 ["lineage.", "recovery."].iter().any(|prefix| {
61 reason.strip_prefix(prefix).is_some_and(|code| {
62 !code.is_empty()
63 && code.len() <= MAX_REASON_CODE_BYTES
64 && code
65 .bytes()
66 .all(|byte| byte.is_ascii_lowercase() || byte == b'_')
67 })
68 })
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73#[serde(deny_unknown_fields, rename_all = "snake_case", tag = "mode")]
74pub enum EnginePresentedUseV1 {
75 Check { content: String },
77 Retain { content: String },
79 Expand { recovery_id: String },
81}
82
83impl EnginePresentedUseV1 {
84 pub fn validate(&self) -> Result<(), ValidationError> {
87 let well_formed = match self {
88 Self::Check { content } | Self::Retain { content } => {
89 !content.is_empty() && content.len() <= MAX_TEXT_BYTES
90 }
91 Self::Expand { recovery_id } => is_recovery_id(recovery_id),
92 };
93 if well_formed { Ok(()) } else { Err(invalid()) }
94 }
95}
96
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99#[serde(deny_unknown_fields)]
100pub struct EnginePresentedUseRequestV1 {
101 pub schema_version: u32,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub authorized_source_ids: Option<Vec<SourceId>>,
110 #[serde(rename = "use")]
111 pub operation: EnginePresentedUseV1,
112}
113
114impl EnginePresentedUseRequestV1 {
115 pub fn validate_payload(&self) -> Result<(), ValidationError> {
118 if self.schema_version != 1 {
119 return Err(invalid());
120 }
121 if let Some(ids) = &self.authorized_source_ids {
122 let distinct: BTreeSet<&SourceId> = ids.iter().collect();
123 if ids.is_empty()
124 || ids.len() > MAX_ENGINE_SOURCE_PLAN_SOURCES
125 || distinct.len() != ids.len()
126 {
127 return Err(invalid());
128 }
129 }
130 self.operation.validate()
131 }
132}
133
134#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138#[serde(deny_unknown_fields)]
139pub struct EnginePresentedUseResponseV1 {
140 pub schema_version: u32,
141 pub presenter_ref: String,
143 pub usable: bool,
144 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub reason: Option<String>,
146 pub source_ids: Vec<String>,
148 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub recovery_id: Option<String>,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub content: Option<String>,
152}
153
154impl EnginePresentedUseResponseV1 {
155 pub fn validate_for(&self, asked: &EnginePresentedUseV1) -> Result<(), ValidationError> {
161 let reference_ok = self
162 .presenter_ref
163 .strip_prefix(ENGINE_PRESENTER_REF_PREFIX)
164 .is_some_and(|hex| lower_hex(hex, PRESENTER_REF_HEX_CHARS));
165 let body_ok = if self.usable {
166 self.is_usable_answer_to(asked)
167 } else {
168 self.is_refusal()
169 };
170 if self.schema_version == 1 && reference_ok && body_ok {
171 Ok(())
172 } else {
173 Err(invalid())
174 }
175 }
176
177 fn is_refusal(&self) -> bool {
178 self.reason.as_deref().is_some_and(is_reason)
179 && self.source_ids.is_empty()
180 && self.recovery_id.is_none()
181 && self.content.is_none()
182 }
183
184 fn is_usable_answer_to(&self, asked: &EnginePresentedUseV1) -> bool {
185 let distinct: BTreeSet<&str> = self.source_ids.iter().map(String::as_str).collect();
188 let sources_ok = !self.source_ids.is_empty()
189 && self.source_ids.len() <= MAX_ENGINE_SOURCE_PLAN_SOURCES
190 && distinct.len() == self.source_ids.len()
191 && self
192 .source_ids
193 .iter()
194 .all(|id| !id.is_empty() && id.len() <= MAX_IDENTIFIER_LENGTH);
195 if self.reason.is_some() || !sources_ok {
196 return false;
197 }
198 match asked {
199 EnginePresentedUseV1::Check { .. } => {
200 self.recovery_id.is_none() && self.content.is_none()
201 }
202 EnginePresentedUseV1::Retain { .. } => {
203 self.recovery_id.as_deref().is_some_and(is_recovery_id) && self.content.is_none()
204 }
205 EnginePresentedUseV1::Expand { recovery_id } => {
206 let text_ok = self
207 .content
208 .as_deref()
209 .is_some_and(|text| text.len() <= MAX_TEXT_BYTES);
210 self.recovery_id.as_deref() == Some(recovery_id.as_str()) && text_ok
211 }
212 }
213 }
214}
215
216fn invalid() -> ValidationError {
217 ValidationError::new("invalid Engine presented use envelope")
218}
219
220#[cfg(test)]
221mod tests {
222 use serde_json::{Value, json};
223
224 use super::*;
225
226 #[test]
232 fn a_use_request_is_closed_and_its_source_constraint_bounded() {
233 let accepted = |request: Value| {
234 serde_json::from_value::<EnginePresentedUseRequestV1>(request)
235 .is_ok_and(|request| request.validate_payload().is_ok())
236 };
237 let check = json!({"mode": "check", "content": "ledger"});
238 let plain = |version: u32, asked: Value| json!({"schema_version": version, "use": asked});
239 let constrained = |ids: Value| json!({"schema_version": 1, "authorized_source_ids": ids, "use": check.clone()});
240 let too_many: Vec<String> = (0..=MAX_ENGINE_SOURCE_PLAN_SOURCES)
241 .map(|index| format!("source-{index}"))
242 .collect();
243 let extra_member = json!({"schema_version": 1, "use": check.clone(), "capability": "x"});
244 let extra_in_use = json!({"mode": "check", "content": "x", "also": 1});
245 let empty_text = json!({"mode": "retain", "content": ""});
246 let path_as_id = json!({"mode": "expand", "recovery_id": "../x"});
247 for (case, request, expected) in [
248 ("no constraint", plain(1, check.clone()), true),
249 ("a set of sources", constrained(json!(["b", "a"])), true),
250 ("an empty set", constrained(json!([])), false),
251 (
252 "a source named twice",
253 constrained(json!(["a", "a"])),
254 false,
255 ),
256 ("an empty id", constrained(json!(["a", ""])), false),
257 (
258 "more sources than a plan has",
259 constrained(json!(too_many)),
260 false,
261 ),
262 (
263 "a constraint that is no set",
264 constrained(json!("a")),
265 false,
266 ),
267 ("an unknown member", extra_member, false),
268 (
269 "an unknown member of the use",
270 plain(1, extra_in_use),
271 false,
272 ),
273 ("another schema version", plain(2, check.clone()), false),
274 ("an empty text", plain(1, empty_text), false),
275 ("a recovery id that is a path", plain(1, path_as_id), false),
276 ] {
277 assert_eq!(accepted(request), expected, "{case}");
278 }
279
280 let recovery_id = "5e".repeat(ENGINE_RECOVERY_ID_HEX_CHARS / 2);
283 let expand = EnginePresentedUseV1::Expand {
284 recovery_id: recovery_id.clone(),
285 };
286 let answer = |usable: bool, reason: Option<&str>| EnginePresentedUseResponseV1 {
287 schema_version: 1,
288 presenter_ref: format!("{ENGINE_PRESENTER_REF_PREFIX}{}", "0a".repeat(16)),
289 usable,
290 reason: reason.map(str::to_owned),
291 source_ids: vec!["source-one".to_owned()],
292 recovery_id: Some(recovery_id.clone()),
293 content: Some("ledger".to_owned()),
294 };
295 let retain = EnginePresentedUseV1::Retain {
296 content: "ledger".to_owned(),
297 };
298 assert!(answer(true, None).validate_for(&expand).is_ok());
299 assert!(answer(true, None).validate_for(&retain).is_err());
300 let mut twice = answer(true, None);
301 twice.source_ids = vec!["source-one".to_owned(), "source-one".to_owned()];
302 assert!(twice.validate_for(&expand).is_err(), "a source named twice");
303 assert!(
304 answer(false, Some("lineage.unknown"))
305 .validate_for(&expand)
306 .is_err(),
307 "text beside a refusal"
308 );
309 }
310}