use std::fs;
use std::io::{self, Read};
use std::path::Path;
use sha2::{Digest, Sha256};
use crate::setup_core::canonical;
use crate::setup_core::error::{Error, ReasonCode, Result};
pub const PREFIX: &str = "sha256:";
fn hex(bytes: &[u8]) -> String {
use std::fmt::Write;
bytes
.iter()
.fold(String::with_capacity(bytes.len() * 2), |mut out, byte| {
let _ = write!(out, "{byte:02x}");
out
})
}
#[must_use]
pub fn of_bytes(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
format!("{PREFIX}{}", hex(&hasher.finalize()))
}
pub fn of_canonical_json(value: &serde_json::Value) -> Result<String> {
Ok(of_bytes(&canonical::to_canonical_bytes(value)?))
}
#[must_use]
pub fn of_domain_bytes(domain: &str, payload: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(domain.as_bytes());
hasher.update([0]);
hasher.update(payload);
format!("{PREFIX}{}", hex(&hasher.finalize()))
}
pub fn of_domain_canonical_json(domain: &str, value: &serde_json::Value) -> Result<String> {
Ok(of_domain_bytes(
domain,
&canonical::to_canonical_bytes(value)?,
))
}
pub fn of_file(path: &Path) -> Result<String> {
let mut file = fs::File::open(path).map_err(|source| {
Error::new(
ReasonCode::StateUnavailable,
format!("cannot open {}", path.display()),
)
.with_source(source)
})?;
let mut hasher = Sha256::new();
let mut buffer = vec![0_u8; 64 * 1024].into_boxed_slice();
loop {
let read = read_chunk(&mut file, &mut buffer).map_err(|source| {
Error::new(
ReasonCode::StateUnavailable,
format!("cannot read {}", path.display()),
)
.with_source(source)
})?;
if read == 0 {
break;
}
match buffer.get(..read) {
Some(chunk) => hasher.update(chunk),
None => {
return Err(Error::new(
ReasonCode::StateUnavailable,
"read reported more bytes than the buffer holds",
));
}
}
}
Ok(format!("{PREFIX}{}", hex(&hasher.finalize())))
}
fn read_chunk(file: &mut fs::File, buffer: &mut [u8]) -> io::Result<usize> {
file.read(buffer)
}
pub fn of_tree(root: &Path) -> Result<String> {
of_tree_excluding(root, &[])
}
pub fn of_owned(root: &Path, namespaces: &[&str], excluded: &[&str]) -> Result<String> {
if !root.is_dir() {
return Err(Error::new(
ReasonCode::InvalidTarget,
format!("{} is not a directory", root.display()),
));
}
let mut owned: Vec<&str> = namespaces
.iter()
.copied()
.filter(|name| !excluded.contains(name))
.collect();
owned.sort_unstable();
owned.dedup();
let cover: Vec<&str> = owned
.iter()
.copied()
.filter(|name| {
!owned.iter().any(|other| {
other != name
&& name
.strip_prefix(*other)
.is_some_and(|rest| rest.starts_with('/'))
})
})
.collect();
let owned = cover;
let mut entries = Vec::new();
for namespace in owned {
let path = namespace
.split('/')
.fold(root.to_path_buf(), |at, part| at.join(part));
if let Some(metadata) = stat_if_present(&path)?
&& describe(root, &path, &metadata, &mut entries)?
&& metadata.is_dir()
{
collect(root, &path, excluded, &mut entries)?;
}
}
entries.sort_by(|left, right| left.0.cmp(&right.0));
Ok(fold(entries))
}
pub fn of_tree_excluding(root: &Path, excluded_top_level: &[&str]) -> Result<String> {
if !root.is_dir() {
return Err(Error::new(
ReasonCode::InvalidTarget,
format!("{} is not a directory", root.display()),
));
}
let mut entries = Vec::new();
collect(root, root, excluded_top_level, &mut entries)?;
entries.sort_by(|left, right| left.0.cmp(&right.0));
Ok(fold(entries))
}
fn fold(entries: Vec<(String, String, String)>) -> String {
let mut hasher = Sha256::new();
for (relative, kind, payload) in entries {
hasher.update(relative.as_bytes());
hasher.update([0]);
hasher.update(kind.as_bytes());
hasher.update([0]);
hasher.update(payload.as_bytes());
hasher.update([0]);
}
format!("{PREFIX}{}", hex(&hasher.finalize()))
}
fn describe(
root: &Path,
path: &Path,
metadata: &fs::Metadata,
out: &mut Vec<(String, String, String)>,
) -> Result<bool> {
let relative = relative_slash_path(root, path)?;
if metadata.is_symlink() {
let mut read = None;
for attempt in 0..ATTEMPTS_BEFORE_BELIEVING_A_REFUSAL {
match fs::read_link(path) {
Ok(destination) => {
read = Some(destination);
break;
}
Err(source) => {
if source.kind() == std::io::ErrorKind::NotFound {
return Ok(false);
}
if attempt + 1 == ATTEMPTS_BEFORE_BELIEVING_A_REFUSAL {
return Err(Error::new(
ReasonCode::StateUnavailable,
format!("cannot read link {}", path.display()),
)
.with_source(source));
}
if attempt == 0 {
std::thread::yield_now();
} else {
std::thread::sleep(std::time::Duration::from_millis(1));
}
}
}
}
let Some(destination) = read else {
return Ok(false);
};
out.push((
relative,
"link".to_owned(),
destination.to_string_lossy().into_owned(),
));
} else if metadata.is_dir() {
out.push((relative, "dir".to_owned(), String::new()));
} else {
let Some(content) = of_file_if_present(path)? else {
return Ok(false);
};
let executable = if is_executable(metadata) { "x" } else { "-" };
out.push((relative, format!("file:{executable}"), content));
}
Ok(true)
}
const ATTEMPTS_BEFORE_BELIEVING_A_REFUSAL: u8 = 8;
fn stat_if_present(path: &Path) -> Result<Option<fs::Metadata>> {
for attempt in 0..ATTEMPTS_BEFORE_BELIEVING_A_REFUSAL {
match fs::symlink_metadata(path) {
Ok(metadata) => return Ok(Some(metadata)),
Err(source) => {
if source.kind() == std::io::ErrorKind::NotFound {
return Ok(None);
}
if attempt + 1 == ATTEMPTS_BEFORE_BELIEVING_A_REFUSAL {
return Err(Error::new(
ReasonCode::StateUnavailable,
format!("cannot stat {}", path.display()),
)
.with_source(source));
}
if attempt == 0 {
std::thread::yield_now();
} else {
std::thread::sleep(std::time::Duration::from_millis(1));
}
}
}
}
Err(Error::new(
ReasonCode::StateUnavailable,
format!("cannot stat {}", path.display()),
))
}
fn of_file_if_present(path: &Path) -> Result<Option<String>> {
for attempt in 0..ATTEMPTS_BEFORE_BELIEVING_A_REFUSAL {
match of_file(path) {
Ok(digest) => return Ok(Some(digest)),
Err(error) => {
if error.is_missing_path() || fs::symlink_metadata(path).is_err() {
return Ok(None);
}
if attempt + 1 == ATTEMPTS_BEFORE_BELIEVING_A_REFUSAL {
return Err(error);
}
if attempt == 0 {
std::thread::yield_now();
} else {
std::thread::sleep(std::time::Duration::from_millis(1));
}
}
}
}
Err(Error::new(
ReasonCode::StateUnavailable,
format!("cannot open {}", path.display()),
))
}
fn collect(
root: &Path,
current: &Path,
excluded_top_level: &[&str],
out: &mut Vec<(String, String, String)>,
) -> Result<()> {
let read = fs::read_dir(current).map_err(|source| {
Error::new(
ReasonCode::StateUnavailable,
format!("cannot list {}", current.display()),
)
.with_source(source)
})?;
for entry in read {
let entry = entry.map_err(|source| {
Error::new(
ReasonCode::StateUnavailable,
format!("cannot read an entry of {}", current.display()),
)
.with_source(source)
})?;
let path = entry.path();
if path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(crate::setup_core::lock::is_staging_name)
{
continue;
}
let relative = relative_slash_path(root, &path)?;
if current == root && excluded_top_level.contains(&relative.as_str()) {
continue;
}
let Some(metadata) = stat_if_present(&path)? else {
continue;
};
if !describe(root, &path, &metadata, out)? {
continue;
}
if metadata.is_dir() && !metadata.is_symlink() {
collect(root, &path, excluded_top_level, out)?;
}
}
Ok(())
}
fn relative_slash_path(root: &Path, path: &Path) -> Result<String> {
let relative = path.strip_prefix(root).map_err(|source| {
Error::new(
ReasonCode::StateUnavailable,
format!("{} is not inside {}", path.display(), root.display()),
)
.with_source(source)
})?;
let joined: Vec<String> = relative
.components()
.map(|component| component.as_os_str().to_string_lossy().into_owned())
.collect();
Ok(joined.join("/"))
}
#[cfg(unix)]
fn is_executable(metadata: &fs::Metadata) -> bool {
use std::os::unix::fs::PermissionsExt;
metadata.permissions().mode() & 0o111 != 0
}
#[cfg(not(unix))]
fn is_executable(_metadata: &fs::Metadata) -> bool {
false
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::panic)]
use super::*;
fn scratch(name: &str) -> std::path::PathBuf {
let base =
std::env::temp_dir().join(format!("setup-core-digest-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&base);
fs::create_dir_all(&base).unwrap();
base
}
#[test]
fn naming_a_path_already_covered_cannot_move_the_identity() {
let base = scratch("cover");
fs::create_dir_all(base.join("plugins/local/floor")).unwrap();
fs::write(base.join("plugins/local/floor/plugin.json"), b"{}").unwrap();
fs::create_dir_all(base.join("plugins-extra")).unwrap();
fs::write(base.join("plugins-extra/note"), b"beside, not inside").unwrap();
let parent_only = of_owned(&base, &["cli-config.json", "plugins"], &[]).unwrap();
let both = of_owned(&base, &["cli-config.json", "plugins", "plugins/local"], &[]).unwrap();
let child_first =
of_owned(&base, &["plugins/local", "plugins", "cli-config.json"], &[]).unwrap();
assert_eq!(parent_only, both);
assert_eq!(parent_only, child_first);
let with_sibling = of_owned(
&base,
&[
"cli-config.json",
"plugins",
"plugins-extra",
"plugins/local",
],
&[],
)
.unwrap();
assert_ne!(parent_only, with_sibling);
}
#[test]
fn bytes_are_tagged_with_the_algorithm() {
assert!(of_bytes(b"x").starts_with(PREFIX));
}
#[test]
fn a_domain_changes_the_digest_of_identical_bytes() {
let one = of_domain_bytes("ai-stp:provider-plan:v3", b"payload");
let two = of_domain_bytes("ai-stp:provider-projection:v3", b"payload");
assert_ne!(one, two);
assert_ne!(one, of_bytes(b"payload"));
}
#[test]
fn the_domain_separator_is_a_nul_byte_not_a_concatenation() {
assert_ne!(of_domain_bytes("ab", b"c"), of_domain_bytes("a", b"bc"));
}
#[test]
fn a_tree_digest_ignores_mtime_but_notices_bytes() {
let root = scratch("mtime");
fs::write(root.join("a.txt"), "one").unwrap();
let first = of_tree(&root).unwrap();
fs::write(root.join("a.txt"), "one").unwrap();
assert_eq!(of_tree(&root).unwrap(), first);
fs::write(root.join("a.txt"), "two").unwrap();
assert_ne!(of_tree(&root).unwrap(), first);
}
#[cfg(unix)]
#[test]
fn a_tree_digest_notices_mode_drift_that_content_cannot_show() {
use std::os::unix::fs::PermissionsExt;
let root = scratch("mode");
let file = root.join("run.sh");
fs::write(&file, "#!/bin/sh\n").unwrap();
fs::set_permissions(&file, fs::Permissions::from_mode(0o644)).unwrap();
let plain = of_tree(&root).unwrap();
fs::set_permissions(&file, fs::Permissions::from_mode(0o755)).unwrap();
assert_ne!(of_tree(&root).unwrap(), plain);
}
#[test]
fn a_missing_root_is_an_invalid_target_not_an_io_error() {
let error = of_tree(std::path::Path::new("/definitely/not/here")).unwrap_err();
assert_eq!(error.reason(), ReasonCode::InvalidTarget);
}
#[test]
fn gone_is_skipped_and_denied_is_refused() {
let root = scratch("gone-or-denied");
let absent = root.join("never-was.md");
assert!(
matches!(stat_if_present(&absent), Ok(None)),
"a path that is not there must be skipped"
);
let present = root.join("still-here.md");
fs::write(&present, b"x").unwrap();
assert!(
matches!(stat_if_present(&present), Ok(Some(_))),
"a path that is there must be stated"
);
}
#[test]
fn a_walk_racing_a_writer_skips_what_vanishes_rather_than_refusing() {
let root = scratch("racing-writer");
fs::create_dir_all(root.join("skills")).unwrap();
for index in 0..64 {
fs::write(root.join("skills").join(format!("{index}.md")), b"x").unwrap();
}
let churn = root.join("skills");
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let flag = std::sync::Arc::clone(&stop);
let writer = std::thread::spawn(move || {
while !flag.load(std::sync::atomic::Ordering::Relaxed) {
for index in 0..64 {
let path = churn.join(format!("{index}.md"));
let _ = fs::remove_file(&path);
let _ = fs::write(&path, b"x");
}
}
});
let mut refusals = Vec::new();
for _ in 0..200 {
if let Err(error) = of_owned(&root, &["skills"], &[]) {
refusals.push(error.to_string());
break;
}
}
stop.store(true, std::sync::atomic::Ordering::Relaxed);
writer.join().unwrap();
assert!(
refusals.is_empty(),
"a walk racing a writer refused instead of skipping: {refusals:?}"
);
}
#[cfg(unix)]
#[test]
fn a_file_this_process_may_not_read_is_still_a_refusal() {
use std::os::unix::fs::PermissionsExt;
let root = scratch("denied-not-vanished");
fs::create_dir_all(root.join("skills")).unwrap();
let denied = root.join("skills").join("locked.md");
fs::write(&denied, b"secret").unwrap();
fs::set_permissions(&denied, fs::Permissions::from_mode(0o000)).unwrap();
if fs::File::open(&denied).is_ok() {
fs::set_permissions(&denied, fs::Permissions::from_mode(0o644)).unwrap();
return;
}
let walked = of_owned(&root, &["skills"], &[]);
fs::set_permissions(&denied, fs::Permissions::from_mode(0o644)).unwrap();
match walked {
Ok(digest) => panic!(
"a file that cannot be read was skipped rather than refused, \
and the walk reported a digest for a target it did not read: {digest}"
),
Err(error) => assert!(
error.to_string().contains("cannot open"),
"the refusal should name the open that failed: {error}"
),
}
}
}