arete-hash 0.1.1

Authoritative typed artifact identity protocol for Arete
Documentation
use std::collections::HashSet;

use crate::{
    framed_tuple_payload, hash_canonical_payload, hash_raw_bytes, require_profile, ArtifactFile,
    CanonicalizationProfile, HashError, HashId, Kind, TupleField,
};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ArtifactEntryKind {
    File,
    Symlink,
}

#[derive(Debug, Clone, Copy)]
pub struct ArtifactTreeEntry<'a> {
    pub path: &'a str,
    pub bytes: &'a [u8],
    pub kind: ArtifactEntryKind,
}

impl<'a> ArtifactTreeEntry<'a> {
    pub const fn file(path: &'a str, bytes: &'a [u8]) -> Self {
        Self {
            path,
            bytes,
            kind: ArtifactEntryKind::File,
        }
    }

    pub const fn symlink(path: &'a str) -> Self {
        Self {
            path,
            bytes: &[],
            kind: ArtifactEntryKind::Symlink,
        }
    }
}

pub fn validate_artifact_path(path: &str) -> Result<(), HashError> {
    let invalid = |reason| HashError::InvalidArtifactPath {
        path: path.to_string(),
        reason,
    };

    if path.is_empty() {
        return Err(invalid("path must contain at least one segment"));
    }
    if path.starts_with('/') || path.ends_with('/') {
        return Err(invalid("leading and trailing slashes are forbidden"));
    }
    if path.contains("//") {
        return Err(invalid("repeated slashes are forbidden"));
    }
    if path.contains('\\') {
        return Err(invalid("backslashes are forbidden"));
    }
    if path.contains('\0') {
        return Err(invalid("NUL bytes are forbidden"));
    }
    if path
        .split('/')
        .any(|segment| segment.is_empty() || segment == "." || segment == "..")
    {
        return Err(invalid("empty, '.' and '..' segments are forbidden"));
    }
    Ok(())
}

pub fn artifact_tree_payload(entries: &[ArtifactTreeEntry<'_>]) -> Result<Vec<u8>, HashError> {
    let mut entries = entries.to_vec();
    for entry in &entries {
        validate_artifact_path(entry.path)?;
        if entry.kind == ArtifactEntryKind::Symlink {
            return Err(HashError::SymlinkArtifact(entry.path.to_string()));
        }
    }
    entries.sort_by(|left, right| left.path.as_bytes().cmp(right.path.as_bytes()));

    let mut seen = HashSet::with_capacity(entries.len());
    let mut payload = Vec::new();
    payload.extend_from_slice(&(entries.len() as u64).to_be_bytes());
    for entry in entries {
        if !seen.insert(entry.path) {
            return Err(HashError::DuplicateArtifactPath(entry.path.to_string()));
        }
        let file_hash = hash_raw_bytes::<ArtifactFile>(entry.bytes)?.to_string();
        let leaf = framed_tuple_payload(&[
            TupleField::new("path", entry.path.as_bytes()),
            TupleField::new("fileHash", file_hash.as_bytes()),
        ])?;
        payload.extend_from_slice(&leaf);
    }
    Ok(payload)
}

pub fn hash_artifact_tree<K: Kind>(
    entries: &[ArtifactTreeEntry<'_>],
) -> Result<HashId<K>, HashError> {
    require_profile::<K>(CanonicalizationProfile::ArtifactTreeV1)?;
    let payload = artifact_tree_payload(entries)?;
    Ok(hash_canonical_payload(&payload))
}