harn-modules 0.10.46

Cross-file module graph and import resolution utilities for Harn
Documentation
use std::borrow::Cow;
use std::ffi::OsStr;
use std::fmt;
use std::fs;
use std::path::{Component, Path, PathBuf};

use sha2::{Digest, Sha256};
use unicode_normalization::UnicodeNormalization;

use super::{PackageExecutionError, CACHE_METADATA_FILE, CONTENT_HASH_FILE};

pub const CANONICAL_CONTENT_HASH_PREFIX: &str = "sha256-v2:";
const ARCHIVE_CONTENT_HASH_PREFIX: &str = "sha256:";

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum PackageContentHashAlgorithm {
    CanonicalV2,
    ArchiveV1,
}

impl PackageContentHashAlgorithm {
    fn parse(hash: &str) -> Result<Self, PackageExecutionError> {
        let (algorithm, hex) = if let Some(hex) = hash.strip_prefix(CANONICAL_CONTENT_HASH_PREFIX) {
            (Self::CanonicalV2, hex)
        } else if let Some(hex) = hash.strip_prefix(ARCHIVE_CONTENT_HASH_PREFIX) {
            (Self::ArchiveV1, hex)
        } else {
            return Err(PackageExecutionError::Invalid(format!(
                "package content hash must use sha256-v2:<64 hex> or archive sha256:<64 hex>, got {hash}"
            )));
        };
        if !is_sha256_hex(hex) {
            return Err(PackageExecutionError::Invalid(format!(
                "package content hash must use sha256-v2:<64 hex> or archive sha256:<64 hex>, got {hash}"
            )));
        }
        Ok(algorithm)
    }
}

pub fn compute_package_content_hash(dir: &Path) -> Result<String, PackageExecutionError> {
    compute_canonical_package_content_hash_capturing(dir, None).map(|(hash, _)| hash)
}

pub fn compute_archive_content_hash(dir: &Path) -> Result<String, PackageExecutionError> {
    compute_archive_content_hash_capturing(dir, None).map(|(hash, _)| hash)
}

pub fn is_canonical_package_content_hash(hash: &str) -> bool {
    hash.strip_prefix(CANONICAL_CONTENT_HASH_PREFIX)
        .is_some_and(is_sha256_hex)
}

pub fn verify_package_content_hash(
    dir: &Path,
    expected: &str,
) -> Result<String, PackageExecutionError> {
    compute_package_content_hash_capturing(dir, None, expected).map(|(hash, _)| hash)
}

pub(super) fn compute_package_content_hash_capturing(
    dir: &Path,
    capture: Option<&Path>,
    expected: &str,
) -> Result<(String, Option<Vec<u8>>), PackageExecutionError> {
    match PackageContentHashAlgorithm::parse(expected)? {
        PackageContentHashAlgorithm::CanonicalV2 => {
            compute_canonical_package_content_hash_capturing(dir, capture)
        }
        PackageContentHashAlgorithm::ArchiveV1 => {
            compute_archive_content_hash_capturing(dir, capture)
        }
    }
}

fn compute_archive_content_hash_capturing(
    dir: &Path,
    capture: Option<&Path>,
) -> Result<(String, Option<Vec<u8>>), PackageExecutionError> {
    let mut files = Vec::new();
    collect_hashable_files(dir, dir, &mut files)?;
    files.sort();
    let mut hasher = Sha256::new();
    let mut captured = None;
    for relative in files {
        let normalized = normalized_package_relative_path(&relative);
        let path = dir.join(&relative);
        let contents = read_regular_file(&path)?;
        hasher.update(normalized.as_bytes());
        hasher.update([0]);
        hasher.update(encode_hex(&Sha256::digest(&contents)).as_bytes());
        if capture == Some(relative.as_path()) {
            captured = Some(contents);
        }
    }
    Ok((
        format!(
            "{ARCHIVE_CONTENT_HASH_PREFIX}{}",
            encode_hex(&hasher.finalize())
        ),
        captured,
    ))
}

fn compute_canonical_package_content_hash_capturing(
    dir: &Path,
    capture: Option<&Path>,
) -> Result<(String, Option<Vec<u8>>), PackageExecutionError> {
    let mut paths = Vec::new();
    collect_hashable_files(dir, dir, &mut paths)?;
    let mut files = paths
        .into_iter()
        .map(|relative| {
            canonical_package_relative_path(&relative).map(|normalized| (normalized, relative))
        })
        .collect::<Result<Vec<_>, _>>()?;
    files.sort_by(|left, right| left.0.as_bytes().cmp(right.0.as_bytes()));
    for adjacent in files.windows(2) {
        if adjacent[0].0 == adjacent[1].0 {
            return Err(PackageExecutionError::Invalid(format!(
                "package paths {} and {} have the same canonical identity '{}'",
                adjacent[0].1.display(),
                adjacent[1].1.display(),
                adjacent[0].0
            )));
        }
    }

    let mut hasher = Sha256::new();
    hasher.update(b"harn-package-content-v2\0");
    let mut captured = None;
    for (normalized_path, relative) in files {
        let path = dir.join(&relative);
        let contents = read_regular_file(&path)?;
        let canonical_contents = canonical_file_contents(&contents);
        hash_framed(&mut hasher, normalized_path.as_bytes());
        hash_framed(&mut hasher, &Sha256::digest(canonical_contents.as_ref()));
        if capture == Some(relative.as_path()) {
            captured = Some(contents);
        }
    }
    Ok((
        format!(
            "{CANONICAL_CONTENT_HASH_PREFIX}{}",
            encode_hex(&hasher.finalize())
        ),
        captured,
    ))
}

