lodestone-client 0.0.4

Current-only asynchronous Rust client for the Lodestone API
Documentation
//! References-only client material manifest.

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(())
    }
}