a3s 0.10.5

a3s — A3S coding agent CLI; `a3s code` launches the interactive TUI
Documentation
use std::io::Read;
use std::path::Path;

use anyhow::{bail, Context};
use sha2::{Digest, Sha256};

const MAX_MANAGED_TREE_ENTRIES: usize = 5_000;
const MAX_MANAGED_TREE_BYTES: u64 = 64 * 1024 * 1024;
const MAX_PACKAGED_TREE_ENTRIES: usize = 2_000;
const MAX_PACKAGED_TREE_BYTES: u64 = 32 * 1024 * 1024;

pub(super) fn hash_install_tree(root: &Path) -> anyhow::Result<String> {
    hash_install_tree_with_policy(root, true, MAX_MANAGED_TREE_ENTRIES, MAX_MANAGED_TREE_BYTES)
}

pub(super) fn hash_packaged_install_tree(root: &Path) -> anyhow::Result<String> {
    hash_install_tree_with_policy(
        root,
        false,
        MAX_PACKAGED_TREE_ENTRIES,
        MAX_PACKAGED_TREE_BYTES,
    )
}

fn hash_install_tree_with_policy(
    root: &Path,
    allow_symlinks: bool,
    max_entries: usize,
    max_bytes: u64,
) -> anyhow::Result<String> {
    if !root.is_dir() {
        bail!(
            "managed SRT installation root is not a directory: {}",
            root.display()
        );
    }
    let mut hasher = Sha256::new();
    let mut budget = TreeBudget::default();
    hash_directory(
        root,
        root,
        &mut hasher,
        allow_symlinks,
        max_entries,
        max_bytes,
        &mut budget,
    )?;
    Ok(format!("{:x}", hasher.finalize()))
}

#[derive(Debug, Default)]
struct TreeBudget {
    entries: usize,
    bytes: u64,
}

fn hash_directory(
    root: &Path,
    directory: &Path,
    hasher: &mut Sha256,
    allow_symlinks: bool,
    max_entries: usize,
    max_bytes: u64,
    budget: &mut TreeBudget,
) -> anyhow::Result<()> {
    let mut entries = std::fs::read_dir(directory)
        .with_context(|| format!("failed to read {}", directory.display()))?
        .collect::<Result<Vec<_>, _>>()?;
    entries.sort_by_key(std::fs::DirEntry::file_name);
    for entry in entries {
        budget.entries = budget
            .entries
            .checked_add(1)
            .context("managed SRT tree entry count overflowed")?;
        if budget.entries > max_entries {
            bail!("managed SRT tree exceeds the {max_entries} entry limit");
        }
        let path = entry.path();
        let relative = path
            .strip_prefix(root)
            .context("managed SRT tree entry escaped its root")?;
        let relative = normalized_relative_path(relative)?;
        let metadata = std::fs::symlink_metadata(&path)
            .with_context(|| format!("failed to inspect {}", path.display()))?;
        if metadata.file_type().is_symlink() {
            if !allow_symlinks {
                bail!(
                    "packaged managed SRT tree contains a symbolic link: {}",
                    path.display()
                );
            }
            hasher.update(b"link\0");
            hash_field(hasher, relative.as_bytes());
            let target = std::fs::read_link(&path)
                .with_context(|| format!("failed to read link {}", path.display()))?;
            hash_field(hasher, target.to_string_lossy().as_bytes());
        } else if metadata.is_dir() {
            hasher.update(b"dir\0");
            hash_field(hasher, relative.as_bytes());
            hash_directory(
                root,
                &path,
                hasher,
                allow_symlinks,
                max_entries,
                max_bytes,
                budget,
            )?;
        } else if metadata.is_file() {
            hasher.update(b"file\0");
            hash_field(hasher, relative.as_bytes());
            let mut file = std::fs::File::open(&path)
                .with_context(|| format!("failed to open {}", path.display()))?;
            let mut buffer = [0_u8; 64 * 1024];
            loop {
                let read = file
                    .read(&mut buffer)
                    .with_context(|| format!("failed to read {}", path.display()))?;
                if read == 0 {
                    break;
                }
                budget.bytes = budget
                    .bytes
                    .checked_add(read as u64)
                    .context("managed SRT tree byte count overflowed")?;
                if budget.bytes > max_bytes {
                    bail!("managed SRT tree exceeds the {max_bytes} byte limit");
                }
                hasher.update(&buffer[..read]);
            }
            hasher.update(b"\0");
        } else {
            bail!(
                "unsupported file type in managed SRT tree: {}",
                path.display()
            );
        }
    }
    Ok(())
}

fn normalized_relative_path(path: &Path) -> anyhow::Result<String> {
    let mut normalized = String::new();
    for component in path.components() {
        let std::path::Component::Normal(segment) = component else {
            bail!(
                "managed SRT tree contains an invalid relative path: {}",
                path.display()
            );
        };
        let segment = segment
            .to_str()
            .with_context(|| format!("managed SRT path is not UTF-8: {}", path.display()))?;
        if !normalized.is_empty() {
            normalized.push('/');
        }
        normalized.push_str(segment);
    }
    if normalized.is_empty() {
        bail!("managed SRT tree contains an empty relative path");
    }
    Ok(normalized)
}

fn hash_field(hasher: &mut Sha256, value: &[u8]) {
    hasher.update((value.len() as u64).to_le_bytes());
    hasher.update(value);
}