fn hash_framed(hasher: &mut Sha256, bytes: &[u8]) {
    hasher.update((bytes.len() as u64).to_be_bytes());
    hasher.update(bytes);
}

fn canonical_file_contents(contents: &[u8]) -> Cow<'_, [u8]> {
    if contents.contains(&0) || std::str::from_utf8(contents).is_err() {
        return Cow::Borrowed(contents);
    }
    if !contents.contains(&b'\r') {
        return Cow::Borrowed(contents);
    }
    let mut normalized = Vec::with_capacity(contents.len());
    let mut index = 0;
    while index < contents.len() {
        if contents[index] == b'\r' {
            normalized.push(b'\n');
            index += usize::from(contents.get(index + 1) == Some(&b'\n')) + 1;
        } else {
            normalized.push(contents[index]);
            index += 1;
        }
    }
    Cow::Owned(normalized)
}

fn collect_hashable_files(
    root: &Path,
    cursor: &Path,
    out: &mut Vec<PathBuf>,
) -> Result<(), PackageExecutionError> {
    let entries = fs::read_dir(cursor).map_err(|error| {
        PackageExecutionError::io("read directory", cursor.to_path_buf(), error)
    })?;
    for entry in entries {
        let entry = entry.map_err(|error| {
            PackageExecutionError::io("read directory entry", cursor.to_path_buf(), error)
        })?;
        let path = entry.path();
        let file_type = entry
            .file_type()
            .map_err(|error| PackageExecutionError::io("stat", path.clone(), error))?;
        let name = entry.file_name();
        if excluded_package_name(&name) {
            continue;
        }
        if file_type.is_symlink() {
            return Err(PackageExecutionError::Invalid(format!(
                "package content contains unsupported symlink: {}",
                path.display()
            )));
        }
        if file_type.is_dir() {
            collect_hashable_files(root, &path, out)?;
        } else if file_type.is_file() {
            let relative = path.strip_prefix(root).map_err(|error| {
                PackageExecutionError::Invalid(format!(
                    "failed to relativize {}: {error}",
                    path.display()
                ))
            })?;
            out.push(relative.to_path_buf());
        }
    }
    Ok(())
}

fn read_regular_file(path: &Path) -> Result<Vec<u8>, PackageExecutionError> {
    let metadata = fs::symlink_metadata(path)
        .map_err(|error| PackageExecutionError::io("stat", path.to_path_buf(), error))?;
    if !metadata.file_type().is_file() {
        return Err(PackageExecutionError::Invalid(format!(
            "package content is not a regular file: {}",
            path.display()
        )));
    }
    fs::read(path).map_err(|error| PackageExecutionError::io("read", path.to_path_buf(), error))
}

pub(super) fn excluded_package_name(name: &OsStr) -> bool {
    name == OsStr::new(".git")
        || name == OsStr::new(".gitignore")
        || name == OsStr::new("CLAUDE.md")
        || name == OsStr::new(CONTENT_HASH_FILE)
        || name == OsStr::new(CACHE_METADATA_FILE)
}

pub fn normalized_package_relative_path(path: &Path) -> String {
    path.components()
        .map(|component| component.as_os_str().to_string_lossy())
        .collect::<Vec<_>>()
        .join("/")
}

fn canonical_package_relative_path(path: &Path) -> Result<String, PackageExecutionError> {
    let mut components = Vec::new();
    for component in path.components() {
        let Component::Normal(value) = component else {
            return Err(PackageExecutionError::Invalid(format!(
                "package content path is not relative and normalized: {}",
                path.display()
            )));
        };
        let value = value.to_str().ok_or_else(|| {
            PackageExecutionError::Invalid(format!(
                "package content path is not valid UTF-8: {}",
                path.display()
            ))
        })?;
        components.push(value.nfc().collect::<String>());
    }
    Ok(components.join("/"))
}

pub(super) fn validate_content_hash(hash: &str) -> Result<(), PackageExecutionError> {
    PackageContentHashAlgorithm::parse(hash).map(|_| ())
}

fn is_sha256_hex(hex: &str) -> bool {
    hex.len() == 64 && hex.bytes().all(|byte| byte.is_ascii_hexdigit())
}

fn encode_hex(bytes: &[u8]) -> String {
    let mut encoded = String::with_capacity(bytes.len() * 2);
    for byte in bytes {
        use fmt::Write as _;
        let _ = write!(encoded, "{byte:02x}");
    }
    encoded
}