1use std::fs;
20use std::io::{self, Read};
21use std::path::{Path, PathBuf};
22
23use crate::chunker::{ChunkIterator, ChunkReader, FastCdc};
24use crate::hash::Hash;
25use crate::ignore::{self, IgnoreList};
26use crate::index::{self, Index};
27use crate::object::{ChunkedBlob, EntryMode, Object, Tree, TreeEntry};
28use crate::serialize;
29use crate::store::{ObjectSink, ObjectStore};
30
31pub const CHUNK_THRESHOLD: u64 = 1024 * 1024;
33
34pub const MAX_FILE_BYTES: u64 = 1024 * 1024 * 1024;
36
37#[derive(Debug, thiserror::Error)]
39pub enum WorktreeError {
40 #[error("symlink target '{0}' is invalid (absolute or contains '..')")]
42 InvalidSymlinkTarget(String),
43 #[error("file '{0}' exceeds the {MAX_FILE_BYTES} byte limit")]
45 FileTooLarge(PathBuf),
46 #[error("path component is not valid UTF-8")]
48 InvalidUtf8,
49 #[error(transparent)]
51 Io(#[from] io::Error),
52 #[error(transparent)]
54 Object(#[from] crate::object::MkitError),
55 #[error(transparent)]
57 Store(#[from] crate::store::StoreError),
58}
59
60pub type WorktreeResult<T> = Result<T, WorktreeError>;
62
63mod blob;
64pub use blob::{LoadedBlob, read_blob};
65
66#[must_use]
69pub fn validate_symlink_target(target: &str) -> bool {
70 if target.is_empty() {
71 return false;
72 }
73 if target.starts_with('/') {
74 return false;
75 }
76 for part in target.split('/') {
77 if part == ".." {
78 return false;
79 }
80 }
81 true
82}
83
84#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct StatObservation {
93 pub path: String,
95 pub object_hash: Hash,
97 pub mtime_ns: u64,
100 pub size: u64,
101 pub ino: u64,
102 pub ctime_ns: u64,
103}
104
105pub fn build_tree<S: ObjectSink + ?Sized>(sink: &S, dir: &Path) -> WorktreeResult<Hash> {
118 build_tree_filtered(sink, dir, None)
119}
120
121pub fn build_tree_filtered<S: ObjectSink + ?Sized>(
130 sink: &S,
131 dir: &Path,
132 index: Option<&Index>,
133) -> WorktreeResult<Hash> {
134 build_tree_filtered_observed(sink, dir, index, &mut Vec::new())
135}
136
137pub fn build_tree_filtered_observed<S: ObjectSink + ?Sized>(
145 sink: &S,
146 dir: &Path,
147 index: Option<&Index>,
148 observations: &mut Vec<StatObservation>,
149) -> WorktreeResult<Hash> {
150 let ignores = ignore::load(dir).map_err(|e| match e {
151 crate::ignore::IgnoreError::Io(io) => WorktreeError::Io(io),
152 crate::ignore::IgnoreError::FileTooLarge => {
153 WorktreeError::Io(io::Error::other("ignore file exceeds 1 MiB"))
154 }
155 })?;
156 let loaded;
159 let index = if let Some(i) = index {
160 i
161 } else {
162 loaded = index::read_index(&crate::layout::RepoLayout::single(dir)).unwrap_or_default();
167 &loaded
168 };
169 let by_path: std::collections::HashMap<&str, &crate::index::IndexEntry> =
172 index.entries.iter().map(|e| (e.path.as_str(), e)).collect();
173 build_tree_inner(
174 sink,
175 dir,
176 "",
177 &ignores,
178 index,
179 &by_path,
180 false,
181 observations,
182 )
183}
184
185#[allow(clippy::too_many_arguments)]
191fn build_tree_inner<S: ObjectSink + ?Sized>(
192 sink: &S,
193 dir: &Path,
194 rel_dir: &str,
195 ignores: &IgnoreList,
196 index: &Index,
197 by_path: &std::collections::HashMap<&str, &crate::index::IndexEntry>,
198 parent_ignored: bool,
199 observations: &mut Vec<StatObservation>,
200) -> WorktreeResult<Hash> {
201 let mut entries: Vec<TreeEntry> = Vec::new();
202
203 for entry in fs::read_dir(dir)? {
204 let entry = entry?;
205 let file_name = entry.file_name();
206 let name_str = file_name
207 .to_str()
208 .ok_or(WorktreeError::InvalidUtf8)?
209 .to_string();
210 let meta = entry.path().symlink_metadata()?;
212 let is_dir = meta.is_dir();
213 let rel_path = if rel_dir.is_empty() {
214 name_str.clone()
215 } else {
216 format!("{rel_dir}/{name_str}")
217 };
218 let entry_ignored = parent_ignored || ignores.is_ignored(&rel_path, is_dir);
223 if entry_ignored && !index.tracks_path_or_descendant(&rel_path) {
224 continue;
225 }
226
227 let name_bytes = name_str.as_bytes();
228 if !TreeEntry::validate_name(name_bytes) {
229 return Err(WorktreeError::Io(io::Error::new(
230 io::ErrorKind::InvalidInput,
231 format!("invalid tree entry name: {name_str:?}"),
232 )));
233 }
234
235 if meta.file_type().is_file() {
236 let indexed = by_path.get(rel_path.as_str()).copied();
242 let cached = indexed.filter(|e| stat_matches(e, &meta));
243 let (object_hash, mode) = if let Some(e) = cached {
244 (e.object_hash, entry_mode_from_file_metadata(&meta))
245 } else {
246 let (h, opened_meta) = hash_file_with_metadata(sink, &entry.path())?;
247 if let Some(e) = indexed
252 && e.object_hash == h
253 {
254 let (mtime_ns, size, ino, ctime_ns) = stat_cache_fields(&opened_meta);
255 observations.push(StatObservation {
256 path: rel_path.clone(),
257 object_hash: h,
258 mtime_ns,
259 size,
260 ino,
261 ctime_ns,
262 });
263 }
264 (h, entry_mode_from_file_metadata(&opened_meta))
265 };
266 entries.push(TreeEntry {
267 name: name_str.into_bytes(),
268 mode,
269 object_hash,
270 });
271 } else if meta.file_type().is_dir() {
272 if index.has_tracked_file_at(&rel_path) {
278 continue;
279 }
280 let h = build_tree_inner(
281 sink,
282 &entry.path(),
283 &rel_path,
284 ignores,
285 index,
286 by_path,
287 entry_ignored,
288 observations,
289 )?;
290 entries.push(TreeEntry {
291 name: name_str.into_bytes(),
292 mode: EntryMode::Tree,
293 object_hash: h,
294 });
295 } else if meta.file_type().is_symlink() {
296 let target = fs::read_link(entry.path())?;
297 let target_str = target
298 .to_str()
299 .ok_or(WorktreeError::InvalidUtf8)?
300 .to_string();
301 if !validate_symlink_target(&target_str) {
302 return Err(WorktreeError::InvalidSymlinkTarget(target_str));
303 }
304 let target_bytes = target_str.as_bytes();
305 let prologue = serialize::blob_prologue(target_bytes.len())?;
306 let h = sink.put_parts(&[&prologue, target_bytes])?;
307 entries.push(TreeEntry {
308 name: name_str.into_bytes(),
309 mode: EntryMode::Symlink,
310 object_hash: h,
311 });
312 } else {
313 }
315 }
316
317 entries.sort_by(|a, b| a.name.cmp(&b.name));
318 let tree = Object::Tree(Tree { entries });
319 let bytes = serialize::serialize(&tree)?;
320 Ok(sink.put(&bytes)?)
321}
322
323pub fn build_tree_from_index(
344 store: &ObjectStore,
345 index: &crate::index::Index,
346) -> WorktreeResult<Hash> {
347 build_tree_from_index_with(store, store, index, true)
350}
351
352#[allow(clippy::items_after_statements, clippy::too_many_lines)]
369pub fn build_tree_from_index_with<S: ObjectSink + ?Sized>(
370 store: &ObjectStore,
371 sink: &S,
372 index: &crate::index::Index,
373 verify: bool,
374) -> WorktreeResult<Hash> {
375 use crate::index::EntryStatus;
376
377 #[derive(Default)]
380 struct Node {
381 children: std::collections::BTreeMap<String, Node>,
383 leaves: std::collections::BTreeMap<String, (EntryMode, Hash)>,
385 }
386
387 let mut root = Node::default();
388 let mut seen_paths = std::collections::HashSet::with_capacity(index.entries.len());
389
390 for entry in &index.entries {
391 if !seen_paths.insert(entry.path.as_str()) {
392 return Err(WorktreeError::Io(io::Error::other(format!(
393 "duplicate index path: '{}'",
394 entry.path
395 ))));
396 }
397 if entry.status == EntryStatus::Removed {
398 continue;
399 }
400 let mode = match entry.status {
401 EntryStatus::Blob => EntryMode::Blob,
402 EntryStatus::Executable => EntryMode::Executable,
403 EntryStatus::Symlink => EntryMode::Symlink,
404 EntryStatus::Tree => {
405 return Err(WorktreeError::Io(io::Error::other(
409 "index entry uses reserved Tree status (subtree staging not implemented)",
410 )));
411 }
412 EntryStatus::Removed => unreachable!("filtered above"),
413 };
414 let object_type = if verify {
428 store.verify_object_type(&entry.object_hash)?
429 } else {
430 store.object_type(&entry.object_hash)?
431 };
432 match object_type {
433 crate::object::ObjectType::Blob => {}
434 crate::object::ObjectType::ChunkedBlob if mode != EntryMode::Symlink => {}
435 other => {
436 return Err(WorktreeError::Io(io::Error::other(format!(
437 "index entry '{}' points to a non-blob object (got {})",
438 entry.path,
439 other.name()
440 ))));
441 }
442 }
443
444 let segments: Vec<&str> = entry.path.split('/').collect();
446 let Some((leaf, dirs)) = segments.split_last() else {
447 return Err(WorktreeError::Io(io::Error::other("empty index path")));
448 };
449 if leaf.is_empty() {
450 return Err(WorktreeError::Io(io::Error::other(
451 "trailing slash in index path",
452 )));
453 }
454
455 let mut node = &mut root;
456 let mut walked = String::new();
457 for seg in dirs {
458 if seg.is_empty() {
459 return Err(WorktreeError::Io(io::Error::other(
460 "empty path segment in index",
461 )));
462 }
463 if node.leaves.contains_key(*seg) {
470 let conflicting = if walked.is_empty() {
471 (*seg).to_string()
472 } else {
473 format!("{walked}/{seg}")
474 };
475 return Err(WorktreeError::Io(io::Error::other(format!(
476 "index path conflict: '{conflicting}' is staged as both a file and a directory"
477 ))));
478 }
479 walked = if walked.is_empty() {
480 (*seg).to_string()
481 } else {
482 format!("{walked}/{seg}")
483 };
484 node = node.children.entry((*seg).to_string()).or_default();
485 }
486 if node.children.contains_key(*leaf) {
490 let conflicting = if walked.is_empty() {
491 (*leaf).to_string()
492 } else {
493 format!("{walked}/{leaf}")
494 };
495 return Err(WorktreeError::Io(io::Error::other(format!(
496 "index path conflict: '{conflicting}' is staged as both a file and a directory"
497 ))));
498 }
499 if node
500 .leaves
501 .insert((*leaf).to_string(), (mode, entry.object_hash))
502 .is_some()
503 {
504 let duplicate = if walked.is_empty() {
505 (*leaf).to_string()
506 } else {
507 format!("{walked}/{leaf}")
508 };
509 return Err(WorktreeError::Io(io::Error::other(format!(
510 "duplicate index path: '{duplicate}'"
511 ))));
512 }
513 }
514
515 fn write_node<S: ObjectSink + ?Sized>(sink: &S, node: &Node) -> WorktreeResult<Hash> {
516 let mut entries: Vec<TreeEntry> = Vec::new();
517
518 for (name, child) in &node.children {
520 let h = write_node(sink, child)?;
521 let bytes = name.as_bytes().to_vec();
522 if !crate::object::TreeEntry::validate_name(&bytes) {
523 return Err(WorktreeError::Io(io::Error::other(format!(
524 "invalid tree entry name: {name:?}"
525 ))));
526 }
527 entries.push(TreeEntry {
528 name: bytes,
529 mode: EntryMode::Tree,
530 object_hash: h,
531 });
532 }
533
534 for (name, (mode, hash)) in &node.leaves {
536 let bytes = name.as_bytes().to_vec();
537 if !crate::object::TreeEntry::validate_name(&bytes) {
538 return Err(WorktreeError::Io(io::Error::other(format!(
539 "invalid tree entry name: {name:?}"
540 ))));
541 }
542 entries.push(TreeEntry {
543 name: bytes,
544 mode: *mode,
545 object_hash: *hash,
546 });
547 }
548
549 entries.sort_by(|a, b| a.name.cmp(&b.name));
551 let tree = Object::Tree(Tree { entries });
552 let bytes = serialize::serialize(&tree)?;
553 Ok(sink.put(&bytes)?)
554 }
555
556 write_node(sink, &root)
557}
558
559pub fn hash_file<S: ObjectSink + ?Sized>(sink: &S, path: &Path) -> WorktreeResult<Hash> {
572 hash_file_with_metadata(sink, path).map(|(hash, _)| hash)
573}
574
575pub fn read_regular_file_bounded(path: &Path) -> WorktreeResult<(fs::Metadata, Vec<u8>)> {
579 let mut file = open_regular_file(path)?;
580 let meta = file.metadata()?;
581 if !meta.file_type().is_file() {
582 return Err(WorktreeError::Io(io::Error::new(
583 io::ErrorKind::InvalidInput,
584 "path is not a regular file",
585 )));
586 }
587 if meta.len() > MAX_FILE_BYTES {
588 return Err(WorktreeError::FileTooLarge(path.to_path_buf()));
589 }
590 let initial_capacity = usize::try_from(meta.len().min(CHUNK_THRESHOLD))
591 .map_err(|_| WorktreeError::FileTooLarge(path.to_path_buf()))?;
592 let mut data = Vec::with_capacity(initial_capacity);
593 file.by_ref()
594 .take(MAX_FILE_BYTES + 1)
595 .read_to_end(&mut data)?;
596 if u64::try_from(data.len()).unwrap_or(u64::MAX) > MAX_FILE_BYTES {
597 return Err(WorktreeError::FileTooLarge(path.to_path_buf()));
598 }
599 Ok((meta, data))
600}
601
602pub fn hash_file_with_metadata<S: ObjectSink + ?Sized>(
617 sink: &S,
618 path: &Path,
619) -> WorktreeResult<(Hash, fs::Metadata)> {
620 let mut file = open_regular_file(path)?;
621 let meta = file.metadata()?;
622 if !meta.file_type().is_file() {
623 return Err(WorktreeError::Io(io::Error::new(
624 io::ErrorKind::InvalidInput,
625 "path is not a regular file",
626 )));
627 }
628 if meta.len() > MAX_FILE_BYTES {
629 return Err(WorktreeError::FileTooLarge(path.to_path_buf()));
630 }
631
632 if meta.len() <= CHUNK_THRESHOLD {
633 let initial_capacity = usize::try_from(meta.len())
634 .map_err(|_| WorktreeError::FileTooLarge(path.to_path_buf()))?;
635 let mut data = Vec::with_capacity(initial_capacity);
636 file.by_ref()
637 .take(MAX_FILE_BYTES + 1)
638 .read_to_end(&mut data)?;
639 if u64::try_from(data.len()).unwrap_or(u64::MAX) > MAX_FILE_BYTES {
640 return Err(WorktreeError::FileTooLarge(path.to_path_buf()));
641 }
642 let hash = store_file_object(sink, &data)?;
643 return Ok((hash, meta));
644 }
645
646 let hash = store_large_file_streaming(sink, file.take(MAX_FILE_BYTES + 1), path)?;
647 Ok((hash, meta))
648}
649
650fn store_large_file_streaming<S: ObjectSink + ?Sized, R: Read>(
663 sink: &S,
664 reader: R,
665 path: &Path,
666) -> WorktreeResult<Hash> {
667 let mut chunker = ChunkReader::new(FastCdc::v1(), reader);
668 let mut chunks = Vec::new();
669 let mut total_size: u64 = 0;
670 while let Some(chunk) = chunker.next_chunk()? {
671 total_size = total_size
672 .checked_add(chunk.len() as u64)
673 .filter(|&t| t <= MAX_FILE_BYTES)
674 .ok_or_else(|| WorktreeError::FileTooLarge(path.to_path_buf()))?;
675 let prologue = serialize::blob_prologue(chunk.len())?;
676 chunks.push(sink.put_parts(&[&prologue, &chunk])?);
677 }
678
679 let manifest = Object::ChunkedBlob(ChunkedBlob {
680 total_size,
681 chunk_size: 0, chunks,
683 });
684 let manifest_bytes = serialize::serialize(&manifest)?;
685 Ok(sink.put(&manifest_bytes)?)
686}
687
688pub fn store_file_object<S: ObjectSink + ?Sized>(sink: &S, data: &[u8]) -> WorktreeResult<Hash> {
703 if u64::try_from(data.len()).unwrap_or(u64::MAX) <= CHUNK_THRESHOLD {
704 let prologue = serialize::blob_prologue(data.len())?;
708 return Ok(sink.put_parts(&[&prologue, data])?);
709 }
710
711 let total_size = data.len() as u64;
717 let chunks: Vec<Hash> = ChunkIterator::new(FastCdc::v1(), data)
718 .map(|b| {
719 let chunk = &data[b.offset..b.offset + b.length];
720 let prologue = serialize::blob_prologue(chunk.len())?;
721 Ok::<_, WorktreeError>(sink.put_parts(&[&prologue, chunk])?)
722 })
723 .collect::<Result<_, _>>()?;
724
725 let manifest = Object::ChunkedBlob(ChunkedBlob {
726 total_size,
727 chunk_size: 0, chunks,
729 });
730 let manifest_bytes = serialize::serialize(&manifest)?;
731 Ok(sink.put(&manifest_bytes)?)
732}
733
734pub fn chunked_blob_from_bytes(data: &[u8]) -> WorktreeResult<ChunkedBlob> {
747 let chunks: Vec<Hash> = ChunkIterator::new(FastCdc::v1(), data)
748 .map(|b| {
749 let chunk = &data[b.offset..b.offset + b.length];
750 let prologue = serialize::blob_prologue(chunk.len())?;
754 let mut hasher = crate::hash::Hasher::new();
755 hasher.update(&prologue);
756 hasher.update(chunk);
757 Ok::<_, WorktreeError>(hasher.finalize())
758 })
759 .collect::<Result<_, _>>()?;
760 Ok(ChunkedBlob {
761 total_size: data.len() as u64,
762 chunk_size: 0,
763 chunks,
764 })
765}
766
767pub fn hash_file_object(data: &[u8]) -> WorktreeResult<Hash> {
776 if u64::try_from(data.len()).unwrap_or(u64::MAX) <= CHUNK_THRESHOLD {
777 let prologue = serialize::blob_prologue(data.len())?;
778 let mut hasher = crate::hash::Hasher::new();
779 hasher.update(&prologue);
780 hasher.update(data);
781 return Ok(hasher.finalize());
782 }
783 Ok(crate::merkle::compute_chunked_id(&chunked_blob_from_bytes(
787 data,
788 )?))
789}
790
791#[must_use]
795pub fn mtime_nanos(meta: &fs::Metadata) -> u64 {
796 meta.modified()
797 .ok()
798 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
799 .map_or(0, |d| u64::try_from(d.as_nanos()).unwrap_or(u64::MAX))
800}
801
802#[must_use]
808pub fn stat_cache_fields(meta: &fs::Metadata) -> (u64, u64, u64, u64) {
809 #[cfg(unix)]
810 let (ino, ctime_ns) = {
811 use std::os::unix::fs::MetadataExt;
812 let ctime_ns = u64::try_from(meta.ctime())
813 .ok()
814 .and_then(|s| s.checked_mul(1_000_000_000))
815 .and_then(|ns| ns.checked_add(u64::try_from(meta.ctime_nsec()).unwrap_or(0)))
816 .unwrap_or(0);
817 (meta.ino(), ctime_ns)
818 };
819 #[cfg(not(unix))]
820 let (ino, ctime_ns) = (0u64, 0u64);
821 (mtime_nanos(meta), meta.len(), ino, ctime_ns)
822}
823
824#[must_use]
833pub fn stat_matches(entry: &crate::index::IndexEntry, meta: &fs::Metadata) -> bool {
834 use crate::index::EntryStatus;
835 if entry.mtime_ns == 0 || !meta.is_file() {
836 return false;
837 }
838 let (mtime_ns, size, ino, ctime_ns) = stat_cache_fields(meta);
839 if size != entry.size || mtime_ns != entry.mtime_ns {
840 return false;
841 }
842 if entry.ino != 0 && ino != 0 && ino != entry.ino {
845 return false;
846 }
847 if entry.ctime_ns != 0 && ctime_ns != 0 && ctime_ns != entry.ctime_ns {
848 return false;
849 }
850 match entry.status {
851 #[cfg(not(unix))]
856 EntryStatus::Blob | EntryStatus::Executable => true,
857 #[cfg(unix)]
858 EntryStatus::Blob => entry_mode_from_file_metadata(meta) == EntryMode::Blob,
859 #[cfg(unix)]
860 EntryStatus::Executable => entry_mode_from_file_metadata(meta) == EntryMode::Executable,
861 EntryStatus::Symlink | EntryStatus::Removed | EntryStatus::Tree => false,
862 }
863}
864
865#[cfg(unix)]
866fn open_regular_file(path: &Path) -> io::Result<fs::File> {
867 use std::os::unix::fs::OpenOptionsExt;
868
869 fs::OpenOptions::new()
870 .read(true)
871 .custom_flags(libc::O_NOFOLLOW)
872 .open(path)
873}
874
875#[cfg(not(unix))]
876fn open_regular_file(path: &Path) -> io::Result<fs::File> {
877 let meta = path.symlink_metadata()?;
881 if !meta.file_type().is_file() {
882 return Err(io::Error::new(
883 io::ErrorKind::InvalidInput,
884 "path is not a regular file",
885 ));
886 }
887 fs::File::open(path)
888}
889
890#[cfg(unix)]
891fn entry_mode_from_file_metadata(meta: &fs::Metadata) -> EntryMode {
892 use std::os::unix::fs::PermissionsExt;
893
894 if meta.permissions().mode() & 0o111 != 0 {
895 EntryMode::Executable
896 } else {
897 EntryMode::Blob
898 }
899}
900
901#[cfg(not(unix))]
902fn entry_mode_from_file_metadata(_meta: &fs::Metadata) -> EntryMode {
903 EntryMode::Blob
904}
905
906#[cfg(test)]
907mod tests {
908 use super::*;
909 use crate::object::ObjectType;
910 use tempfile::TempDir;
911
912 fn fresh_store() -> (TempDir, ObjectStore) {
913 let dir = TempDir::new().unwrap();
914 let store = ObjectStore::init(&crate::layout::RepoLayout::single(dir.path())).unwrap();
915 (dir, store)
916 }
917
918 #[test]
919 fn validate_symlink_targets() {
920 assert!(validate_symlink_target("hello"));
921 assert!(validate_symlink_target("sub/dir/file"));
922 assert!(!validate_symlink_target(""));
923 assert!(!validate_symlink_target("/etc/passwd"));
924 assert!(!validate_symlink_target("../escape"));
925 assert!(!validate_symlink_target("a/../b"));
926 }
927
928 #[test]
929 fn build_tree_from_empty_dir() {
930 let (_sd, store) = fresh_store();
931 let work = TempDir::new().unwrap();
932 let h = build_tree(&store, work.path()).unwrap();
933 let obj = store.read_object(&h).unwrap();
934 match obj {
935 Object::Tree(t) => assert_eq!(t.entries.len(), 0),
936 other => panic!("expected tree, got {other:?}"),
937 }
938 }
939
940 #[test]
941 fn build_tree_with_single_file() {
942 let (_sd, store) = fresh_store();
943 let work = TempDir::new().unwrap();
944 fs::write(work.path().join("hello.txt"), b"hello world").unwrap();
945 let h = build_tree(&store, work.path()).unwrap();
946 let obj = store.read_object(&h).unwrap();
947 let Object::Tree(t) = obj else {
948 panic!("expected tree");
949 };
950 assert_eq!(t.entries.len(), 1);
951 assert_eq!(t.entries[0].name.as_slice(), b"hello.txt");
952 assert_eq!(t.entries[0].mode, EntryMode::Blob);
953 let blob_obj = store.read_object(&t.entries[0].object_hash).unwrap();
954 let Object::Blob(b) = blob_obj else {
955 panic!("expected blob");
956 };
957 assert_eq!(b.data, b"hello world");
958 }
959
960 #[cfg(unix)]
961 #[test]
962 fn build_tree_marks_executable_regular_files() {
963 use std::os::unix::fs::PermissionsExt;
964
965 let (_sd, store) = fresh_store();
966 let work = TempDir::new().unwrap();
967 let script = work.path().join("run.sh");
968 fs::write(&script, b"#!/bin/sh\n").unwrap();
969 let mut perms = fs::metadata(&script).unwrap().permissions();
970 perms.set_mode(perms.mode() | 0o111);
971 fs::set_permissions(&script, perms).unwrap();
972
973 let h = build_tree(&store, work.path()).unwrap();
974 let Object::Tree(t) = store.read_object(&h).unwrap() else {
975 panic!("expected tree");
976 };
977 assert_eq!(t.entries[0].name.as_slice(), b"run.sh");
978 assert_eq!(t.entries[0].mode, EntryMode::Executable);
979 }
980
981 #[cfg(unix)]
982 #[test]
983 fn build_tree_rejects_invalid_entry_name_before_writing_tree() {
984 let (_sd, store) = fresh_store();
985 let work = TempDir::new().unwrap();
986 fs::write(work.path().join("bad."), b"bad name").unwrap();
987
988 let err = build_tree(&store, work.path()).unwrap_err();
989 assert!(matches!(err, WorktreeError::Io(_)));
990 }
991
992 #[cfg(unix)]
993 #[test]
994 fn hash_file_rejects_final_component_symlink() {
995 use std::os::unix::fs::symlink;
996
997 let (_sd, store) = fresh_store();
998 let work = TempDir::new().unwrap();
999 fs::write(work.path().join("target.txt"), b"target").unwrap();
1000 symlink("target.txt", work.path().join("link.txt")).unwrap();
1001
1002 let err = hash_file(&store, &work.path().join("link.txt")).unwrap_err();
1003 assert!(matches!(err, WorktreeError::Io(_)));
1004 }
1005
1006 #[test]
1007 fn build_tree_with_nested_directories() {
1008 let (_sd, store) = fresh_store();
1009 let work = TempDir::new().unwrap();
1010 fs::write(work.path().join("a.txt"), b"file a").unwrap();
1011 fs::create_dir(work.path().join("subdir")).unwrap();
1012 fs::write(work.path().join("subdir/b.txt"), b"file b").unwrap();
1013 let h = build_tree(&store, work.path()).unwrap();
1014 let obj = store.read_object(&h).unwrap();
1015 let Object::Tree(t) = obj else {
1016 panic!("expected tree");
1017 };
1018 assert_eq!(t.entries.len(), 2);
1019 assert_eq!(t.entries[0].name.as_slice(), b"a.txt");
1021 assert_eq!(t.entries[1].name.as_slice(), b"subdir");
1022 assert_eq!(t.entries[1].mode, EntryMode::Tree);
1023 let sub = store.read_object(&t.entries[1].object_hash).unwrap();
1024 let Object::Tree(st) = sub else {
1025 panic!("expected tree");
1026 };
1027 assert_eq!(st.entries.len(), 1);
1028 assert_eq!(st.entries[0].name.as_slice(), b"b.txt");
1029 }
1030
1031 #[test]
1032 fn build_tree_skips_mkit_directory() {
1033 let (_sd, store) = fresh_store();
1034 let work = TempDir::new().unwrap();
1035 fs::create_dir(work.path().join(".mkit")).unwrap();
1036 fs::write(work.path().join(".mkit/should_skip"), b"").unwrap();
1037 fs::write(work.path().join("keep.txt"), b"kept").unwrap();
1038 let h = build_tree(&store, work.path()).unwrap();
1039 let obj = store.read_object(&h).unwrap();
1040 let Object::Tree(t) = obj else {
1041 panic!("expected tree");
1042 };
1043 assert_eq!(t.entries.len(), 1);
1044 assert_eq!(t.entries[0].name.as_slice(), b"keep.txt");
1045 }
1046
1047 #[test]
1048 fn build_tree_is_deterministic() {
1049 let (_sd, store) = fresh_store();
1050 let work = TempDir::new().unwrap();
1051 fs::write(work.path().join("z.txt"), b"z").unwrap();
1052 fs::write(work.path().join("a.txt"), b"a").unwrap();
1053 let h1 = build_tree(&store, work.path()).unwrap();
1054 let h2 = build_tree(&store, work.path()).unwrap();
1055 assert_eq!(h1, h2);
1056 }
1057
1058 #[test]
1059 fn build_tree_respects_mkitignore() {
1060 let (_sd, store) = fresh_store();
1061 let work = TempDir::new().unwrap();
1062 fs::write(work.path().join(".mkitignore"), b"*.log\n").unwrap();
1063 fs::write(work.path().join("keep.txt"), b"kept").unwrap();
1064 fs::write(work.path().join("debug.log"), b"ignored").unwrap();
1065 let h = build_tree(&store, work.path()).unwrap();
1066 let obj = store.read_object(&h).unwrap();
1067 let Object::Tree(t) = obj else {
1068 panic!("expected tree");
1069 };
1070 assert_eq!(t.entries.len(), 2);
1072 assert_eq!(t.entries[0].name.as_slice(), b".mkitignore");
1073 assert_eq!(t.entries[1].name.as_slice(), b"keep.txt");
1074 }
1075
1076 #[cfg(unix)]
1077 #[test]
1078 fn rejects_invalid_symlink_targets() {
1079 use std::os::unix::fs::symlink;
1080 let (_sd, store) = fresh_store();
1081 let work = TempDir::new().unwrap();
1082 symlink("/etc/passwd", work.path().join("bad-link")).unwrap();
1083 let err = build_tree(&store, work.path()).unwrap_err();
1084 assert!(matches!(err, WorktreeError::InvalidSymlinkTarget(_)));
1085 }
1086
1087 #[cfg(unix)]
1088 #[test]
1089 fn rejects_dotdot_symlink_targets() {
1090 use std::os::unix::fs::symlink;
1091 let (_sd, store) = fresh_store();
1092 let work = TempDir::new().unwrap();
1093 symlink("../../etc/passwd", work.path().join("bad-link")).unwrap();
1094 let err = build_tree(&store, work.path()).unwrap_err();
1095 assert!(matches!(err, WorktreeError::InvalidSymlinkTarget(_)));
1096 }
1097
1098 #[test]
1099 fn small_file_stays_as_regular_blob() {
1100 let (_sd, store) = fresh_store();
1101 let work = TempDir::new().unwrap();
1102 fs::write(work.path().join("small.txt"), b"hello world").unwrap();
1103 let h = build_tree(&store, work.path()).unwrap();
1104 let obj = store.read_object(&h).unwrap();
1105 let Object::Tree(t) = obj else {
1106 panic!("expected tree");
1107 };
1108 let entry = store.read_object(&t.entries[0].object_hash).unwrap();
1109 assert_eq!(entry.object_type(), ObjectType::Blob);
1110 }
1111
1112 #[test]
1113 fn large_file_becomes_chunked_blob() {
1114 let (_sd, store) = fresh_store();
1119 let work = TempDir::new().unwrap();
1120 let n = usize::try_from(CHUNK_THRESHOLD).unwrap() + 256 * 1024;
1121 let mut big = Vec::with_capacity(n);
1122 let mut state: u64 = 0x00C0_FFEE;
1123 for _ in 0..n {
1124 state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
1126 let mut z = state;
1127 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
1128 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
1129 z ^= z >> 31;
1130 big.push((z & 0xFF) as u8);
1131 }
1132 fs::write(work.path().join("big.bin"), &big).unwrap();
1133
1134 let tree_hash = build_tree(&store, work.path()).unwrap();
1135 let Object::Tree(t) = store.read_object(&tree_hash).unwrap() else {
1136 panic!("expected tree");
1137 };
1138 assert_eq!(t.entries.len(), 1);
1139
1140 let entry_hash = t.entries[0].object_hash;
1141 let entry = store.read_object(&entry_hash).unwrap();
1142 let Object::ChunkedBlob(manifest) = entry else {
1143 panic!("expected chunked_blob, got {entry:?}");
1144 };
1145
1146 assert_eq!(manifest.total_size, n as u64);
1147 assert_eq!(manifest.chunk_size, 0, "0 = content-defined (FastCDC)");
1148 assert!(!manifest.chunks.is_empty());
1149 let mut reassembled: Vec<u8> = Vec::with_capacity(n);
1152 for h in &manifest.chunks {
1153 let Object::Blob(b) = store.read_object(h).unwrap() else {
1154 panic!("chunk did not resolve to a Blob");
1155 };
1156 reassembled.extend_from_slice(&b.data);
1157 }
1158 assert_eq!(reassembled, big, "chunks must round-trip the source");
1159 }
1160
1161 #[test]
1162 fn hash_file_with_metadata_streaming_matches_store_file_object_in_memory() {
1163 let work = TempDir::new().unwrap();
1167 let n = usize::try_from(CHUNK_THRESHOLD).unwrap() + 700 * 1024;
1168 let mut big = Vec::with_capacity(n);
1169 let mut state: u64 = 0xFACE_FEED;
1170 for _ in 0..n {
1171 state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
1172 let mut z = state;
1173 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
1174 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
1175 z ^= z >> 31;
1176 big.push((z & 0xFF) as u8);
1177 }
1178 let path = work.path().join("big.bin");
1179 fs::write(&path, &big).unwrap();
1180
1181 let (_sd1, store1) = fresh_store();
1182 let (streamed_hash, meta) = hash_file_with_metadata(&store1, &path).unwrap();
1183 assert_eq!(meta.len(), n as u64);
1184
1185 let (_sd2, store2) = fresh_store();
1186 let in_memory_hash = store_file_object(&store2, &big).unwrap();
1187
1188 assert_eq!(
1189 streamed_hash, in_memory_hash,
1190 "streaming a file from disk must produce the same content-address \
1191 as chunking the fully-buffered bytes"
1192 );
1193
1194 let Object::ChunkedBlob(manifest) = store1.read_object(&streamed_hash).unwrap() else {
1196 panic!("expected chunked_blob");
1197 };
1198 let mut reassembled = Vec::with_capacity(n);
1199 for h in &manifest.chunks {
1200 let Object::Blob(b) = store1.read_object(h).unwrap() else {
1201 panic!("chunk did not resolve to a Blob");
1202 };
1203 reassembled.extend_from_slice(&b.data);
1204 }
1205 assert_eq!(reassembled, big);
1206 }
1207
1208 use crate::index::{EntryStatus, Index, IndexEntry};
1211
1212 fn write_blob(store: &ObjectStore, bytes: &[u8]) -> Hash {
1213 let blob = Object::Blob(crate::object::Blob {
1214 data: bytes.to_vec(),
1215 });
1216 let body = serialize::serialize(&blob).unwrap();
1217 store.write(&body).unwrap()
1218 }
1219
1220 #[test]
1221 fn from_index_empty_returns_empty_tree() {
1222 let (_sd, store) = fresh_store();
1223 let idx = Index::new();
1224 let h = build_tree_from_index(&store, &idx).unwrap();
1225 let Object::Tree(t) = store.read_object(&h).unwrap() else {
1226 panic!("expected tree");
1227 };
1228 assert!(t.entries.is_empty());
1229 }
1230
1231 #[test]
1232 fn from_index_single_file_at_root() {
1233 let (_sd, store) = fresh_store();
1234 let blob_hash = write_blob(&store, b"hello world");
1235 let mut idx = Index::new();
1236 idx.entries.push(IndexEntry {
1237 path: "hello.txt".into(),
1238 status: EntryStatus::Blob,
1239 object_hash: blob_hash,
1240 mtime_ns: 0,
1241 size: 0,
1242 ino: 0,
1243 ctime_ns: 0,
1244 });
1245 let h = build_tree_from_index(&store, &idx).unwrap();
1246 let Object::Tree(t) = store.read_object(&h).unwrap() else {
1247 panic!();
1248 };
1249 assert_eq!(t.entries.len(), 1);
1250 assert_eq!(t.entries[0].name, b"hello.txt");
1251 assert_eq!(t.entries[0].mode, EntryMode::Blob);
1252 assert_eq!(t.entries[0].object_hash, blob_hash);
1253 }
1254
1255 #[test]
1256 fn from_index_nested_paths_build_subtrees() {
1257 let (_sd, store) = fresh_store();
1258 let a = write_blob(&store, b"file a");
1259 let b = write_blob(&store, b"file b");
1260 let mut idx = Index::new();
1261 idx.entries.push(IndexEntry {
1262 path: "a.txt".into(),
1263 status: EntryStatus::Blob,
1264 object_hash: a,
1265 mtime_ns: 0,
1266 size: 0,
1267 ino: 0,
1268 ctime_ns: 0,
1269 });
1270 idx.entries.push(IndexEntry {
1271 path: "subdir/b.txt".into(),
1272 status: EntryStatus::Blob,
1273 object_hash: b,
1274 mtime_ns: 0,
1275 size: 0,
1276 ino: 0,
1277 ctime_ns: 0,
1278 });
1279 let root_hash = build_tree_from_index(&store, &idx).unwrap();
1280 let Object::Tree(root) = store.read_object(&root_hash).unwrap() else {
1281 panic!();
1282 };
1283 assert_eq!(root.entries.len(), 2);
1284 assert_eq!(root.entries[0].name, b"a.txt");
1285 assert_eq!(root.entries[0].mode, EntryMode::Blob);
1286 assert_eq!(root.entries[1].name, b"subdir");
1287 assert_eq!(root.entries[1].mode, EntryMode::Tree);
1288
1289 let Object::Tree(sub) = store.read_object(&root.entries[1].object_hash).unwrap() else {
1290 panic!();
1291 };
1292 assert_eq!(sub.entries.len(), 1);
1293 assert_eq!(sub.entries[0].name, b"b.txt");
1294 assert_eq!(sub.entries[0].object_hash, b);
1295 }
1296
1297 #[test]
1298 fn from_index_removed_entries_are_skipped() {
1299 let (_sd, store) = fresh_store();
1300 let a = write_blob(&store, b"keep me");
1301 let mut idx = Index::new();
1302 idx.entries.push(IndexEntry {
1303 path: "keep.txt".into(),
1304 status: EntryStatus::Blob,
1305 object_hash: a,
1306 mtime_ns: 0,
1307 size: 0,
1308 ino: 0,
1309 ctime_ns: 0,
1310 });
1311 idx.entries.push(IndexEntry {
1312 path: "drop.txt".into(),
1313 status: EntryStatus::Removed,
1314 object_hash: [0; 32],
1315 mtime_ns: 0,
1316 size: 0,
1317 ino: 0,
1318 ctime_ns: 0,
1319 });
1320 let h = build_tree_from_index(&store, &idx).unwrap();
1321 let Object::Tree(t) = store.read_object(&h).unwrap() else {
1322 panic!();
1323 };
1324 assert_eq!(t.entries.len(), 1);
1325 assert_eq!(t.entries[0].name, b"keep.txt");
1326 }
1327
1328 #[test]
1329 fn from_index_executable_and_symlink_modes_pass_through() {
1330 let (_sd, store) = fresh_store();
1331 let exec = write_blob(&store, b"#!/bin/sh");
1332 let link = write_blob(&store, b"target.txt");
1333 let mut idx = Index::new();
1334 idx.entries.push(IndexEntry {
1335 path: "run.sh".into(),
1336 status: EntryStatus::Executable,
1337 object_hash: exec,
1338 mtime_ns: 0,
1339 size: 0,
1340 ino: 0,
1341 ctime_ns: 0,
1342 });
1343 idx.entries.push(IndexEntry {
1344 path: "link".into(),
1345 status: EntryStatus::Symlink,
1346 object_hash: link,
1347 mtime_ns: 0,
1348 size: 0,
1349 ino: 0,
1350 ctime_ns: 0,
1351 });
1352 let h = build_tree_from_index(&store, &idx).unwrap();
1353 let Object::Tree(t) = store.read_object(&h).unwrap() else {
1354 panic!();
1355 };
1356 let by_name: std::collections::HashMap<&[u8], &TreeEntry> =
1357 t.entries.iter().map(|e| (e.name.as_slice(), e)).collect();
1358 assert_eq!(by_name[&b"run.sh"[..]].mode, EntryMode::Executable);
1359 assert_eq!(by_name[&b"link"[..]].mode, EntryMode::Symlink);
1360 }
1361
1362 #[test]
1363 fn from_index_entries_are_sorted_by_name() {
1364 let (_sd, store) = fresh_store();
1365 let a = write_blob(&store, b"x");
1366 let mut idx = Index::new();
1367 idx.entries.push(IndexEntry {
1370 path: "z.txt".into(),
1371 status: EntryStatus::Blob,
1372 object_hash: a,
1373 mtime_ns: 0,
1374 size: 0,
1375 ino: 0,
1376 ctime_ns: 0,
1377 });
1378 idx.entries.push(IndexEntry {
1379 path: "a.txt".into(),
1380 status: EntryStatus::Blob,
1381 object_hash: a,
1382 mtime_ns: 0,
1383 size: 0,
1384 ino: 0,
1385 ctime_ns: 0,
1386 });
1387 idx.entries.push(IndexEntry {
1388 path: "m.txt".into(),
1389 status: EntryStatus::Blob,
1390 object_hash: a,
1391 mtime_ns: 0,
1392 size: 0,
1393 ino: 0,
1394 ctime_ns: 0,
1395 });
1396 let h = build_tree_from_index(&store, &idx).unwrap();
1397 let Object::Tree(t) = store.read_object(&h).unwrap() else {
1398 panic!();
1399 };
1400 let names: Vec<&[u8]> = t.entries.iter().map(|e| e.name.as_slice()).collect();
1401 assert_eq!(names, vec![&b"a.txt"[..], b"m.txt", b"z.txt"]);
1402 }
1403
1404 #[test]
1405 fn from_index_rejects_trailing_slash() {
1406 let (_sd, store) = fresh_store();
1407 let h = write_blob(&store, b"x");
1408 let mut idx = Index::new();
1409 idx.entries.push(IndexEntry {
1410 path: "dir/".into(),
1411 status: EntryStatus::Blob,
1412 object_hash: h,
1413 mtime_ns: 0,
1414 size: 0,
1415 ino: 0,
1416 ctime_ns: 0,
1417 });
1418 let err = build_tree_from_index(&store, &idx).unwrap_err();
1419 assert!(matches!(err, WorktreeError::Io(_)));
1420 }
1421
1422 #[test]
1423 fn from_index_rejects_empty_segment() {
1424 let (_sd, store) = fresh_store();
1425 let h = write_blob(&store, b"x");
1426 let mut idx = Index::new();
1427 idx.entries.push(IndexEntry {
1428 path: "a//b.txt".into(),
1429 status: EntryStatus::Blob,
1430 object_hash: h,
1431 mtime_ns: 0,
1432 size: 0,
1433 ino: 0,
1434 ctime_ns: 0,
1435 });
1436 let err = build_tree_from_index(&store, &idx).unwrap_err();
1437 assert!(matches!(err, WorktreeError::Io(_)));
1438 }
1439
1440 #[test]
1441 fn from_index_rejects_reserved_name() {
1442 let (_sd, store) = fresh_store();
1443 let h = write_blob(&store, b"x");
1444 let mut idx = Index::new();
1445 idx.entries.push(IndexEntry {
1448 path: ".mkit".into(),
1449 status: EntryStatus::Blob,
1450 object_hash: h,
1451 mtime_ns: 0,
1452 size: 0,
1453 ino: 0,
1454 ctime_ns: 0,
1455 });
1456 let err = build_tree_from_index(&store, &idx).unwrap_err();
1457 assert!(matches!(err, WorktreeError::Io(_)));
1458 }
1459
1460 #[test]
1466 fn from_index_matches_build_tree_for_equivalent_worktree() {
1467 let (_sd, store) = fresh_store();
1468
1469 let work = TempDir::new().unwrap();
1474 fs::write(work.path().join("a.txt"), b"alpha").unwrap();
1475 fs::create_dir(work.path().join("dir")).unwrap();
1476 fs::write(work.path().join("dir/b.txt"), b"beta").unwrap();
1477 fs::write(work.path().join("dir/c.txt"), b"gamma").unwrap();
1478 let worktree_root = build_tree(&store, work.path()).unwrap();
1479
1480 let a = write_blob(&store, b"alpha");
1481 let b = write_blob(&store, b"beta");
1482 let c = write_blob(&store, b"gamma");
1483 let mut idx = Index::new();
1484 idx.entries.push(IndexEntry {
1485 path: "a.txt".into(),
1486 status: EntryStatus::Blob,
1487 object_hash: a,
1488 mtime_ns: 0,
1489 size: 0,
1490 ino: 0,
1491 ctime_ns: 0,
1492 });
1493 idx.entries.push(IndexEntry {
1494 path: "dir/b.txt".into(),
1495 status: EntryStatus::Blob,
1496 object_hash: b,
1497 mtime_ns: 0,
1498 size: 0,
1499 ino: 0,
1500 ctime_ns: 0,
1501 });
1502 idx.entries.push(IndexEntry {
1503 path: "dir/c.txt".into(),
1504 status: EntryStatus::Blob,
1505 object_hash: c,
1506 mtime_ns: 0,
1507 size: 0,
1508 ino: 0,
1509 ctime_ns: 0,
1510 });
1511 let index_root = build_tree_from_index(&store, &idx).unwrap();
1512
1513 assert_eq!(
1514 worktree_root, index_root,
1515 "build_tree_from_index must produce the same root hash as build_tree for equivalent contents"
1516 );
1517 }
1518
1519 #[test]
1520 fn from_index_deeply_nested_paths_build_chain_of_subtrees() {
1521 let (_sd, store) = fresh_store();
1522 let h = write_blob(&store, b"deep");
1523 let mut idx = Index::new();
1524 idx.entries.push(IndexEntry {
1525 path: "a/b/c/d/e.txt".into(),
1526 status: EntryStatus::Blob,
1527 object_hash: h,
1528 mtime_ns: 0,
1529 size: 0,
1530 ino: 0,
1531 ctime_ns: 0,
1532 });
1533 let root = build_tree_from_index(&store, &idx).unwrap();
1534 let Object::Tree(t) = store.read_object(&root).unwrap() else {
1535 panic!();
1536 };
1537 assert_eq!(t.entries.len(), 1);
1538 assert_eq!(t.entries[0].name, b"a");
1539 assert_eq!(t.entries[0].mode, EntryMode::Tree);
1540 let mut cursor = t.entries[0].object_hash;
1542 for seg in [b"b" as &[u8], b"c", b"d"] {
1543 let Object::Tree(t) = store.read_object(&cursor).unwrap() else {
1544 panic!();
1545 };
1546 assert_eq!(t.entries.len(), 1);
1547 assert_eq!(t.entries[0].name, seg);
1548 cursor = t.entries[0].object_hash;
1549 }
1550 let Object::Tree(t) = store.read_object(&cursor).unwrap() else {
1551 panic!();
1552 };
1553 assert_eq!(t.entries[0].name, b"e.txt");
1554 assert_eq!(t.entries[0].object_hash, h);
1555 }
1556
1557 #[test]
1565 fn from_index_rejects_blob_then_subdir_collision() {
1566 let (_sd, store) = fresh_store();
1567 let h = write_blob(&store, b"x");
1568 let mut idx = Index::new();
1569 idx.entries.push(IndexEntry {
1570 path: "a".into(),
1571 status: EntryStatus::Blob,
1572 object_hash: h,
1573 mtime_ns: 0,
1574 size: 0,
1575 ino: 0,
1576 ctime_ns: 0,
1577 });
1578 idx.entries.push(IndexEntry {
1579 path: "a/b".into(),
1580 status: EntryStatus::Blob,
1581 object_hash: h,
1582 mtime_ns: 0,
1583 size: 0,
1584 ino: 0,
1585 ctime_ns: 0,
1586 });
1587 let err = build_tree_from_index(&store, &idx).unwrap_err();
1588 let msg = format!("{err}");
1589 assert!(
1590 msg.contains("conflict") || msg.contains("collision") || msg.contains("'a'"),
1591 "expected collision error mentioning the path, got: {msg}"
1592 );
1593 }
1594
1595 #[test]
1598 fn from_index_rejects_subdir_then_blob_collision() {
1599 let (_sd, store) = fresh_store();
1600 let h = write_blob(&store, b"x");
1601 let mut idx = Index::new();
1602 idx.entries.push(IndexEntry {
1603 path: "a/b".into(),
1604 status: EntryStatus::Blob,
1605 object_hash: h,
1606 mtime_ns: 0,
1607 size: 0,
1608 ino: 0,
1609 ctime_ns: 0,
1610 });
1611 idx.entries.push(IndexEntry {
1612 path: "a".into(),
1613 status: EntryStatus::Blob,
1614 object_hash: h,
1615 mtime_ns: 0,
1616 size: 0,
1617 ino: 0,
1618 ctime_ns: 0,
1619 });
1620 assert!(build_tree_from_index(&store, &idx).is_err());
1621 }
1622
1623 #[test]
1624 fn from_index_rejects_duplicate_exact_path() {
1625 let (_sd, store) = fresh_store();
1626 let a = write_blob(&store, b"a");
1627 let b = write_blob(&store, b"b");
1628 let mut idx = Index::new();
1629 idx.entries.push(IndexEntry {
1630 path: "same.txt".into(),
1631 status: EntryStatus::Blob,
1632 object_hash: a,
1633 mtime_ns: 0,
1634 size: 0,
1635 ino: 0,
1636 ctime_ns: 0,
1637 });
1638 idx.entries.push(IndexEntry {
1639 path: "same.txt".into(),
1640 status: EntryStatus::Blob,
1641 object_hash: b,
1642 mtime_ns: 0,
1643 size: 0,
1644 ino: 0,
1645 ctime_ns: 0,
1646 });
1647
1648 let err = build_tree_from_index(&store, &idx).unwrap_err();
1649 let msg = format!("{err}");
1650 assert!(msg.contains("duplicate index path"), "got: {msg}");
1651 }
1652
1653 #[test]
1654 fn from_index_rejects_duplicate_removed_and_live_path() {
1655 let (_sd, store) = fresh_store();
1656 let h = write_blob(&store, b"live");
1657 let mut idx = Index::new();
1658 idx.entries.push(IndexEntry {
1659 path: "same.txt".into(),
1660 status: EntryStatus::Removed,
1661 object_hash: [0; 32],
1662 mtime_ns: 0,
1663 size: 0,
1664 ino: 0,
1665 ctime_ns: 0,
1666 });
1667 idx.entries.push(IndexEntry {
1668 path: "same.txt".into(),
1669 status: EntryStatus::Blob,
1670 object_hash: h,
1671 mtime_ns: 0,
1672 size: 0,
1673 ino: 0,
1674 ctime_ns: 0,
1675 });
1676
1677 let err = build_tree_from_index(&store, &idx).unwrap_err();
1678 let msg = format!("{err}");
1679 assert!(msg.contains("duplicate index path"), "got: {msg}");
1680 }
1681
1682 #[test]
1689 fn from_index_all_removed_produces_empty_tree() {
1690 let (_sd, store) = fresh_store();
1691 let mut idx = Index::new();
1692 idx.entries.push(IndexEntry {
1693 path: "gone.txt".into(),
1694 status: EntryStatus::Removed,
1695 object_hash: [0; 32],
1696 mtime_ns: 0,
1697 size: 0,
1698 ino: 0,
1699 ctime_ns: 0,
1700 });
1701 let h = build_tree_from_index(&store, &idx).unwrap();
1702 let Object::Tree(t) = store.read_object(&h).unwrap() else {
1703 panic!();
1704 };
1705 assert!(t.entries.is_empty());
1706 }
1707
1708 #[test]
1711 fn from_index_root_is_a_tree_object() {
1712 let (_sd, store) = fresh_store();
1713 let idx = Index::new();
1714 let h = build_tree_from_index(&store, &idx).unwrap();
1715 let obj = store.read_object(&h).unwrap();
1716 assert_eq!(obj.object_type(), ObjectType::Tree);
1717 }
1718
1719 #[test]
1720 fn from_index_rejects_missing_blob_object() {
1721 let (_sd, store) = fresh_store();
1722 let mut idx = Index::new();
1723 idx.entries.push(IndexEntry {
1724 path: "missing.txt".into(),
1725 status: EntryStatus::Blob,
1726 object_hash: [42; 32],
1727 mtime_ns: 0,
1728 size: 0,
1729 ino: 0,
1730 ctime_ns: 0,
1731 });
1732
1733 let err = build_tree_from_index(&store, &idx).unwrap_err();
1734 assert!(matches!(err, WorktreeError::Store(_)));
1735 }
1736
1737 #[test]
1738 fn from_index_rejects_non_blob_object_for_blob_status() {
1739 let (_sd, store) = fresh_store();
1740 let tree = Object::Tree(Tree { entries: vec![] });
1741 let body = serialize::serialize(&tree).unwrap();
1742 let tree_hash = store.write(&body).unwrap();
1743 let mut idx = Index::new();
1744 idx.entries.push(IndexEntry {
1745 path: "not-a-blob.txt".into(),
1746 status: EntryStatus::Blob,
1747 object_hash: tree_hash,
1748 mtime_ns: 0,
1749 size: 0,
1750 ino: 0,
1751 ctime_ns: 0,
1752 });
1753
1754 let err = build_tree_from_index(&store, &idx).unwrap_err();
1755 let msg = format!("{err}");
1756 assert!(
1757 msg.contains("non-blob"),
1758 "expected non-blob index object error, got: {msg}"
1759 );
1760 }
1761
1762 #[test]
1769 fn from_index_accepts_chunked_blob_for_file_entry() {
1770 let (_sd, store) = fresh_store();
1771 let n = usize::try_from(CHUNK_THRESHOLD).unwrap() + 256 * 1024;
1774 let mut big = Vec::with_capacity(n);
1775 let mut state: u64 = 0x00C0_FFEE;
1776 for _ in 0..n {
1777 state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
1778 let mut z = state;
1779 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
1780 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
1781 z ^= z >> 31;
1782 big.push((z & 0xFF) as u8);
1783 }
1784 let chunked_hash = store_file_object(&store, &big).unwrap();
1785 assert!(
1786 matches!(
1787 store.read_object(&chunked_hash).unwrap(),
1788 Object::ChunkedBlob(_)
1789 ),
1790 "fixture must be a ChunkedBlob"
1791 );
1792
1793 let mut idx = Index::new();
1794 idx.entries.push(IndexEntry {
1795 path: "big.bin".into(),
1796 status: EntryStatus::Blob,
1797 object_hash: chunked_hash,
1798 mtime_ns: 0,
1799 size: 0,
1800 ino: 0,
1801 ctime_ns: 0,
1802 });
1803 let root = build_tree_from_index(&store, &idx).unwrap();
1804 let Object::Tree(t) = store.read_object(&root).unwrap() else {
1805 panic!("expected tree");
1806 };
1807 assert_eq!(t.entries.len(), 1);
1808 assert_eq!(t.entries[0].name, b"big.bin");
1809 assert_eq!(t.entries[0].mode, EntryMode::Blob);
1810 assert_eq!(t.entries[0].object_hash, chunked_hash);
1811 assert_eq!(read_blob(&store, &chunked_hash).unwrap(), big);
1813 }
1814
1815 #[test]
1821 fn read_blob_rejects_chunked_total_size_mismatch() {
1822 let (_sd, store) = fresh_store();
1823 let chunk = serialize::serialize(&Object::Blob(crate::object::Blob {
1824 data: b"twelve bytes".to_vec(),
1825 }))
1826 .unwrap();
1827 let chunk_hash = store.write(&chunk).unwrap();
1828 let manifest = Object::ChunkedBlob(ChunkedBlob {
1829 total_size: 999,
1830 chunk_size: 0,
1831 chunks: vec![chunk_hash],
1832 });
1833 let h = store
1834 .write(&serialize::serialize(&manifest).unwrap())
1835 .unwrap();
1836 let err = read_blob(&store, &h).unwrap_err();
1837 assert!(
1838 matches!(
1839 err,
1840 WorktreeError::Object(crate::object::MkitError::ChunkedBlobSizeMismatch {
1841 expected: 999,
1842 actual: 12,
1843 })
1844 ),
1845 "expected ChunkedBlobSizeMismatch, got {err:?}"
1846 );
1847 }
1848
1849 #[test]
1852 fn from_index_rejects_chunked_blob_for_symlink_entry() {
1853 let (_sd, store) = fresh_store();
1854 let n = usize::try_from(CHUNK_THRESHOLD).unwrap() + 256 * 1024;
1855 let big = vec![0xABu8; n];
1856 let chunked_hash = store_file_object(&store, &big).unwrap();
1857 let mut idx = Index::new();
1858 idx.entries.push(IndexEntry {
1859 path: "link".into(),
1860 status: EntryStatus::Symlink,
1861 object_hash: chunked_hash,
1862 mtime_ns: 0,
1863 size: 0,
1864 ino: 0,
1865 ctime_ns: 0,
1866 });
1867 let err = build_tree_from_index(&store, &idx).unwrap_err();
1868 assert!(format!("{err}").contains("non-blob"));
1869 }
1870
1871 #[test]
1878 fn store_file_object_via_batch_equals_via_store() {
1879 let data: Vec<u8> = (0..3 * 1024 * 1024u32)
1881 .map(|i| u8::try_from((i.wrapping_mul(2_654_435_761)) % 251).unwrap())
1882 .collect();
1883
1884 let (_d1, store1) = fresh_store();
1885 let h_store = store_file_object(&store1, &data).unwrap();
1886
1887 let (_d2, store2) = fresh_store();
1888 let batch = store2.batch();
1889 let h_batch = store_file_object(&batch, &data).unwrap();
1890 batch.commit().unwrap();
1891
1892 assert_eq!(h_store, h_batch, "sink choice must not change hashes");
1893 assert_eq!(
1894 read_blob(&store1, &h_store).unwrap(),
1895 read_blob(&store2, &h_batch).unwrap(),
1896 );
1897
1898 let small = b"under the chunk threshold";
1900 let h1 = store_file_object(&store1, small).unwrap();
1901 let batch2 = store2.batch();
1902 let h2 = store_file_object(&batch2, small).unwrap();
1903 batch2.commit().unwrap();
1904 assert_eq!(h1, h2);
1905 }
1906
1907 #[test]
1910 fn build_tree_from_index_with_batch_single_flush() {
1911 use crate::batch::testing::{Ev, RecordingSyncer};
1912 use crate::index::{EntryStatus, Index, IndexEntry};
1913 use std::sync::Arc;
1914
1915 let (_sd, mut store) = fresh_store();
1916 let mut idx = Index::default();
1918 for i in 0..20 {
1919 let blob = Object::Blob(crate::object::Blob {
1920 data: format!("file {i}").into_bytes(),
1921 });
1922 let bytes = serialize::serialize(&blob).unwrap();
1923 let h = store.write(&bytes).unwrap();
1924 idx.entries.push(IndexEntry {
1925 status: EntryStatus::Blob,
1926 object_hash: h,
1927 path: format!("d{}/sub/f{i}.txt", i % 5),
1928 mtime_ns: 0,
1929 size: 0,
1930 ino: 0,
1931 ctime_ns: 0,
1932 });
1933 }
1934
1935 let rec = Arc::new(RecordingSyncer::default());
1936 store.set_syncer(rec.clone());
1937
1938 let batch = store.batch();
1939 let tree_h = build_tree_from_index_with(&store, &batch, &idx, true).unwrap();
1940 batch.commit().unwrap();
1941
1942 let fulls = rec
1943 .events()
1944 .iter()
1945 .filter(|e| matches!(e, Ev::Full(_)))
1946 .count();
1947 assert_eq!(fulls, 2, "tree materialisation flush cost must be constant");
1948 assert!(store.read_object(&tree_h).is_ok());
1949
1950 let (_sd2, store2) = fresh_store();
1952 for i in 0..20 {
1953 let blob = Object::Blob(crate::object::Blob {
1954 data: format!("file {i}").into_bytes(),
1955 });
1956 store2.write(&serialize::serialize(&blob).unwrap()).unwrap();
1957 }
1958 assert_eq!(tree_h, build_tree_from_index(&store2, &idx).unwrap());
1959 }
1960
1961 #[test]
1965 fn build_tree_from_index_verify_rejects_corrupt_staged_object() {
1966 use crate::index::{EntryStatus, Index, IndexEntry};
1967
1968 let (_sd, store) = fresh_store();
1969 let blob = Object::Blob(crate::object::Blob {
1970 data: b"hello".to_vec(),
1971 });
1972 let h = store.write(&serialize::serialize(&blob).unwrap()).unwrap();
1973 let mut idx = Index::default();
1974 idx.entries.push(IndexEntry {
1975 status: EntryStatus::Blob,
1976 object_hash: h,
1977 path: "a.txt".to_string(),
1978 mtime_ns: 0,
1979 size: 0,
1980 ino: 0,
1981 ctime_ns: 0,
1982 });
1983
1984 assert!(build_tree_from_index_with(&store, &store, &idx, true).is_ok());
1986 assert!(build_tree_from_index_with(&store, &store, &idx, false).is_ok());
1987
1988 let path = store.path_for(&h);
1991 let mut bytes = std::fs::read(&path).unwrap();
1992 let i = bytes.len() - 1;
1993 bytes[i] ^= 0xFF;
1994 std::fs::write(&path, &bytes).unwrap();
1995
1996 assert!(
1998 build_tree_from_index_with(&store, &store, &idx, true).is_err(),
1999 "commit-path tree build must reject a corrupt staged object"
2000 );
2001 assert!(
2003 build_tree_from_index_with(&store, &store, &idx, false).is_ok(),
2004 "status/diff snapshot path keeps the cheap prologue-only check"
2005 );
2006 }
2007
2008 #[test]
2013 fn hash_file_object_equals_store_file_object() {
2014 let threshold = usize::try_from(CHUNK_THRESHOLD).unwrap();
2015 for len in [0usize, 1, 1024, threshold, 3 * 1024 * 1024] {
2016 let data: Vec<u8> = (0..len)
2017 .map(|i| u8::try_from((i * 31 + 7) % 251).unwrap())
2018 .collect();
2019 let (_sd, store) = fresh_store();
2020 let stored = store_file_object(&store, &data).unwrap();
2021 let pure = hash_file_object(&data).unwrap();
2022 assert_eq!(stored, pure, "len {len}: pure hash must match stored hash");
2023 }
2024 }
2025
2026 #[test]
2027 fn hash_file_object_writes_nothing() {
2028 let (_sd, store) = fresh_store();
2029 let data = vec![0xAB; 2 * 1024 * 1024]; let _ = hash_file_object(&data).unwrap();
2031 assert!(
2032 store.iter_object_hashes().unwrap().is_empty(),
2033 "pure hashing must not create objects"
2034 );
2035 }
2036
2037 fn meta_of(p: &Path) -> fs::Metadata {
2038 p.symlink_metadata().unwrap()
2039 }
2040
2041 #[test]
2042 fn stat_matches_requires_nonzero_mtime_and_equal_fields() {
2043 let work = TempDir::new().unwrap();
2044 let f = work.path().join("a.txt");
2045 fs::write(&f, b"hello").unwrap();
2046 let meta = meta_of(&f);
2047 let entry = crate::index::IndexEntry {
2048 path: "a.txt".into(),
2049 status: crate::index::EntryStatus::Blob,
2050 object_hash: crate::hash::hash(b"irrelevant"),
2051 mtime_ns: mtime_nanos(&meta),
2052 size: meta.len(),
2053 ino: 0,
2054 ctime_ns: 0,
2055 };
2056 assert!(stat_matches(&entry, &meta));
2057
2058 let mut zeroed = entry.clone();
2060 zeroed.mtime_ns = 0;
2061 assert!(!stat_matches(&zeroed, &meta), "zero sentinel must re-hash");
2062
2063 let mut wrong_size = entry.clone();
2065 wrong_size.size += 1;
2066 assert!(!stat_matches(&wrong_size, &meta));
2067
2068 let mut wrong_time = entry.clone();
2070 wrong_time.mtime_ns ^= 1;
2071 assert!(!stat_matches(&wrong_time, &meta));
2072 }
2073
2074 #[cfg(unix)]
2075 #[test]
2076 fn stat_matches_detects_exec_bit_flip() {
2077 use std::os::unix::fs::PermissionsExt;
2078 let work = TempDir::new().unwrap();
2079 let f = work.path().join("run.sh");
2080 fs::write(&f, b"#!/bin/sh\n").unwrap();
2081 let meta = meta_of(&f);
2082 let entry = crate::index::IndexEntry {
2083 path: "run.sh".into(),
2084 status: crate::index::EntryStatus::Blob,
2085 object_hash: crate::hash::hash(b"x"),
2086 mtime_ns: mtime_nanos(&meta),
2087 size: meta.len(),
2088 ino: 0,
2089 ctime_ns: 0,
2090 };
2091 assert!(stat_matches(&entry, &meta));
2092 let mtime = meta.modified().unwrap();
2095 fs::set_permissions(&f, fs::Permissions::from_mode(0o755)).unwrap();
2096 let f_handle = fs::File::options().write(true).open(&f).unwrap();
2097 f_handle
2098 .set_times(fs::FileTimes::new().set_modified(mtime))
2099 .unwrap();
2100 drop(f_handle);
2101 let meta2 = meta_of(&f);
2102 assert_eq!(mtime_nanos(&meta2), entry.mtime_ns, "mtime restored");
2103 assert!(
2104 !stat_matches(&entry, &meta2),
2105 "exec-bit flip must invalidate a Blob-status cache hit"
2106 );
2107 }
2108
2109 #[cfg(unix)]
2113 #[test]
2114 fn build_tree_reuses_hash_on_stat_match_without_reading_file() {
2115 use std::os::unix::fs::PermissionsExt;
2116 let (_sd, store) = fresh_store();
2117 let work = TempDir::new().unwrap();
2118 let f = work.path().join("locked.txt");
2119 fs::write(&f, b"cached content").unwrap();
2120
2121 let staged_hash = store_file_object(&store, b"cached content").unwrap();
2122 let meta = meta_of(&f);
2123 let idx = crate::index::Index::from_entries(vec![crate::index::IndexEntry {
2124 path: "locked.txt".into(),
2125 status: crate::index::EntryStatus::Blob,
2126 object_hash: staged_hash,
2127 mtime_ns: mtime_nanos(&meta),
2128 size: meta.len(),
2129 ino: 0,
2130 ctime_ns: 0,
2131 }]);
2132
2133 fs::set_permissions(&f, fs::Permissions::from_mode(0o000)).unwrap();
2135 let result = build_tree_filtered(&store, work.path(), Some(&idx));
2136 fs::set_permissions(&f, fs::Permissions::from_mode(0o644)).unwrap();
2137 let tree_h = result.expect("stat match must skip the file read");
2138
2139 let Object::Tree(t) = store.read_object(&tree_h).unwrap() else {
2140 panic!("expected tree");
2141 };
2142 assert_eq!(t.entries.len(), 1);
2143 assert_eq!(t.entries[0].object_hash, staged_hash);
2144
2145 let (_sd2, store2) = fresh_store();
2147 let f2_dir = TempDir::new().unwrap();
2148 fs::write(f2_dir.path().join("locked.txt"), b"cached content").unwrap();
2149 let plain = build_tree(&store2, f2_dir.path()).unwrap();
2150 assert_eq!(plain, tree_h, "cache hit must not change tree hashes");
2151 }
2152
2153 #[cfg(unix)]
2156 #[test]
2157 fn stat_mismatch_on_inode_rehashes() {
2158 let work = TempDir::new().unwrap();
2159 let f = work.path().join("swap.txt");
2160 fs::write(&f, b"original").unwrap();
2161 let meta = meta_of(&f);
2162 let (mtime_ns, size, ino, ctime_ns) = stat_cache_fields(&meta);
2163 let entry = crate::index::IndexEntry {
2164 path: "swap.txt".into(),
2165 status: crate::index::EntryStatus::Blob,
2166 object_hash: crate::hash::hash(b"original"),
2167 mtime_ns,
2168 size,
2169 ino,
2170 ctime_ns,
2171 };
2172 assert!(stat_matches(&entry, &meta));
2173
2174 let staging = work.path().join(".swap.new");
2177 fs::write(&staging, b"REPLACED").unwrap(); let fh = fs::File::options().write(true).open(&staging).unwrap();
2179 fh.set_times(fs::FileTimes::new().set_modified(meta.modified().unwrap()))
2180 .unwrap();
2181 drop(fh);
2182 fs::rename(&staging, &f).unwrap();
2183 let meta2 = meta_of(&f);
2184 assert_eq!(meta2.len(), entry.size, "size preserved by the swap");
2185 assert!(
2186 !stat_matches(&entry, &meta2),
2187 "a renamed-in replacement must not stat-match (ino differs)"
2188 );
2189 }
2190
2191 #[test]
2195 fn stat_mismatch_on_ctime_rehashes() {
2196 let work = TempDir::new().unwrap();
2197 let f = work.path().join("touched.txt");
2198 fs::write(&f, b"content").unwrap();
2199 let meta = meta_of(&f);
2200 let (mtime_ns, size, ino, ctime_ns) = stat_cache_fields(&meta);
2201 if ctime_ns == 0 {
2202 return; }
2204 let entry = crate::index::IndexEntry {
2205 path: "touched.txt".into(),
2206 status: crate::index::EntryStatus::Blob,
2207 object_hash: crate::hash::hash(b"content"),
2208 mtime_ns,
2209 size,
2210 ino,
2211 ctime_ns: ctime_ns ^ 1,
2212 };
2213 assert!(
2214 !stat_matches(&entry, &meta),
2215 "ctime disagreement must invalidate the cache"
2216 );
2217 }
2218
2219 #[test]
2224 fn build_tree_observed_reports_clean_rehashes() {
2225 let (_sd, store) = fresh_store();
2226 let work = TempDir::new().unwrap();
2227 fs::write(work.path().join("clean.txt"), b"clean bytes").unwrap();
2228 fs::write(work.path().join("dirty.txt"), b"new content").unwrap();
2229
2230 let clean_hash = store_file_object(&store, b"clean bytes").unwrap();
2231 let stale_hash = crate::hash::hash(b"old content");
2232 let idx = crate::index::Index::from_entries(vec![
2233 crate::index::IndexEntry {
2234 path: "clean.txt".into(),
2235 status: crate::index::EntryStatus::Blob,
2236 object_hash: clean_hash,
2237 mtime_ns: 0, size: 0,
2239 ino: 0,
2240 ctime_ns: 0,
2241 },
2242 crate::index::IndexEntry {
2243 path: "dirty.txt".into(),
2244 status: crate::index::EntryStatus::Blob,
2245 object_hash: stale_hash,
2246 mtime_ns: 0,
2247 size: 0,
2248 ino: 0,
2249 ctime_ns: 0,
2250 },
2251 ]);
2252 let mut obs = Vec::new();
2253 build_tree_filtered_observed(&store, work.path(), Some(&idx), &mut obs).unwrap();
2254
2255 assert_eq!(obs.len(), 1, "only the verified-clean entry is observed");
2256 let o = &obs[0];
2257 assert_eq!(o.path, "clean.txt");
2258 assert_eq!(o.object_hash, clean_hash);
2259 let meta = meta_of(&work.path().join("clean.txt"));
2260 let (mtime_ns, size, _ino, _ctime) = stat_cache_fields(&meta);
2261 assert_eq!(o.mtime_ns, mtime_ns, "observation carries the fd stat");
2262 assert_eq!(o.size, size);
2263 }
2264
2265 #[test]
2267 fn build_tree_rehashes_on_stat_mismatch() {
2268 let (_sd, store) = fresh_store();
2269 let work = TempDir::new().unwrap();
2270 let f = work.path().join("changed.txt");
2271 fs::write(&f, b"new content").unwrap();
2272 let stale_hash = crate::hash::hash(b"not the real object");
2273 let meta = meta_of(&f);
2274 let idx = crate::index::Index::from_entries(vec![crate::index::IndexEntry {
2275 path: "changed.txt".into(),
2276 status: crate::index::EntryStatus::Blob,
2277 object_hash: stale_hash,
2278 mtime_ns: mtime_nanos(&meta),
2280 size: meta.len() + 1,
2281 ino: 0,
2282 ctime_ns: 0,
2283 }]);
2284 let tree_h = build_tree_filtered(&store, work.path(), Some(&idx)).unwrap();
2285 let Object::Tree(t) = store.read_object(&tree_h).unwrap() else {
2286 panic!("expected tree");
2287 };
2288 assert_ne!(
2289 t.entries[0].object_hash, stale_hash,
2290 "mismatched stat must not reuse the stale hash"
2291 );
2292 assert_eq!(
2293 t.entries[0].object_hash,
2294 store_file_object(&store, b"new content").unwrap()
2295 );
2296 }
2297}