use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use sha2::{Digest, Sha256};
use crate::atp::object::{ContentId, ContentIdHasher, Object, ObjectEdge, ObjectId, ObjectKind};
use crate::io::AsyncReadExt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StreamingError(String);
impl StreamingError {
#[must_use]
pub fn new(message: impl Into<String>) -> Self {
Self(message.into())
}
#[must_use]
pub fn message(&self) -> &str {
&self.0
}
#[must_use]
pub fn into_message(self) -> String {
self.0
}
}
impl std::fmt::Display for StreamingError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for StreamingError {}
#[derive(Debug, Clone)]
pub struct EntryDigest {
pub rel_path: String,
pub size: u64,
pub content_id: ObjectId,
pub content_sha256: [u8; 32],
}
enum FlatNode<'a> {
File {
size: u64,
content_sha256: &'a [u8; 32],
},
Dir {
kind: ObjectKind,
size_bytes: Option<u64>,
children: Vec<ObjectEdge>,
},
}
#[must_use]
pub fn flat_merkle_root_from_digests(entries: &[EntryDigest]) -> String {
let mut sorted: Vec<&EntryDigest> = entries.iter().collect();
sorted.sort_by(|a, b| a.rel_path.cmp(&b.rel_path));
let mut objects: BTreeMap<ObjectId, FlatNode<'_>> = BTreeMap::new();
let mut edges = Vec::with_capacity(sorted.len());
for entry in sorted {
objects
.entry(entry.content_id.clone())
.or_insert(FlatNode::File {
size: entry.size,
content_sha256: &entry.content_sha256,
});
edges.push(ObjectEdge::new(
entry.content_id.clone(),
entry.rel_path.clone(),
));
}
let root = Object::directory(edges);
objects.insert(
root.id,
FlatNode::Dir {
kind: root.metadata.kind,
size_bytes: root.metadata.size_bytes,
children: root.children,
},
);
let mut hasher = Sha256::new();
for (id, node) in objects {
hasher.update(id.hash_bytes());
match node {
FlatNode::File {
size,
content_sha256,
} => {
hasher.update([ObjectKind::FileObject as u8]);
hasher.update(size.to_be_bytes());
hasher.update(content_sha256);
}
FlatNode::Dir {
kind,
size_bytes,
children,
} => {
hasher.update([kind as u8]);
if let Some(size) = size_bytes {
hasher.update(size.to_be_bytes());
}
let mut child_indices: Vec<usize> = (0..children.len()).collect();
child_indices.sort_by(|&a, &b| children[a].name.cmp(&children[b].name));
for idx in child_indices {
let edge = &children[idx];
hasher.update(edge.name.as_bytes());
hasher.update(edge.child_id.hash_bytes());
hasher.update([u8::from(edge.is_symlink)]);
if let Some(target) = &edge.symlink_target {
hasher.update(target.as_os_str().as_encoded_bytes());
}
}
}
}
}
hex_encode(&hasher.finalize())
}
#[must_use]
pub fn flat_merkle_root_from_slices<'a>(
entries: impl IntoIterator<Item = (&'a str, &'a [u8])>,
) -> String {
let digests: Vec<EntryDigest> = entries
.into_iter()
.map(|(rel_path, bytes)| EntryDigest {
rel_path: rel_path.to_string(),
size: bytes.len() as u64,
content_id: ObjectId::content(ContentId::from_bytes(bytes)),
content_sha256: Sha256::digest(bytes).into(),
})
.collect();
flat_merkle_root_from_digests(&digests)
}
#[must_use]
pub fn hex_encode(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2);
for b in bytes {
out.push(char::from_digit(u32::from(b >> 4), 16).unwrap_or('0'));
out.push(char::from_digit(u32::from(b & 0x0f), 16).unwrap_or('0'));
}
out
}
#[derive(Debug, Clone)]
pub struct SourceEntry {
pub rel_path: String,
pub abs_path: PathBuf,
}
pub async fn collect_entries(
root: &Path,
) -> Result<(String, bool, Vec<SourceEntry>), StreamingError> {
let meta = crate::fs::metadata(root)
.await
.map_err(|e| StreamingError::new(format!("{}: {e}", root.display())))?;
let root_name = root.file_name().map_or_else(
|| "transfer".to_string(),
|n| n.to_string_lossy().into_owned(),
);
if meta.is_file() {
return Ok((
root_name.clone(),
false,
vec![SourceEntry {
rel_path: root_name,
abs_path: root.to_path_buf(),
}],
));
}
if meta.is_dir() {
let mut entries = Vec::new();
collect_dir(root, String::new(), &mut entries).await?;
entries.sort_by(|a, b| a.rel_path.cmp(&b.rel_path));
return Ok((root_name, true, entries));
}
Err(StreamingError::new(format!(
"{}: not a regular file or directory",
root.display()
)))
}
fn collect_dir<'a>(
dir: &'a Path,
prefix: String,
out: &'a mut Vec<SourceEntry>,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), StreamingError>> + Send + 'a>> {
Box::pin(async move {
let mut read_dir = crate::fs::read_dir(dir)
.await
.map_err(|e| StreamingError::new(format!("{}: {e}", dir.display())))?;
let mut children: Vec<(String, PathBuf, bool)> = Vec::new();
while let Some(entry) = read_dir
.next_entry()
.await
.map_err(|e| StreamingError::new(format!("{}: {e}", dir.display())))?
{
let name = entry.file_name().to_string_lossy().into_owned();
let path = entry.path();
let ft = entry
.file_type()
.await
.map_err(|e| StreamingError::new(format!("{}: {e}", path.display())))?;
children.push((name, path, ft.is_dir()));
}
children.sort_by(|a, b| a.0.cmp(&b.0));
if children.is_empty() && !prefix.is_empty() {
out.push(SourceEntry {
rel_path: prefix,
abs_path: dir.to_path_buf(),
});
return Ok(());
}
for (name, path, is_dir) in children {
let rel = if prefix.is_empty() {
name.clone()
} else {
format!("{prefix}/{name}")
};
if is_dir {
collect_dir(&path, rel, out).await?;
} else {
out.push(SourceEntry {
rel_path: rel,
abs_path: path,
});
}
}
Ok(())
})
}
pub async fn hash_file_streaming(
path: &Path,
buf: &mut [u8],
) -> Result<(u64, ObjectId, [u8; 32]), StreamingError> {
let mut file = crate::fs::File::open(path)
.await
.map_err(|e| StreamingError::new(format!("{}: {e}", path.display())))?;
let mut sha = Sha256::new();
let mut cid: ContentIdHasher = ContentId::streaming();
let mut size: u64 = 0;
loop {
let n = file
.read(buf)
.await
.map_err(|e| StreamingError::new(format!("{}: {e}", path.display())))?;
if n == 0 {
break;
}
sha.update(&buf[..n]);
cid.update(&buf[..n]);
size = size.saturating_add(n as u64);
}
let content_sha256: [u8; 32] = sha.finalize().into();
let content_id = ObjectId::content(cid.finalize());
Ok((size, content_id, content_sha256))
}
pub struct StagedEntryReceive {
pub staging_path: PathBuf,
pub bytes_written: u64,
pub created: bool,
sha: Sha256,
cid: ContentIdHasher,
}
impl StagedEntryReceive {
#[must_use]
pub fn new(staging_path: PathBuf) -> Self {
Self {
staging_path,
bytes_written: 0,
created: false,
sha: Sha256::new(),
cid: ContentId::streaming(),
}
}
pub fn mark_created(&mut self) {
self.created = true;
}
pub fn update_with_chunk(&mut self, chunk: &[u8]) {
self.sha.update(chunk);
self.cid.update(chunk);
self.bytes_written = self.bytes_written.saturating_add(chunk.len() as u64);
}
#[must_use]
pub fn finalize(self, rel_path: String) -> (EntryDigest, PathBuf, bool) {
let content_sha256: [u8; 32] = self.sha.finalize().into();
let content_id = ObjectId::content(self.cid.finalize());
(
EntryDigest {
rel_path,
size: self.bytes_written,
content_id,
content_sha256,
},
self.staging_path,
self.created,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn digest_of(rel: &str, bytes: &[u8]) -> EntryDigest {
EntryDigest {
rel_path: rel.to_string(),
size: bytes.len() as u64,
content_id: ObjectId::content(ContentId::from_bytes(bytes)),
content_sha256: Sha256::digest(bytes).into(),
}
}
#[test]
fn hex_encode_is_lowercase_two_chars_per_byte() {
assert_eq!(hex_encode(&[0x00, 0x0f, 0xa0, 0xff]), "000fa0ff");
assert_eq!(hex_encode(&[]), "");
}
#[test]
fn merkle_root_is_deterministic_order_independent_and_64_hex() {
let a = vec![digest_of("a.txt", b"alpha"), digest_of("b.txt", b"bravo")];
let b = vec![digest_of("b.txt", b"bravo"), digest_of("a.txt", b"alpha")];
let ra = flat_merkle_root_from_digests(&a);
let rb = flat_merkle_root_from_digests(&b);
assert_eq!(ra, rb, "merkle root must be independent of entry order");
assert_eq!(ra.len(), 64, "sha-256 hex root is 64 chars");
assert!(
ra.bytes().all(|c| c.is_ascii_hexdigit()),
"root must be lowercase hex"
);
}
#[test]
fn matches_canonical_owned_object_graph_root() {
use crate::atp::manifest::MerkleRoot;
use crate::atp::object::ObjectGraph;
let files: [(&str, &[u8]); 3] = [
("a.txt", b"alpha"),
("b.txt", b"bravo"),
("nested/dup", b"alpha"),
];
let digests: Vec<EntryDigest> = files.iter().map(|(p, b)| digest_of(p, b)).collect();
let streamed = flat_merkle_root_from_digests(&digests);
let mut graph = ObjectGraph::new();
let mut sorted: Vec<&(&str, &[u8])> = files.iter().collect();
sorted.sort_by(|a, b| a.0.cmp(b.0));
let mut edges = Vec::new();
for (rel, bytes) in sorted {
let obj = Object::file(bytes.to_vec());
let id = obj.id.clone();
if !graph.contains_object(&id) {
let _ = graph.add_object(obj);
}
edges.push(ObjectEdge::new(id, (*rel).to_string()));
}
let root = Object::directory(edges);
let _ = graph.add_root(root);
let owned = MerkleRoot::from_graph(&graph).to_hex();
assert_eq!(
streamed, owned,
"streaming digest root must equal canonical owned-graph root"
);
}
#[test]
fn duplicate_content_collapses_but_path_is_committed() {
let dup = vec![digest_of("x", b"same"), digest_of("y", b"same")];
let moved = vec![digest_of("x", b"same"), digest_of("z", b"same")];
let root_dup = flat_merkle_root_from_digests(&dup);
assert_eq!(root_dup.len(), 64);
assert_ne!(
root_dup,
flat_merkle_root_from_digests(&moved),
"renaming a committed path must change the root"
);
}
#[test]
fn empty_transfer_has_a_stable_root() {
let r = flat_merkle_root_from_digests(&[]);
assert_eq!(r.len(), 64);
assert_eq!(r, flat_merkle_root_from_digests(&[]));
}
#[test]
fn flat_merkle_root_from_slices_matches_digest_path() {
let root_from_slices =
flat_merkle_root_from_slices([("a", b"alpha".as_slice()), ("b", b"bravo".as_slice())]);
let digests = vec![digest_of("a", b"alpha"), digest_of("b", b"bravo")];
assert_eq!(root_from_slices, flat_merkle_root_from_digests(&digests));
}
#[test]
fn staged_receive_finalizes_incremental_digest() {
let mut recv = StagedEntryReceive::new(PathBuf::from("stage/0"));
recv.mark_created();
recv.update_with_chunk(b"hel");
recv.update_with_chunk(b"lo");
let (digest, path, created) = recv.finalize("greeting.txt".to_string());
assert!(created);
assert_eq!(path, PathBuf::from("stage/0"));
assert_eq!(digest.rel_path, "greeting.txt");
assert_eq!(digest.size, 5);
let expected_sha: [u8; 32] = Sha256::digest(b"hello").into();
assert_eq!(digest.content_sha256, expected_sha);
assert_eq!(
digest.content_id,
ObjectId::content(ContentId::from_bytes(b"hello"))
);
}
#[test]
fn streaming_error_preserves_message() {
let e = StreamingError::new("path/to/x: No such file or directory (os error 2)");
assert_eq!(
e.message(),
"path/to/x: No such file or directory (os error 2)"
);
assert_eq!(
e.clone().into_message(),
"path/to/x: No such file or directory (os error 2)"
);
assert_eq!(
format!("{e}"),
"path/to/x: No such file or directory (os error 2)"
);
}
}