Skip to main content

lean_ctx_protocol/
runtime_handshake.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Legacy V1 runtime notification contract, retained for wire compatibility.
3//!
4//! This one-way message does not authenticate a peer, admit a capability, or
5//! establish a request/response session. Transport and admission remain host-owned.
6
7use serde::{Deserialize, Serialize};
8
9pub const HANDSHAKE_SCHEMA: &str = "leanctx.runtime-handshake/v1";
10pub const WIRE_PROTOCOL: &str = "leanctx.protocol/v4";
11pub const MAX_HANDSHAKE_BYTES: usize = 64 * 1024;
12
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub struct RuntimeHandshake {
15    pub schema: String,
16    pub wire_protocol: String,
17    pub runtime: String,
18    pub capabilities: Vec<String>,
19}
20
21impl RuntimeHandshake {
22    pub fn public(capabilities: impl IntoIterator<Item = String>) -> Self {
23        Self {
24            schema: HANDSHAKE_SCHEMA.to_owned(),
25            wire_protocol: WIRE_PROTOCOL.to_owned(),
26            runtime: "public-reference".to_owned(),
27            capabilities: capabilities.into_iter().collect(),
28        }
29    }
30
31    pub fn validate(&self) -> Result<(), String> {
32        if self.schema != HANDSHAKE_SCHEMA {
33            return Err(format!("unsupported handshake schema: {}", self.schema));
34        }
35        if self.wire_protocol != WIRE_PROTOCOL {
36            return Err(format!("unsupported wire protocol: {}", self.wire_protocol));
37        }
38        if self.runtime.trim().is_empty() {
39            return Err("runtime identity must not be empty".to_owned());
40        }
41        Ok(())
42    }
43
44    /// Encode the legacy sidecar frame: big-endian length followed by JSON.
45    pub fn encode_frame(&self) -> Result<Vec<u8>, String> {
46        self.validate()?;
47        let payload = serde_json::to_vec(self).map_err(|error| error.to_string())?;
48        if payload.len() > MAX_HANDSHAKE_BYTES {
49            return Err("handshake exceeds maximum size".to_owned());
50        }
51        let length = u32::try_from(payload.len()).map_err(|_| "handshake too large".to_owned())?;
52        let mut frame = Vec::with_capacity(4 + payload.len());
53        frame.extend_from_slice(&length.to_be_bytes());
54        frame.extend_from_slice(&payload);
55        Ok(frame)
56    }
57}
58
59#[cfg(test)]
60mod tests {
61    use super::*;
62
63    #[test]
64    fn legacy_public_frame_preserves_exact_wire_bytes() {
65        let value = RuntimeHandshake::public(["reference-planning".to_owned()]);
66        let payload = br#"{"schema":"leanctx.runtime-handshake/v1","wire_protocol":"leanctx.protocol/v4","runtime":"public-reference","capabilities":["reference-planning"]}"#;
67        let mut expected = u32::try_from(payload.len()).unwrap().to_be_bytes().to_vec();
68        expected.extend_from_slice(payload);
69        assert_eq!(value.encode_frame().unwrap(), expected);
70        assert_eq!(
71            serde_json::from_slice::<RuntimeHandshake>(payload).unwrap(),
72            value
73        );
74    }
75
76    #[test]
77    fn legacy_validation_rejects_wrong_versions_and_empty_identity() {
78        for (field, bad) in [
79            ("schema", "leanctx.runtime-handshake/v2"),
80            ("wire_protocol", "leanctx.protocol/v3"),
81            ("runtime", "  "),
82        ] {
83            let mut json = serde_json::to_value(RuntimeHandshake::public([])).unwrap();
84            json[field] = serde_json::Value::String(bad.to_owned());
85            let value: RuntimeHandshake = serde_json::from_value(json).unwrap();
86            assert!(value.validate().is_err(), "{field}");
87            assert!(value.encode_frame().is_err(), "{field}");
88        }
89    }
90
91    #[test]
92    fn legacy_size_bound_is_on_encoded_payload_not_capability_count() {
93        let value = RuntimeHandshake::public(["x".repeat(MAX_HANDSHAKE_BYTES)]);
94        assert_eq!(
95            value.encode_frame().unwrap_err(),
96            "handshake exceeds maximum size"
97        );
98    }
99}