Skip to main content

lean_ctx_protocol/
engine_presented_use.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! `context-presented-use`: what the holder of a presenter capability asks
4//! LATER about text that was delivered under a presented lineage.
5//!
6//! Additive to the v2 materialization exchange; neither that exchange nor v1
7//! changes. The capability is never part of this JSON: it reaches the Engine
8//! through `--presenter-capability-file`.
9//!
10//! A request may carry a source constraint — the ids of exactly the sources
11//! the sender has authorized the request for. The Engine then answers only
12//! for a lineage of exactly these sources, and nothing is checked, stored or
13//! returned for any other. An Engine that does not know a member of the
14//! request refuses it unread.
15
16use 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
25/// Schema id of the presented-use exchange.
26pub const ENGINE_PRESENTED_USE_V1_SCHEMA_ID: &str = "leanctx.engine-presented-use/v1";
27/// Lowercase hex characters of a recovery id.
28pub const ENGINE_RECOVERY_ID_HEX_CHARS: usize = 64;
29
30/// A delivered text is at most one materialized context.
31const MAX_TEXT_BYTES: usize = MAX_ENGINE_SOURCE_MATERIALIZED_CONTEXT_BYTES;
32/// The source constraint as JSON: every id a string of at most two bytes a
33/// byte (quote and backslash are escaped; control characters are not ids),
34/// with its quotes and separator.
35const MAX_SOURCE_CONSTRAINT_BYTES: usize =
36    MAX_ENGINE_SOURCE_PLAN_SOURCES * (2 * MAX_IDENTIFIER_LENGTH + 3);
37/// Bound of a serialized use request: the delivered text as a JSON string
38/// (quotes, backslashes and line breaks take two bytes), the source
39/// constraint and the envelope.
40pub 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
57/// A refusal reason is one of the Engine's fixed, content-free codes:
58/// `lineage.*` or `recovery.*`, lowercase letters and underscores.
59fn 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/// What the holder of a capability asks (the `use` member).
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73#[serde(deny_unknown_fields, rename_all = "snake_case", tag = "mode")]
74pub enum EnginePresentedUseV1 {
75    /// May this exact delivered text still be used by this holder now?
76    Check { content: String },
77    /// `Check`, and keep a recovery copy of the text bound to its lineage.
78    Retain { content: String },
79    /// Hand back the text of a recovery copy, under current authority only.
80    Expand { recovery_id: String },
81}
82
83impl EnginePresentedUseV1 {
84    /// Form only: a non-empty text within the materialization bound, or a
85    /// canonical recovery id.
86    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/// Request of `context-presented-use`.
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99#[serde(deny_unknown_fields)]
100pub struct EnginePresentedUseRequestV1 {
101    pub schema_version: u32,
102    /// The source constraint: the ids of EXACTLY the sources the sender has
103    /// authorized this request for. Set equality, not inclusion — a lineage
104    /// with fewer sources belongs to another text than the one the sender
105    /// authorized, and a lineage with more derives from a source nobody
106    /// authorized for this request. Absent: no additional constraint (a
107    /// local host without an authorizing caller in front of the Engine).
108    #[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    /// Validate the envelope, the constraint (a non-empty, bounded set of
116    /// distinct ids) and the form of what is asked.
117    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/// Answer of `context-presented-use`. `usable: false` carries a content-free
135/// reason and nothing else; source ids, recovery id and text are present
136/// only when usable.
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138#[serde(deny_unknown_fields)]
139pub struct EnginePresentedUseResponseV1 {
140    pub schema_version: u32,
141    /// Content-free reference of the presenter entry that asked.
142    pub presenter_ref: String,
143    pub usable: bool,
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub reason: Option<String>,
146    /// Ids of the caller-supplied sources the text derives from, sorted.
147    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    /// Validate the form of this answer as an answer to `asked`: text comes
156    /// back for `expand` only, a recovery id for `retain` and `expand`, and a
157    /// refusal carries its reason and nothing else. Form only: whether the
158    /// answer is by the presenter that asked, and free of its capability, is
159    /// for the caller that holds both.
160    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        // A lineage names every source once: a repeated id could stand in
186        // for a source the answer does not name.
187        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    /// A use request is closed and bounded: an unknown member anywhere, a
227    /// constraint that is not a non-empty bounded set of distinct ids, and a
228    /// malformed text or recovery id are all unreadable — never narrowed,
229    /// widened or ignored. Without this an Engine could answer a request
230    /// whose constraint it did not understand as if there were none.
231    #[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        // An answer is accepted only in the form of an answer to what was
281        // asked: text for `expand` alone, nothing beside a refusal's reason.
282        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}