1use 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}