use std::fs::{self, File};
use std::io::{self, BufReader, Read};
use std::path::{Path, PathBuf};
use sha2::{Digest, Sha256};
use super::{INSTALL_MARKER, Result};
use crate::local::error::LocalError;
pub(super) fn parse_expected_digest(raw: &str) -> Result<String> {
let trimmed = raw.trim();
if trimmed.len() != 64 {
return Err(LocalError::InvalidDigest {
value: raw.to_owned(),
reason: "expected exactly 64 hexadecimal characters",
});
}
if !trimmed.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(LocalError::InvalidDigest {
value: raw.to_owned(),
reason: "expected only hexadecimal characters",
});
}
Ok(trimmed.to_ascii_lowercase())
}
pub(super) fn hex_digest(hasher: Sha256) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let bytes = hasher.finalize();
let mut output = String::with_capacity(bytes.len() * 2);
for byte in bytes {
output.push(char::from(HEX[usize::from(byte >> 4)]));
output.push(char::from(HEX[usize::from(byte & 0x0f)]));
}
output
}
pub(super) fn file_digest(path: &Path) -> Result<String> {
let file = File::open(path).map_err(|source| LocalError::Io {
operation: "open cached artifact",
path: path.to_owned(),
source,
})?;
let mut reader = BufReader::new(file);
let mut hasher = Sha256::new();
let mut buffer = vec![0_u8; 64 * 1024].into_boxed_slice();
loop {
let count = reader.read(&mut buffer).map_err(|source| LocalError::Io {
operation: "hash cached artifact",
path: path.to_owned(),
source,
})?;
if count == 0 {
break;
}
hasher.update(&buffer[..count]);
}
Ok(hex_digest(hasher))
}
pub(super) fn tree_digest(root: &Path) -> Result<String> {
let mut files = Vec::new();
collect_tree_files(root, root, &mut files)?;
files.sort_by(|left, right| left.0.cmp(&right.0));
let mut tree_hasher = Sha256::new();
for (relative, path) in files {
tree_hasher.update((relative.len() as u64).to_le_bytes());
tree_hasher.update(relative.as_bytes());
tree_hasher.update(file_mode(&path)?.to_le_bytes());
tree_hasher.update(file_digest(&path)?.as_bytes());
}
Ok(hex_digest(tree_hasher))
}
#[cfg(unix)]
fn file_mode(path: &Path) -> Result<u32> {
use std::os::unix::fs::PermissionsExt as _;
fs::metadata(path)
.map(|metadata| metadata.permissions().mode() & 0o7777)
.map_err(|source| LocalError::Io {
operation: "inspect cached artifact permissions",
path: path.to_owned(),
source,
})
}
#[cfg(not(unix))]
#[expect(
clippy::unnecessary_wraps,
reason = "matches the fallible Unix implementation at the call site"
)]
fn file_mode(_path: &Path) -> Result<u32> {
Ok(0)
}
fn collect_tree_files(
root: &Path,
directory: &Path,
files: &mut Vec<(String, PathBuf)>,
) -> Result<()> {
let entries = fs::read_dir(directory).map_err(|source| LocalError::Io {
operation: "read installation directory",
path: directory.to_owned(),
source,
})?;
for entry in entries {
let entry = entry.map_err(|source| LocalError::Io {
operation: "read installation entry",
path: directory.to_owned(),
source,
})?;
let path = entry.path();
let file_type = entry.file_type().map_err(|source| LocalError::Io {
operation: "inspect installation entry",
path: path.clone(),
source,
})?;
if file_type.is_dir() {
collect_tree_files(root, &path, files)?;
} else if file_type.is_file() && path != root.join(INSTALL_MARKER) {
let relative = path
.strip_prefix(root)
.map_err(|source| LocalError::Io {
operation: "resolve installation entry",
path: path.clone(),
source: io::Error::other(source),
})?
.to_str()
.ok_or_else(|| LocalError::InvalidPath { path: path.clone() })?
.replace('\\', "/");
files.push((relative, path));
} else if !file_type.is_file() {
return Err(LocalError::UnsafeArchiveEntry {
archive: root.display().to_string(),
entry: path.display().to_string(),
});
}
}
Ok(())
}