use std::collections::BTreeMap;
use std::io;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use thiserror::Error;
use crate::excludes::{ExcludeMatcher, FollowMode};
use crate::manifest::{Manifest, ManifestEntry, PathType};
use crate::merkle::Hasher;
use crate::progress::{Meter, Phase};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PathMode {
#[default]
Relative,
Absolute,
}
#[derive(Debug, Clone, Default)]
pub struct WalkOptions {
pub follow: FollowMode,
pub path_mode: PathMode,
pub exclude: Option<ExcludeMatcher>,
}
#[derive(Debug, Error)]
pub enum WalkError {
#[error("walk root must be an absolute path, got {0:?}")]
RootNotAbsolute(PathBuf),
#[error("walk root is not a directory: {0:?}")]
RootNotDirectory(PathBuf),
#[error("i/o error while walking {path:?}: {source}")]
Io {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("path is not valid UTF-8: {0:?}")]
NonUtf8Path(PathBuf),
}
impl WalkError {
fn io(path: impl Into<PathBuf>, source: io::Error) -> Self {
WalkError::Io {
path: path.into(),
source,
}
}
}
fn octal_permissions(mode: u32) -> String {
format!("{:o}", mode & 0o7777)
}
fn path_str(path: &Path) -> Result<&str, WalkError> {
path.to_str()
.ok_or_else(|| WalkError::NonUtf8Path(path.to_path_buf()))
}
struct FileRecord {
abs_path: String,
permissions: String,
checksum: String,
size: u64,
}
struct DirRecord {
abs_path: String,
permissions: String,
child_dirs: Vec<String>,
files: Vec<FileRecord>,
}
pub fn walk<H: Hasher>(
root: &Path,
options: &WalkOptions,
hasher: &H,
) -> Result<Manifest, WalkError> {
walk_with_meter(root, options, hasher, None)
}
pub fn walk_with_meter<H: Hasher>(
root: &Path,
options: &WalkOptions,
hasher: &H,
meter: Option<&Meter>,
) -> Result<Manifest, WalkError> {
if let Some(meter) = meter {
meter.set_phase(Phase::Hashing);
}
if !root.is_absolute() {
return Err(WalkError::RootNotAbsolute(root.to_path_buf()));
}
let root_meta = std::fs::metadata(root).map_err(|e| WalkError::io(root, e))?;
if !root_meta.is_dir() {
return Err(WalkError::RootNotDirectory(root.to_path_buf()));
}
let root_lstat = std::fs::symlink_metadata(root).map_err(|e| WalkError::io(root, e))?;
let root_permissions = octal_permissions(root_lstat.permissions().mode());
let root_str = path_str(root)?.to_owned();
let mut dirs: BTreeMap<String, DirRecord> = BTreeMap::new();
discover_dir(
root,
&root_str,
root_permissions,
options,
hasher,
meter,
&mut dirs,
)?;
let keys: Vec<String> = dirs.keys().cloned().collect();
let mut finalized: BTreeMap<String, (String, u64)> = BTreeMap::new();
for key in keys.iter().rev() {
let record = &dirs[key];
let mut child_checksums: Vec<String> = Vec::new();
let mut member_size: u64 = 0;
for file in &record.files {
child_checksums.push(file.checksum.clone());
member_size += file.size;
}
for child in &record.child_dirs {
let (csum, size) = finalized
.get(child)
.expect("child dir finalized before parent (reverse key order)");
child_checksums.push(csum.clone());
member_size += size;
}
let checksum =
crate::merkle::directory_checksum(child_checksums.iter().map(String::as_str), hasher);
finalized.insert(key.clone(), (checksum, member_size));
}
let mut manifest = Manifest::new();
for (key, record) in &dirs {
let (checksum, size) = &finalized[key];
let dir_path = render_dir_path(key, &root_str, options.path_mode);
manifest.push(ManifestEntry::new(
PathType::Directory,
record.permissions.clone(),
checksum.clone(),
*size,
dir_path,
));
for file in &record.files {
let file_path = rewrite_path(&file.abs_path, &root_str, options.path_mode);
manifest.push(ManifestEntry::new(
PathType::File,
file.permissions.clone(),
file.checksum.clone(),
file.size,
file_path,
));
}
}
manifest.sort();
Ok(manifest)
}
fn discover_dir<H: Hasher>(
dir: &Path,
abs_path: &str,
permissions: String,
options: &WalkOptions,
hasher: &H,
meter: Option<&Meter>,
dirs: &mut BTreeMap<String, DirRecord>,
) -> Result<(), WalkError> {
let mut record = DirRecord {
abs_path: abs_path.to_owned(),
permissions,
child_dirs: Vec::new(),
files: Vec::new(),
};
let read_dir = std::fs::read_dir(dir).map_err(|e| WalkError::io(dir, e))?;
for entry in read_dir {
let entry = entry.map_err(|e| WalkError::io(dir, e))?;
let entry_path = entry.path();
let entry_abs = path_str(&entry_path)?.to_owned();
if let Some(matcher) = &options.exclude {
if matcher.is_excluded(&entry_abs) {
continue;
}
}
let link_meta = entry
.metadata()
.or_else(|_| std::fs::symlink_metadata(&entry_path))
.map_err(|e| WalkError::io(&entry_path, e))?;
let is_symlink = link_meta.file_type().is_symlink();
if is_symlink && !options.follow.follows_symlinks() {
continue;
}
let target_meta = match std::fs::metadata(&entry_path) {
Ok(m) => m,
Err(e) => {
if is_symlink && (e.kind() == io::ErrorKind::NotFound || is_loop_error(&e)) {
continue;
}
return Err(WalkError::io(&entry_path, e));
}
};
let file_type = target_meta.file_type();
let own_permissions = octal_permissions(link_meta.permissions().mode());
if file_type.is_dir() {
record.child_dirs.push(entry_abs.clone());
discover_dir(
&entry_path,
&entry_abs,
own_permissions,
options,
hasher,
meter,
dirs,
)?;
} else if file_type.is_file() {
let bytes = std::fs::read(&entry_path).map_err(|e| WalkError::io(&entry_path, e))?;
let checksum = hasher.hash_hex(&bytes);
if let Some(meter) = meter {
meter.add_in(bytes.len() as u64);
meter.object_finished();
}
record.files.push(FileRecord {
abs_path: entry_abs,
permissions: own_permissions,
checksum,
size: link_meta.len(),
});
}
}
dirs.insert(record.abs_path.clone(), record);
Ok(())
}
fn is_loop_error(error: &io::Error) -> bool {
error.raw_os_error() == Some(libc_eloop())
}
const fn libc_eloop() -> i32 {
#[cfg(target_os = "linux")]
{
40
}
#[cfg(not(target_os = "linux"))]
{
62
}
}
fn render_dir_path(abs_path: &str, root: &str, mode: PathMode) -> String {
let rewritten = rewrite_path(abs_path, root, mode);
if rewritten.ends_with('/') {
rewritten
} else {
format!("{rewritten}/")
}
}
fn rewrite_path(abs_path: &str, root: &str, mode: PathMode) -> String {
match mode {
PathMode::Absolute => abs_path.to_owned(),
PathMode::Relative => {
if abs_path == root {
".".to_owned()
} else if let Some(rest) = abs_path.strip_prefix(root) {
format!(".{rest}")
} else {
abs_path.to_owned()
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::merkle::Blake3Hasher;
use crate::progress::{Meter, Phase};
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
struct Scratch {
path: PathBuf,
}
impl Scratch {
fn new(tag: &str) -> Self {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let pid = std::process::id();
let base = std::env::temp_dir()
.canonicalize()
.expect("temp dir canonicalizes");
let path = base.join(format!("snapdir-walk-test-{tag}-{pid}-{n}"));
let _ = fs::remove_dir_all(&path);
fs::create_dir_all(&path).expect("create scratch dir");
fs::set_permissions(&path, fs::Permissions::from_mode(0o755))
.expect("chmod scratch root");
Scratch { path }
}
fn root(&self) -> &Path {
&self.path
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.path);
}
}
fn write_file(path: &Path, contents: &[u8]) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("create parent dir");
}
fs::write(path, contents).expect("write file");
fs::set_permissions(path, fs::Permissions::from_mode(0o600)).expect("chmod file");
}
fn chmod_dirs(root: &Path, mode: u32) {
fs::set_permissions(root, fs::Permissions::from_mode(mode)).expect("chmod dir");
for entry in fs::read_dir(root).expect("read_dir").flatten() {
let ft = entry.file_type().expect("file_type");
if ft.is_dir() {
chmod_dirs(&entry.path(), mode);
}
}
}
fn opts(follow: FollowMode, path_mode: PathMode, exclude: Option<&str>) -> WalkOptions {
WalkOptions {
follow,
path_mode,
exclude: exclude.map(|p| ExcludeMatcher::new(p).expect("valid exclude regex")),
}
}
fn manifest_text(root: &Path, options: &WalkOptions) -> String {
walk(root, options, &Blake3Hasher::new())
.expect("walk")
.to_string()
}
const EMPTY_B3: &str = "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262";
#[test]
fn walk_root_must_be_absolute() {
let err = walk(
Path::new("relative/path"),
&WalkOptions::default(),
&Blake3Hasher::new(),
)
.unwrap_err();
assert!(matches!(err, WalkError::RootNotAbsolute(_)));
}
#[test]
fn walk_empty_directory_golden() {
let scratch = Scratch::new("empty-dir");
let expected = format!("D 755 {EMPTY_B3} 0 ./");
assert_eq!(
manifest_text(scratch.root(), &WalkOptions::default()),
expected
);
}
#[test]
fn walk_single_empty_file_golden() {
let scratch = Scratch::new("empty-file");
write_file(&scratch.root().join("empty.txt"), b"");
let expected = format!(
"D 755 dba5865c0d91b17958e4d2cac98c338f85cbbda07b71a020ab16c391b5e7af4b 0 ./\n\
F 600 {EMPTY_B3} 0 ./empty.txt"
);
assert_eq!(
manifest_text(scratch.root(), &WalkOptions::default()),
expected
);
}
const NESTED_RELATIVE_GOLDEN: &str = "\
D 700 3f938f681dcbd616d00d42f704d525c05e7ed2746888c35c8214127c632587c3 43 ./
D 700 ed23cfd2037d23cf8c6b67497425e7a06d5e40ea2bd8e43fc434006022dafe86 21 ./a/
F 600 3c9cb8b8c8f3588f8e59e18d284330b0a951be644fbef2b9784b56e15d1c6096 4 ./a/a1f
D 700 ee795476bff6c1816b4c7558a74ee0b44ec600c3cde6b02564508f67d536a656 17 ./a/aa/
F 600 a2951028421deef48d1ba185f4c497c2d986f1dd76079baf2f5eb8479f132b5a 5 ./a/aa/aa1f
D 700 8aed4caf45b22aa4c8a195945136e3a01f77864e91fabe2d9272feeee87ae334 12 ./a/aa/aaa/
F 600 5cfee4fb4074748633b4ccbddb6b184a9b5e2f5ce74df6d2803f5fea0392a197 6 ./a/aa/aaa/aaa1f
F 600 3791f11a017feedffd24c2656e18d5c4ca9d6c404c8f40ccc511b6351c8575a6 6 ./a/aa/aaa/aaa2f
D 700 9a8b0e35c000df69893648b91d15cc30ab88ae5a40af48228caf5fa443dafc9b 12 ./b/
D 700 d41c2090167e6f546a510f0da98d8a8355d6bd2b61666644604c73b3a8f5b5d9 12 ./b/bb/
D 700 3b9023fa454aa22466feeb8cbf55a2c764dd79de0e93c9a793e8b54caec227da 12 ./b/bb/bbb/
F 600 8d18b7f3aabbef192a524fa2549d1d36b48c9030d234c9bdf87caa267fb09933 6 ./b/bb/bbb/bbb1f
F 600 2e16e172b6e337325f271d4eae00bc1ea20e41609ef78665710cada1477005cc 6 ./b/bb/bbb/bbb2f
D 700 15eb2657c1e6f5a24023c10429bb6f1b7d81b2cc2057eedee2192fbf3e7b892c 6 ./c/
D 700 e711f4e76ae9b3e25ad9a32b5f115cc9a81e55a428c552aa0bcab8543967f51a 6 ./c/cc/
D 700 31a1955d5a65328f31014650cf79b5c0c3d9b82de19352ade8d299cc22f6ec40 6 ./c/cc/ccc/
F 600 24f0cf3553e0dac0ce8aead4279e0fc368899e89ef776999d0d7e812b5ca0f3b 6 ./c/cc/ccc/ccc1f
D 700 af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262 0 ./d/
F 600 27a55588c59999fd686667c4b186af08161b95c287216f0cde723f0e191d1974 4 ./r1f";
fn build_nested(root: &Path) {
write_file(&root.join("a/aa/aaa/aaa1f"), b"aaa1f\n");
write_file(&root.join("a/aa/aaa/aaa2f"), b"aaa2f\n");
write_file(&root.join("a/aa/aa1f"), b"aa1f\n");
write_file(&root.join("a/a1f"), b"a1f\n");
write_file(&root.join("r1f"), b"r1f\n");
write_file(&root.join("b/bb/bbb/bbb1f"), b"bbb1f\n");
write_file(&root.join("b/bb/bbb/bbb2f"), b"bbb2f\n");
write_file(&root.join("c/cc/ccc/ccc1f"), b"ccc1f\n");
fs::create_dir_all(root.join("d")).unwrap();
chmod_dirs(root, 0o700);
}
#[test]
fn walk_nested_tree_relative_golden() {
let scratch = Scratch::new("nested-rel");
build_nested(scratch.root());
assert_eq!(
manifest_text(
scratch.root(),
&opts(FollowMode::Follow, PathMode::Relative, None)
),
NESTED_RELATIVE_GOLDEN
);
}
#[test]
fn walk_nested_tree_absolute_golden() {
let scratch = Scratch::new("nested-abs");
let r = scratch.root();
build_nested(r);
let root_str = r.to_str().unwrap();
let expected: String = NESTED_RELATIVE_GOLDEN
.lines()
.map(|line| {
let (head, path) = line.rsplit_once(' ').unwrap();
let abs_path = if path == "./" {
format!("{root_str}/")
} else {
format!("{root_str}/{}", path.strip_prefix("./").unwrap())
};
format!("{head} {abs_path}")
})
.collect::<Vec<_>>()
.join("\n");
assert_eq!(
manifest_text(r, &opts(FollowMode::Follow, PathMode::Absolute, None)),
expected
);
}
#[test]
fn walk_directory_size_is_sum_of_members_golden() {
let scratch = Scratch::new("dir-size");
let r = scratch.root();
write_file(&r.join("f1"), b"hello"); write_file(&r.join("sub/f2"), b"world!!"); write_file(&r.join("sub/f3"), b"x"); chmod_dirs(r, 0o700);
let expected = "\
D 700 5681c72cfd0ddea4f54683365bc4082b92147bf33976875653133cc4aed0f96a 13 ./
F 600 ea8f163db38682925e4491c5e58d4bb3506ef8c14eb78a86e908c5624a67200f 5 ./f1
D 700 2ac73ec4f4ec2ef21ebfba467be499a58aef80a34d7001d68bdeb14cb58a954d 8 ./sub/
F 600 8bafa24d36bc2aa6edc0d041e763cb59ebadb71b6e63ab4ac9314de95e9a0de7 7 ./sub/f2
F 600 3ae7d805f6789a6402acb70ad4096a85a56bf6804eaf25c0493ac697548d30b5 1 ./sub/f3";
let manifest = walk(r, &WalkOptions::default(), &Blake3Hasher::new()).expect("walk");
assert_eq!(manifest.to_string(), expected);
let root_dir = manifest.entries().iter().find(|e| e.path == "./").unwrap();
let sub_dir = manifest
.entries()
.iter()
.find(|e| e.path == "./sub/")
.unwrap();
assert_eq!(sub_dir.size, 8, "sub = f2(7) + f3(1)");
assert_eq!(root_dir.size, 13, "root = f1(5) + sub(8)");
}
fn build_symlinks(root: &Path) {
write_file(&root.join("a/aa/f1"), b"hello");
write_file(&root.join("a/f2"), b"world!!");
write_file(&root.join("r1f"), b"r");
std::os::unix::fs::symlink("a", root.join("a_link")).expect("symlink dir");
std::os::unix::fs::symlink("r1f", root.join("r1f_link")).expect("symlink file");
chmod_dirs(root, 0o700);
}
#[test]
fn walk_symlink_followed_by_default() {
let scratch = Scratch::new("symlink-follow");
let r = scratch.root();
build_symlinks(r);
let manifest = manifest_text(r, &opts(FollowMode::Follow, PathMode::Relative, None));
let a_dir_b3 = "0c862ed8e62262f84e7fc0fe4a6c566adec4a85ef22f8a46b7ad4c9344146701";
assert!(
manifest
.lines()
.any(|l| l.starts_with("D ") && l.contains(a_dir_b3) && l.ends_with(" ./a/")),
"real ./a/ dir present with its merkle: {manifest}"
);
assert!(
manifest
.lines()
.any(|l| l.starts_with("D ") && l.contains(a_dir_b3) && l.ends_with(" ./a_link/")),
"followed symlink dir ./a_link/ mirrors ./a/'s merkle: {manifest}"
);
assert!(manifest.lines().any(|l| l.ends_with(" ./a_link/aa/")));
assert!(manifest.lines().any(|l| {
l.starts_with("F ")
&& l.contains("ea8f163db38682925e4491c5e58d4bb3506ef8c14eb78a86e908c5624a67200f")
&& l.ends_with(" ./a_link/aa/f1")
}));
let r1f_b3 = "b2dea48d667b2821a9bcf69eded39a2458a1d8165ca7fcac64c3557b69a7ea08";
assert!(
manifest
.lines()
.any(|l| l.starts_with("F ") && l.contains(r1f_b3) && l.ends_with(" ./r1f_link")),
"followed symlink file ./r1f_link present: {manifest}"
);
assert!(
manifest
.lines()
.any(|l| l.starts_with("F ") && l.contains(r1f_b3) && l.ends_with(" ./r1f")),
"real ./r1f present: {manifest}"
);
}
#[test]
fn walk_no_follow_drops_symlinks() {
let scratch = Scratch::new("symlink-nofollow");
let r = scratch.root();
build_symlinks(r);
let expected = "\
D 700 61a8f1898844a17eeed84d34c2e3b5fd9c7fef136dba5f7036ae70294595a085 13 ./
D 700 0c862ed8e62262f84e7fc0fe4a6c566adec4a85ef22f8a46b7ad4c9344146701 12 ./a/
D 700 6cd17c61c7e42c50586ee5f3f54dbc4f809f71073fc176ed2ae865103dd33625 5 ./a/aa/
F 600 ea8f163db38682925e4491c5e58d4bb3506ef8c14eb78a86e908c5624a67200f 5 ./a/aa/f1
F 600 8bafa24d36bc2aa6edc0d041e763cb59ebadb71b6e63ab4ac9314de95e9a0de7 7 ./a/f2
F 600 b2dea48d667b2821a9bcf69eded39a2458a1d8165ca7fcac64c3557b69a7ea08 1 ./r1f";
let manifest = manifest_text(r, &opts(FollowMode::NoFollow, PathMode::Relative, None));
assert_eq!(manifest, expected);
assert!(!manifest.contains("_link"), "no-follow drops all symlinks");
}
#[test]
fn walk_exclude_regex_golden() {
let scratch = Scratch::new("exclude-regex");
let r = scratch.root();
write_file(&r.join("keep/k"), b"x");
write_file(&r.join("drop/d"), b"y");
write_file(&r.join("top.txt"), b"top");
chmod_dirs(r, 0o700);
let abs = r.to_str().unwrap();
let pattern = format!("{abs}/drop");
let manifest = manifest_text(
r,
&opts(FollowMode::Follow, PathMode::Relative, Some(&pattern)),
);
let expected = "\
D 700 b6f1055a5f14fdd55fa831ff6d2e2f433c7ca7fa2cc43e63a8cd0a4542d3010a 4 ./
D 700 b9030f201b43e2a72e62951476c0bcfafe3b020ece221d2254d8610ea9e88fb5 1 ./keep/
F 600 3ae7d805f6789a6402acb70ad4096a85a56bf6804eaf25c0493ac697548d30b5 1 ./keep/k
F 600 ef854702aa94ba4f60c67d731671c9e0e49a031be6ce475489e91f7a33cb5243 3 ./top.txt";
assert_eq!(manifest, expected);
assert!(!manifest.contains("drop"), "drop/ excluded");
}
#[test]
fn walk_exclude_common_golden() {
let scratch = Scratch::new("exclude-common");
let r = scratch.root();
write_file(&r.join("src/main.rs"), b"fn main() {}\n");
write_file(&r.join(".git/objects/secret"), b"secret");
write_file(&r.join("node_modules/pkg/index.js"), b"//js\n");
chmod_dirs(r, 0o700);
let expanded = crate::excludes::expand_excludes(
"%common%",
"/nonexistent/.cache/",
"/nonexistent/cache",
);
let pattern = expanded.pattern.expect("non-empty");
let manifest = manifest_text(
r,
&opts(FollowMode::Follow, PathMode::Relative, Some(&pattern)),
);
let expected = "\
D 700 ad5409ad5f97a26c908382b379b23971ee143e6bcd29a7d663175936d2cd4e94 13 ./
D 700 069cd5e102d7dd39faa7093b5b2d784c32e19b01f829a902c14aa10b7182debc 13 ./src/
F 600 2d1ebfa706ba230165250f744796a92accba5e1b6fa357983b65319da33f8e93 13 ./src/main.rs";
assert_eq!(manifest, expected);
assert!(!manifest.contains(".git"), "%common% excludes .git");
assert!(
!manifest.contains("node_modules"),
"%common% excludes node_modules"
);
}
#[test]
fn progress_meter_walk_records_files_and_bytes() {
let scratch = Scratch::new("meter-records");
let r = scratch.root();
write_file(&r.join("f1"), b"hello"); write_file(&r.join("sub/f2"), b"world!!"); write_file(&r.join("sub/f3"), b"x"); chmod_dirs(r, 0o700);
let meter = Meter::new();
let _ = walk_with_meter(
r,
&WalkOptions::default(),
&Blake3Hasher::new(),
Some(&meter),
)
.expect("walk");
let snap = meter.snapshot();
assert_eq!(snap.bytes_in, 5 + 7 + 1, "sum of file byte lengths");
assert_eq!(snap.objects_done, 3, "one finished object per file");
assert_eq!(snap.in_flight, 0, "no object left in flight");
assert_eq!(snap.phase, Phase::Hashing, "walk sets the Hashing phase");
}
#[test]
fn progress_meter_walk_output_unchanged() {
let scratch = Scratch::new("meter-unchanged");
let r = scratch.root();
build_nested(r);
let opts = opts(FollowMode::Follow, PathMode::Relative, None);
let without = walk(r, &opts, &Blake3Hasher::new()).expect("walk");
let meter = Meter::new();
let with = walk_with_meter(r, &opts, &Blake3Hasher::new(), Some(&meter)).expect("walk");
assert_eq!(
without.to_string(),
with.to_string(),
"meter recording must not change the manifest"
);
assert_eq!(meter.snapshot().objects_done, 8);
}
#[test]
fn walk_snapshot_id_is_blake3_of_manifest_text() {
let scratch = Scratch::new("snapshot-id");
let r = scratch.root();
write_file(&r.join("a/f1"), b"hello\n");
write_file(&r.join("b/f2"), b"world\n");
chmod_dirs(r, 0o700);
let hasher = Blake3Hasher::new();
let manifest = walk(r, &WalkOptions::default(), &hasher).expect("walk");
let id = crate::merkle::snapshot_id(&manifest, &hasher);
let mut bytes = manifest.to_string().into_bytes();
bytes.push(b'\n');
let expected = hasher.hash_hex(&bytes);
assert_eq!(
id, expected,
"snapshot id == blake3(manifest_text + \"\\n\")"
);
assert_eq!(id.len(), 64, "id is 64 lowercase hex chars");
assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
}
}