use std::error::Error;
use std::fmt::{self, Display, Formatter};
use std::path::{Component, Path};
use serde::{Deserialize, Serialize};
pub const CLIENT_MATERIAL_SCHEMA_ID: &str = "lodestone-client-material/v1";
pub const CLIENT_MATERIAL_FORMAT_VERSION: u16 = 1;
pub const MAX_CLIENT_MATERIAL_BYTES: usize = 64 * 1024;
pub const CLIENT_MATERIAL_CREDENTIAL_REFERENCE_OWNER: &str = "connection_profile.secret_reference";
const DEVELOPMENT_SUPPLY_SCHEMA: &str = "lodestone-public-development-supply/v1";
const MAX_PATH_BYTES: usize = 4_096;
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct ClientMaterialV1 {
schema_id: String,
format_version: u16,
development_supply: ClientMaterialSupplyBindingV1,
connection_profile: ClientMaterialFileReferenceV1,
trust_material: ClientTrustMaterialReferenceV1,
credential_reference_owner: String,
}
impl ClientMaterialV1 {
pub fn new(
development_supply: ClientMaterialSupplyBindingV1,
connection_profile: ClientMaterialFileReferenceV1,
trust_material: ClientTrustMaterialReferenceV1,
) -> Result<Self, ClientMaterialError> {
let value = Self {
schema_id: CLIENT_MATERIAL_SCHEMA_ID.to_owned(),
format_version: CLIENT_MATERIAL_FORMAT_VERSION,
development_supply,
connection_profile,
trust_material,
credential_reference_owner: CLIENT_MATERIAL_CREDENTIAL_REFERENCE_OWNER.to_owned(),
};
value.validate()?;
Ok(value)
}
pub fn validate(&self) -> Result<(), ClientMaterialError> {
if self.schema_id != CLIENT_MATERIAL_SCHEMA_ID || self.format_version != CLIENT_MATERIAL_FORMAT_VERSION {
return Err(ClientMaterialError::UnsupportedFormat);
}
if self.credential_reference_owner != CLIENT_MATERIAL_CREDENTIAL_REFERENCE_OWNER {
return Err(ClientMaterialError::InvalidCredentialOwner);
}
self.development_supply.validate()?;
self.connection_profile.validate()?;
self.trust_material.validate()
}
pub fn encode_canonical(&self) -> Result<Vec<u8>, ClientMaterialError> {
self.validate()?;
let bytes = serde_json::to_vec(self).map_err(|_| ClientMaterialError::Encoding)?;
if bytes.len() > MAX_CLIENT_MATERIAL_BYTES {
return Err(ClientMaterialError::InvalidSize);
}
Ok(bytes)
}
pub fn decode_canonical(bytes: &[u8]) -> Result<Self, ClientMaterialError> {
if bytes.is_empty() || bytes.len() > MAX_CLIENT_MATERIAL_BYTES {
return Err(ClientMaterialError::InvalidSize);
}
let value: Self = serde_json::from_slice(bytes).map_err(|_| ClientMaterialError::Malformed)?;
value.validate()?;
if value.encode_canonical()?.as_slice() != bytes {
return Err(ClientMaterialError::NonCanonical);
}
Ok(value)
}
#[must_use]
pub const fn development_supply(&self) -> &ClientMaterialSupplyBindingV1 {
&self.development_supply
}
#[must_use]
pub const fn connection_profile(&self) -> &ClientMaterialFileReferenceV1 {
&self.connection_profile
}
#[must_use]
pub const fn trust_material(&self) -> &ClientTrustMaterialReferenceV1 {
&self.trust_material
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct ClientMaterialSupplyBindingV1 {
index_schema: String,
package_digest: String,
source_revision: String,
native_api_sha256: String,
}
impl ClientMaterialSupplyBindingV1 {
pub fn new(
package_digest: String,
source_revision: String,
native_api_sha256: String,
) -> Result<Self, ClientMaterialError> {
let value = Self {
index_schema: DEVELOPMENT_SUPPLY_SCHEMA.to_owned(),
package_digest,
source_revision,
native_api_sha256,
};
value.validate()?;
Ok(value)
}
fn validate(&self) -> Result<(), ClientMaterialError> {
if self.index_schema != DEVELOPMENT_SUPPLY_SCHEMA
|| !valid_sha256(&self.package_digest)
|| !valid_git_object(&self.source_revision)
|| !valid_sha256(&self.native_api_sha256)
{
return Err(ClientMaterialError::InvalidSupplyBinding);
}
Ok(())
}
#[must_use]
pub fn package_digest(&self) -> &str {
&self.package_digest
}
#[must_use]
pub fn source_revision(&self) -> &str {
&self.source_revision
}
#[must_use]
pub fn native_api_sha256(&self) -> &str {
&self.native_api_sha256
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct ClientMaterialFileReferenceV1 {
path: String,
sha256: String,
}
impl ClientMaterialFileReferenceV1 {
pub fn new(path: String, sha256: String) -> Result<Self, ClientMaterialError> {
let value = Self { path, sha256 };
value.validate()?;
Ok(value)
}
fn validate(&self) -> Result<(), ClientMaterialError> {
if !valid_absolute_path(&self.path) || !valid_sha256(&self.sha256) {
return Err(ClientMaterialError::InvalidFileReference);
}
Ok(())
}
#[must_use]
pub fn path(&self) -> &str {
&self.path
}
#[must_use]
pub fn sha256(&self) -> &str {
&self.sha256
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct ClientTrustMaterialReferenceV1 {
path: String,
file_sha256: String,
lodestone_tls_trust_sha256: String,
}
impl ClientTrustMaterialReferenceV1 {
pub fn new(
path: String,
file_sha256: String,
lodestone_tls_trust_sha256: String,
) -> Result<Self, ClientMaterialError> {
let value = Self {
path,
file_sha256,
lodestone_tls_trust_sha256,
};
value.validate()?;
Ok(value)
}
fn validate(&self) -> Result<(), ClientMaterialError> {
if !valid_absolute_path(&self.path)
|| !valid_sha256(&self.file_sha256)
|| !valid_sha256(&self.lodestone_tls_trust_sha256)
{
return Err(ClientMaterialError::InvalidTrustReference);
}
Ok(())
}
#[must_use]
pub fn path(&self) -> &str {
&self.path
}
#[must_use]
pub fn file_sha256(&self) -> &str {
&self.file_sha256
}
#[must_use]
pub fn lodestone_tls_trust_sha256(&self) -> &str {
&self.lodestone_tls_trust_sha256
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ClientMaterialError {
InvalidSize,
Malformed,
NonCanonical,
UnsupportedFormat,
InvalidCredentialOwner,
InvalidSupplyBinding,
InvalidFileReference,
InvalidTrustReference,
Encoding,
}
impl Display for ClientMaterialError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
Self::InvalidSize => "client material is empty or oversized",
Self::Malformed => "client material is malformed",
Self::NonCanonical => "client material is not canonical",
Self::UnsupportedFormat => "client material format is unsupported",
Self::InvalidCredentialOwner => "client material credential-reference owner is invalid",
Self::InvalidSupplyBinding => "client material development-supply binding is invalid",
Self::InvalidFileReference => "client material connection-profile reference is invalid",
Self::InvalidTrustReference => "client material trust reference is invalid",
Self::Encoding => "client material could not be encoded",
})
}
}
impl Error for ClientMaterialError {}
fn valid_sha256(value: &str) -> bool {
value.len() == 71
&& value.starts_with("sha256:")
&& value.as_bytes().get(7..).is_some_and(|hex| {
hex.iter()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(byte))
})
}
fn valid_git_object(value: &str) -> bool {
(40..=64).contains(&value.len())
&& value
.as_bytes()
.iter()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(byte))
}
fn valid_absolute_path(value: &str) -> bool {
let path = Path::new(value);
!value.is_empty()
&& value.len() <= MAX_PATH_BYTES
&& !value.bytes().any(|byte| byte == 0 || byte.is_ascii_control())
&& path.is_absolute()
&& path.parent().is_some()
&& !path
.components()
.any(|component| matches!(component, Component::CurDir | Component::ParentDir))
&& path.to_string_lossy() == value
}
#[cfg(test)]
mod tests {
use super::{
CLIENT_MATERIAL_CREDENTIAL_REFERENCE_OWNER, ClientMaterialError, ClientMaterialFileReferenceV1,
ClientMaterialSupplyBindingV1, ClientMaterialV1, ClientTrustMaterialReferenceV1, MAX_CLIENT_MATERIAL_BYTES,
};
fn digest(character: char) -> String {
format!("sha256:{}", character.to_string().repeat(64))
}
fn material() -> Result<ClientMaterialV1, ClientMaterialError> {
ClientMaterialV1::new(
ClientMaterialSupplyBindingV1::new(digest('1'), "2".repeat(40), digest('3'))?,
ClientMaterialFileReferenceV1::new("/private/lodestone/profile.json".to_owned(), digest('4'))?,
ClientTrustMaterialReferenceV1::new("/private/lodestone/ca.der".to_owned(), digest('5'), digest('6'))?,
)
}
#[test]
fn round_trip_is_canonical_and_references_only() -> Result<(), Box<dyn std::error::Error>> {
let material = material()?;
let bytes = material.encode_canonical()?;
assert_eq!(ClientMaterialV1::decode_canonical(&bytes)?, material);
let text = std::str::from_utf8(&bytes)?;
assert!(text.contains(CLIENT_MATERIAL_CREDENTIAL_REFERENCE_OWNER));
for forbidden in [
"native_endpoints",
"cluster_id",
"collection_id",
"credential_id",
"secret_hex",
] {
assert!(!text.contains(forbidden));
}
Ok(())
}
#[test]
fn unknown_embedded_credential_and_relative_paths_refuse() -> Result<(), Box<dyn std::error::Error>> {
let material_value = material()?;
let mut value = serde_json::to_value(material_value)?;
value["credential"] = serde_json::json!({"secret": "forbidden"});
assert_eq!(
ClientMaterialV1::decode_canonical(&serde_json::to_vec(&value)?),
Err(ClientMaterialError::Malformed)
);
value
.as_object_mut()
.ok_or("material must be an object")?
.remove("credential");
value["connection_profile"]["path"] = serde_json::json!("relative/profile.json");
assert_eq!(
ClientMaterialV1::decode_canonical(&serde_json::to_vec(&value)?),
Err(ClientMaterialError::InvalidFileReference)
);
let mut pretty = serde_json::to_vec_pretty(&material()?)?;
pretty.push(b'\n');
assert_eq!(
ClientMaterialV1::decode_canonical(&pretty),
Err(ClientMaterialError::NonCanonical)
);
assert_eq!(
ClientMaterialV1::decode_canonical(&vec![b'x'; MAX_CLIENT_MATERIAL_BYTES + 1]),
Err(ClientMaterialError::InvalidSize)
);
Ok(())
}
}