xdoc-rs 0.1.1

Declarative XML engine for Rust
Documentation
use std::fs;
use std::path::Path;

use crate::core::{ErrorKind, XmlError, XmlResult};

use super::{digest_bytes, DigestAlgorithm};

/// Explicit XAdES signature policy expected by EPES.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SignaturePolicy {
    pub id: SignaturePolicyId,
    pub description: Option<String>,
    pub digest_algorithm: DigestAlgorithm,
    pub digest_value: Vec<u8>,
    pub qualifiers: Vec<SignaturePolicyQualifier>,
}

impl SignaturePolicy {
    pub fn new(
        id: SignaturePolicyId,
        digest_algorithm: DigestAlgorithm,
        digest_value: impl Into<Vec<u8>>,
    ) -> Self {
        Self {
            id,
            description: None,
            digest_algorithm,
            digest_value: digest_value.into(),
            qualifiers: Vec::new(),
        }
    }

    /// Builds an explicit XAdES policy by hashing the actual policy document
    /// bytes with the declared digest algorithm.
    pub fn from_bytes(
        id: SignaturePolicyId,
        digest_algorithm: DigestAlgorithm,
        policy_document: impl AsRef<[u8]>,
    ) -> XmlResult<Self> {
        let digest_value = digest_bytes(digest_algorithm, policy_document)?;

        Ok(Self::new(id, digest_algorithm, digest_value))
    }

    /// Builds an explicit XAdES policy by reading and hashing a local policy
    /// document. Network fetching and cache policy remain the caller's
    /// responsibility.
    pub fn from_file(
        id: SignaturePolicyId,
        digest_algorithm: DigestAlgorithm,
        path: impl AsRef<Path>,
    ) -> XmlResult<Self> {
        let path = path.as_ref();
        let policy_document = fs::read(path).map_err(|error| {
            XmlError::new(
                ErrorKind::Signature,
                format!(
                    "cannot read XAdES signature policy document `{}`: {error}",
                    path.display()
                ),
            )
        })?;

        Self::from_bytes(id, digest_algorithm, policy_document)
    }

    pub fn with_description(mut self, description: impl Into<String>) -> Self {
        self.description = Some(description.into());
        self
    }

    pub fn with_qualifier(mut self, qualifier: SignaturePolicyQualifier) -> Self {
        self.qualifiers.push(qualifier);
        self
    }
}

/// Identifier for a signature policy.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SignaturePolicyId {
    Uri(String),
    Oid(String),
}

impl SignaturePolicyId {
    pub fn value(&self) -> &str {
        match self {
            Self::Uri(value) | Self::Oid(value) => value,
        }
    }
}

/// Optional qualifiers attached to a signature policy.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SignaturePolicyQualifier {
    SpUri(String),
    SpUserNotice {
        organization: Option<String>,
        notice_numbers: Vec<i64>,
        explicit_text: Option<String>,
    },
}

/// Typed XAdES signer role extension.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SignerRole {
    claimed_roles: Vec<String>,
}

impl SignerRole {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn claimed(role: impl Into<String>) -> Self {
        Self::new().with_claimed_role(role)
    }

    pub fn with_claimed_role(mut self, role: impl Into<String>) -> Self {
        self.claimed_roles.push(role.into());
        self
    }

    pub fn claimed_roles(&self) -> &[String] {
        &self.claimed_roles
    }

    pub fn is_empty(&self) -> bool {
        self.claimed_roles.is_empty()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn signature_policy_keeps_explicit_digest_and_qualifiers() {
        let policy = SignaturePolicy::new(
            SignaturePolicyId::Uri("urn:example:policy:v1".to_owned()),
            DigestAlgorithm::Sha256,
            [1, 2, 3],
        )
        .with_qualifier(SignaturePolicyQualifier::SpUri(
            "https://example.test/policy.pdf".to_owned(),
        ));

        assert_eq!(policy.id.value(), "urn:example:policy:v1");
        assert_eq!(policy.digest_algorithm, DigestAlgorithm::Sha256);
        assert_eq!(policy.digest_value, vec![1, 2, 3]);
        assert_eq!(policy.qualifiers.len(), 1);
    }

    #[test]
    fn signature_policy_can_hash_document_bytes() -> XmlResult<()> {
        let policy = SignaturePolicy::from_bytes(
            SignaturePolicyId::Uri("https://example.test/policy.pdf".to_owned()),
            DigestAlgorithm::Sha256,
            b"policy bytes",
        )?;

        assert_eq!(policy.id.value(), "https://example.test/policy.pdf");
        assert_eq!(policy.digest_algorithm, DigestAlgorithm::Sha256);
        assert_eq!(
            policy.digest_value,
            digest_bytes(DigestAlgorithm::Sha256, b"policy bytes")?
        );

        Ok(())
    }

    #[test]
    fn signature_policy_can_hash_document_file() -> XmlResult<()> {
        let path = std::env::temp_dir().join(format!(
            "xdoc-policy-{}-{}.pdf",
            std::process::id(),
            "from-file"
        ));
        fs::write(&path, b"policy file bytes").expect("write policy fixture");

        let policy = SignaturePolicy::from_file(
            SignaturePolicyId::Uri("https://example.test/policy.pdf".to_owned()),
            DigestAlgorithm::Sha512,
            &path,
        )?;

        fs::remove_file(&path).expect("remove policy fixture");

        assert_eq!(policy.digest_algorithm, DigestAlgorithm::Sha512);
        assert_eq!(
            policy.digest_value,
            digest_bytes(DigestAlgorithm::Sha512, b"policy file bytes")?
        );

        Ok(())
    }

    #[test]
    fn signature_policy_file_reports_read_error() {
        let path =
            std::env::temp_dir().join(format!("xdoc-policy-{}-missing.pdf", std::process::id()));

        let error = SignaturePolicy::from_file(
            SignaturePolicyId::Uri("https://example.test/policy.pdf".to_owned()),
            DigestAlgorithm::Sha256,
            &path,
        )
        .expect_err("missing policy file must fail");

        assert_eq!(error.kind(), &ErrorKind::Signature);
        assert!(error
            .message()
            .contains("cannot read XAdES signature policy document"));
        assert!(error.message().contains(&path.display().to_string()));
    }

    #[test]
    fn signature_policy_supports_optional_description() {
        let policy = SignaturePolicy::new(
            SignaturePolicyId::Uri("urn:example:policy:v1".to_owned()),
            DigestAlgorithm::Sha256,
            [1, 2, 3],
        )
        .with_description("Example policy");

        assert_eq!(policy.description.as_deref(), Some("Example policy"));
    }

    #[test]
    fn signature_policy_id_supports_oid() {
        let id = SignaturePolicyId::Oid("1.2.3.4".to_owned());

        assert_eq!(id.value(), "1.2.3.4");
    }

    #[test]
    fn signer_role_keeps_claimed_roles_in_order() {
        let role = SignerRole::claimed("reviewer").with_claimed_role("approver");

        assert_eq!(role.claimed_roles(), ["reviewer", "approver"]);
        assert!(!role.is_empty());
    }
}