Skip to main content

lodestone_client/
client_material.rs

1//! References-only client material manifest.
2
3use std::error::Error;
4use std::fmt::{self, Display, Formatter};
5use std::path::{Component, Path};
6
7use serde::{Deserialize, Serialize};
8
9pub const CLIENT_MATERIAL_SCHEMA_ID: &str = "lodestone-client-material/v1";
10pub const CLIENT_MATERIAL_FORMAT_VERSION: u16 = 1;
11pub const MAX_CLIENT_MATERIAL_BYTES: usize = 64 * 1024;
12pub const CLIENT_MATERIAL_CREDENTIAL_REFERENCE_OWNER: &str = "connection_profile.secret_reference";
13const DEVELOPMENT_SUPPLY_SCHEMA: &str = "lodestone-public-development-supply/v1";
14const MAX_PATH_BYTES: usize = 4_096;
15
16#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
17#[serde(deny_unknown_fields)]
18pub struct ClientMaterialV1 {
19    schema_id: String,
20    format_version: u16,
21    development_supply: ClientMaterialSupplyBindingV1,
22    connection_profile: ClientMaterialFileReferenceV1,
23    trust_material: ClientTrustMaterialReferenceV1,
24    credential_reference_owner: String,
25}
26
27impl ClientMaterialV1 {
28    pub fn new(
29        development_supply: ClientMaterialSupplyBindingV1,
30        connection_profile: ClientMaterialFileReferenceV1,
31        trust_material: ClientTrustMaterialReferenceV1,
32    ) -> Result<Self, ClientMaterialError> {
33        let value = Self {
34            schema_id: CLIENT_MATERIAL_SCHEMA_ID.to_owned(),
35            format_version: CLIENT_MATERIAL_FORMAT_VERSION,
36            development_supply,
37            connection_profile,
38            trust_material,
39            credential_reference_owner: CLIENT_MATERIAL_CREDENTIAL_REFERENCE_OWNER.to_owned(),
40        };
41        value.validate()?;
42        Ok(value)
43    }
44
45    pub fn validate(&self) -> Result<(), ClientMaterialError> {
46        if self.schema_id != CLIENT_MATERIAL_SCHEMA_ID || self.format_version != CLIENT_MATERIAL_FORMAT_VERSION {
47            return Err(ClientMaterialError::UnsupportedFormat);
48        }
49        if self.credential_reference_owner != CLIENT_MATERIAL_CREDENTIAL_REFERENCE_OWNER {
50            return Err(ClientMaterialError::InvalidCredentialOwner);
51        }
52        self.development_supply.validate()?;
53        self.connection_profile.validate()?;
54        self.trust_material.validate()
55    }
56
57    pub fn encode_canonical(&self) -> Result<Vec<u8>, ClientMaterialError> {
58        self.validate()?;
59        let bytes = serde_json::to_vec(self).map_err(|_| ClientMaterialError::Encoding)?;
60        if bytes.len() > MAX_CLIENT_MATERIAL_BYTES {
61            return Err(ClientMaterialError::InvalidSize);
62        }
63        Ok(bytes)
64    }
65
66    pub fn decode_canonical(bytes: &[u8]) -> Result<Self, ClientMaterialError> {
67        if bytes.is_empty() || bytes.len() > MAX_CLIENT_MATERIAL_BYTES {
68            return Err(ClientMaterialError::InvalidSize);
69        }
70        let value: Self = serde_json::from_slice(bytes).map_err(|_| ClientMaterialError::Malformed)?;
71        value.validate()?;
72        if value.encode_canonical()?.as_slice() != bytes {
73            return Err(ClientMaterialError::NonCanonical);
74        }
75        Ok(value)
76    }
77
78    #[must_use]
79    pub const fn development_supply(&self) -> &ClientMaterialSupplyBindingV1 {
80        &self.development_supply
81    }
82
83    #[must_use]
84    pub const fn connection_profile(&self) -> &ClientMaterialFileReferenceV1 {
85        &self.connection_profile
86    }
87
88    #[must_use]
89    pub const fn trust_material(&self) -> &ClientTrustMaterialReferenceV1 {
90        &self.trust_material
91    }
92}
93
94#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
95#[serde(deny_unknown_fields)]
96pub struct ClientMaterialSupplyBindingV1 {
97    index_schema: String,
98    package_digest: String,
99    source_revision: String,
100    native_api_sha256: String,
101}
102
103impl ClientMaterialSupplyBindingV1 {
104    pub fn new(
105        package_digest: String,
106        source_revision: String,
107        native_api_sha256: String,
108    ) -> Result<Self, ClientMaterialError> {
109        let value = Self {
110            index_schema: DEVELOPMENT_SUPPLY_SCHEMA.to_owned(),
111            package_digest,
112            source_revision,
113            native_api_sha256,
114        };
115        value.validate()?;
116        Ok(value)
117    }
118
119    fn validate(&self) -> Result<(), ClientMaterialError> {
120        if self.index_schema != DEVELOPMENT_SUPPLY_SCHEMA
121            || !valid_sha256(&self.package_digest)
122            || !valid_git_object(&self.source_revision)
123            || !valid_sha256(&self.native_api_sha256)
124        {
125            return Err(ClientMaterialError::InvalidSupplyBinding);
126        }
127        Ok(())
128    }
129
130    #[must_use]
131    pub fn package_digest(&self) -> &str {
132        &self.package_digest
133    }
134
135    #[must_use]
136    pub fn source_revision(&self) -> &str {
137        &self.source_revision
138    }
139
140    #[must_use]
141    pub fn native_api_sha256(&self) -> &str {
142        &self.native_api_sha256
143    }
144}
145
146#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
147#[serde(deny_unknown_fields)]
148pub struct ClientMaterialFileReferenceV1 {
149    path: String,
150    sha256: String,
151}
152
153impl ClientMaterialFileReferenceV1 {
154    pub fn new(path: String, sha256: String) -> Result<Self, ClientMaterialError> {
155        let value = Self { path, sha256 };
156        value.validate()?;
157        Ok(value)
158    }
159
160    fn validate(&self) -> Result<(), ClientMaterialError> {
161        if !valid_absolute_path(&self.path) || !valid_sha256(&self.sha256) {
162            return Err(ClientMaterialError::InvalidFileReference);
163        }
164        Ok(())
165    }
166
167    #[must_use]
168    pub fn path(&self) -> &str {
169        &self.path
170    }
171
172    #[must_use]
173    pub fn sha256(&self) -> &str {
174        &self.sha256
175    }
176}
177
178#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
179#[serde(deny_unknown_fields)]
180pub struct ClientTrustMaterialReferenceV1 {
181    path: String,
182    file_sha256: String,
183    lodestone_tls_trust_sha256: String,
184}
185
186impl ClientTrustMaterialReferenceV1 {
187    pub fn new(
188        path: String,
189        file_sha256: String,
190        lodestone_tls_trust_sha256: String,
191    ) -> Result<Self, ClientMaterialError> {
192        let value = Self {
193            path,
194            file_sha256,
195            lodestone_tls_trust_sha256,
196        };
197        value.validate()?;
198        Ok(value)
199    }
200
201    fn validate(&self) -> Result<(), ClientMaterialError> {
202        if !valid_absolute_path(&self.path)
203            || !valid_sha256(&self.file_sha256)
204            || !valid_sha256(&self.lodestone_tls_trust_sha256)
205        {
206            return Err(ClientMaterialError::InvalidTrustReference);
207        }
208        Ok(())
209    }
210
211    #[must_use]
212    pub fn path(&self) -> &str {
213        &self.path
214    }
215
216    #[must_use]
217    pub fn file_sha256(&self) -> &str {
218        &self.file_sha256
219    }
220
221    #[must_use]
222    pub fn lodestone_tls_trust_sha256(&self) -> &str {
223        &self.lodestone_tls_trust_sha256
224    }
225}
226
227#[derive(Clone, Copy, Debug, Eq, PartialEq)]
228pub enum ClientMaterialError {
229    InvalidSize,
230    Malformed,
231    NonCanonical,
232    UnsupportedFormat,
233    InvalidCredentialOwner,
234    InvalidSupplyBinding,
235    InvalidFileReference,
236    InvalidTrustReference,
237    Encoding,
238}
239
240impl Display for ClientMaterialError {
241    fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
242        formatter.write_str(match self {
243            Self::InvalidSize => "client material is empty or oversized",
244            Self::Malformed => "client material is malformed",
245            Self::NonCanonical => "client material is not canonical",
246            Self::UnsupportedFormat => "client material format is unsupported",
247            Self::InvalidCredentialOwner => "client material credential-reference owner is invalid",
248            Self::InvalidSupplyBinding => "client material development-supply binding is invalid",
249            Self::InvalidFileReference => "client material connection-profile reference is invalid",
250            Self::InvalidTrustReference => "client material trust reference is invalid",
251            Self::Encoding => "client material could not be encoded",
252        })
253    }
254}
255
256impl Error for ClientMaterialError {}
257
258fn valid_sha256(value: &str) -> bool {
259    value.len() == 71
260        && value.starts_with("sha256:")
261        && value.as_bytes().get(7..).is_some_and(|hex| {
262            hex.iter()
263                .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(byte))
264        })
265}
266
267fn valid_git_object(value: &str) -> bool {
268    (40..=64).contains(&value.len())
269        && value
270            .as_bytes()
271            .iter()
272            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(byte))
273}
274
275fn valid_absolute_path(value: &str) -> bool {
276    let path = Path::new(value);
277    !value.is_empty()
278        && value.len() <= MAX_PATH_BYTES
279        && !value.bytes().any(|byte| byte == 0 || byte.is_ascii_control())
280        && path.is_absolute()
281        && path.parent().is_some()
282        && !path
283            .components()
284            .any(|component| matches!(component, Component::CurDir | Component::ParentDir))
285        && path.to_string_lossy() == value
286}
287
288#[cfg(test)]
289mod tests {
290    use super::{
291        CLIENT_MATERIAL_CREDENTIAL_REFERENCE_OWNER, ClientMaterialError, ClientMaterialFileReferenceV1,
292        ClientMaterialSupplyBindingV1, ClientMaterialV1, ClientTrustMaterialReferenceV1, MAX_CLIENT_MATERIAL_BYTES,
293    };
294
295    fn digest(character: char) -> String {
296        format!("sha256:{}", character.to_string().repeat(64))
297    }
298
299    fn material() -> Result<ClientMaterialV1, ClientMaterialError> {
300        ClientMaterialV1::new(
301            ClientMaterialSupplyBindingV1::new(digest('1'), "2".repeat(40), digest('3'))?,
302            ClientMaterialFileReferenceV1::new("/private/lodestone/profile.json".to_owned(), digest('4'))?,
303            ClientTrustMaterialReferenceV1::new("/private/lodestone/ca.der".to_owned(), digest('5'), digest('6'))?,
304        )
305    }
306
307    #[test]
308    fn round_trip_is_canonical_and_references_only() -> Result<(), Box<dyn std::error::Error>> {
309        let material = material()?;
310        let bytes = material.encode_canonical()?;
311        assert_eq!(ClientMaterialV1::decode_canonical(&bytes)?, material);
312        let text = std::str::from_utf8(&bytes)?;
313        assert!(text.contains(CLIENT_MATERIAL_CREDENTIAL_REFERENCE_OWNER));
314        for forbidden in [
315            "native_endpoints",
316            "cluster_id",
317            "collection_id",
318            "credential_id",
319            "secret_hex",
320        ] {
321            assert!(!text.contains(forbidden));
322        }
323        Ok(())
324    }
325
326    #[test]
327    fn unknown_embedded_credential_and_relative_paths_refuse() -> Result<(), Box<dyn std::error::Error>> {
328        let material_value = material()?;
329        let mut value = serde_json::to_value(material_value)?;
330        value["credential"] = serde_json::json!({"secret": "forbidden"});
331        assert_eq!(
332            ClientMaterialV1::decode_canonical(&serde_json::to_vec(&value)?),
333            Err(ClientMaterialError::Malformed)
334        );
335
336        value
337            .as_object_mut()
338            .ok_or("material must be an object")?
339            .remove("credential");
340        value["connection_profile"]["path"] = serde_json::json!("relative/profile.json");
341        assert_eq!(
342            ClientMaterialV1::decode_canonical(&serde_json::to_vec(&value)?),
343            Err(ClientMaterialError::InvalidFileReference)
344        );
345
346        let mut pretty = serde_json::to_vec_pretty(&material()?)?;
347        pretty.push(b'\n');
348        assert_eq!(
349            ClientMaterialV1::decode_canonical(&pretty),
350            Err(ClientMaterialError::NonCanonical)
351        );
352        assert_eq!(
353            ClientMaterialV1::decode_canonical(&vec![b'x'; MAX_CLIENT_MATERIAL_BYTES + 1]),
354            Err(ClientMaterialError::InvalidSize)
355        );
356        Ok(())
357    }
358}