Skip to main content

mkit_core/
worktree.rs

1//! Worktree → tree-object builder.
2//!
3//! Walks a directory, applies `.mkitignore`, hashes each file as a
4//! [`Blob`](crate::object::Blob), recurses on subdirectories, validates
5//! symlink targets against path-traversal, and writes a single root
6//! [`Tree`] into the supplied [`ObjectStore`].
7//!
8//! Notes:
9//!
10//! - Files at or below [`CHUNK_THRESHOLD`] are stored as a single
11//!   [`Blob`](crate::object::Blob). Files above the threshold are
12//!   chunked with [`crate::chunker::FastCdc::v1`]; each chunk is
13//!   stored as a `Blob` and the file is represented by a
14//!   [`ChunkedBlob`] manifest whose hash
15//!   is what lands in the parent tree.
16//! - We never follow symlinks while walking. Linux/macOS `read_link`
17//!   reports the target verbatim and we hash it as a blob.
18
19use 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
31/// Files larger than this go through the chunker (1 MiB).
32pub const CHUNK_THRESHOLD: u64 = 1024 * 1024;
33
34/// Hard cap on a single file (1 GiB).
35pub const MAX_FILE_BYTES: u64 = 1024 * 1024 * 1024;
36
37/// Errors returned by this module.
38#[derive(Debug, thiserror::Error)]
39pub enum WorktreeError {
40    /// `read_link` returned a target that fails [`validate_symlink_target`].
41    #[error("symlink target '{0}' is invalid (absolute or contains '..')")]
42    InvalidSymlinkTarget(String),
43    /// File exceeded [`MAX_FILE_BYTES`].
44    #[error("file '{0}' exceeds the {MAX_FILE_BYTES} byte limit")]
45    FileTooLarge(PathBuf),
46    /// Path component had non-UTF-8 bytes; tree entry names must be UTF-8.
47    #[error("path component is not valid UTF-8")]
48    InvalidUtf8,
49    /// Underlying I/O failure.
50    #[error(transparent)]
51    Io(#[from] io::Error),
52    /// Error encoding/serialising an object on its way into the store.
53    #[error(transparent)]
54    Object(#[from] crate::object::MkitError),
55    /// Error returned by the object store.
56    #[error(transparent)]
57    Store(#[from] crate::store::StoreError),
58}
59
60/// Result alias used throughout this module.
61pub type WorktreeResult<T> = Result<T, WorktreeError>;
62
63mod blob;
64pub use blob::{LoadedBlob, read_blob};
65
66/// Validate a symlink target: must be relative and contain no `..`
67/// segments.
68#[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/// A hash-time stat observation: while building a tree we re-hashed
85/// `path` (its cache was absent or racy-smudged) and the result equals
86/// the staging index's hash — so the stat captured from the OPENED file
87/// descriptor *before* its content was read proves the entry clean.
88/// `status` consumes these to heal the stat cache without ever pairing
89/// a post-verification stat with a pre-verification hash (the unsound
90/// verify-then-stat order).
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct StatObservation {
93    /// Repo-relative path, `/`-separated (index path form).
94    pub path: String,
95    /// The content hash the re-hash produced (== the index entry's).
96    pub object_hash: Hash,
97    /// Stat fields captured from the opened fd before the read, in
98    /// [`stat_cache_fields`] order.
99    pub mtime_ns: u64,
100    pub size: u64,
101    pub ino: u64,
102    pub ctime_ns: u64,
103}
104
105/// Build a tree object for `dir` and its subdirectories. Honours the
106/// `.gitignore` + `.mkitignore` ignore files loaded from `dir`.
107///
108/// Ignore rules only exclude **untracked** content: a path that is tracked
109/// (or whose subtree holds tracked content) is always included even if it
110/// matches an ignore rule, so a tracked file matching `.gitignore` is never
111/// dropped from the worktree snapshot (which would misreport it as a deletion
112/// in status/diff). The staging index at `<dir>/.mkit/index` provides the
113/// tracked set; an absent index means nothing is tracked.
114///
115/// # Errors
116/// See [`WorktreeError`].
117pub fn build_tree<S: ObjectSink + ?Sized>(sink: &S, dir: &Path) -> WorktreeResult<Hash> {
118    build_tree_filtered(sink, dir, None)
119}
120
121/// Like [`build_tree`], but the caller supplies the authoritative tracked
122/// set (`index`). Callers that seed their index from `HEAD` when no index
123/// file exists yet (status, restore safety) MUST pass it here so a tracked
124/// file that matches an ignore rule is not dropped right after a checkout.
125/// `None` falls back to the on-disk `<dir>/.mkit/index` (empty if absent).
126///
127/// # Errors
128/// See [`WorktreeError`].
129pub 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
137/// [`build_tree_filtered`] that additionally reports every
138/// [`StatObservation`] (file re-hashed to a hash matching its index
139/// entry) into `observations`, so callers can heal the stat cache from
140/// hash-time stats.
141///
142/// # Errors
143/// See [`WorktreeError`].
144pub 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    // Tracked set for ignore exemption: the caller's index if given, else the
157    // on-disk index (missing/unreadable = empty = nothing tracked).
158    let loaded;
159    let index = if let Some(i) = index {
160        i
161    } else {
162        // Single-layout assumption: `dir` is treated as a classic
163        // single-worktree root. Linked-worktree callers (#493 Phase 1+)
164        // must pass `Some(index)` or this fallback reads the wrong
165        // index; the CLI always passes the discovered layout's index.
166        loaded = index::read_index(&crate::layout::RepoLayout::single(dir)).unwrap_or_default();
167        &loaded
168    };
169    // O(1) per-file entry lookups; `Index::find_entry` is a linear scan
170    // and the walk consults it once per regular file.
171    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/// `rel_dir` is the path of `dir` relative to the repo root (empty at the
186/// root), so ignore patterns can be matched against full repo-relative paths
187/// rather than bare basenames. `parent_ignored` carries down whether an
188/// ancestor directory is ignored (git "everything under an excluded dir is
189/// excluded"); `index` is the tracked set used to exempt tracked content.
190#[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        // `symlink_metadata` does not follow symlinks.
211        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        // Exclude ignored content, but only when it is UNTRACKED — a tracked
219        // path (or a dir holding tracked content) is always kept so status/
220        // diff see it. An ignored dir with tracked content is descended into
221        // (carrying the ignored bit) so its untracked children stay excluded.
222        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            // Stat cache: when the staging index proves this file's
237            // content via mtime+size+ino+ctime (+exec class), reuse the
238            // staged hash without opening the file — O(stat) instead of
239            // O(content) for unchanged files. The object was stored at
240            // `add` time, so the tree reference stays resolvable.
241            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                // Cache miss that re-hashed back to the staged hash:
248                // report the observation (stat captured from the opened
249                // fd BEFORE the content read) so callers can heal the
250                // racy-smudged cache soundly.
251                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            // A directory on disk at a path tracked as a *file* shadows that
273            // tracked entry. git reports only the tracked-side deletion and
274            // suppresses the directory's contents as untracked (#288); mirror
275            // that by leaving the whole subtree out of the snapshot, so the
276            // tracked file reads as deleted and nothing inside surfaces.
277            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            // Block / char / fifo / socket — silently skip.
314        }
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
323/// Build a tree object from an [`Index`] (the staging area).
324///
325/// Walks the flat list of entries, groups them by directory, and
326/// recursively materialises sub-tree objects so the on-disk shape
327/// matches what [`build_tree`] would produce for the same set of
328/// paths. Entries with [`crate::index::EntryStatus::Removed`] are
329/// excluded; everything else maps to an [`EntryMode`] one-to-one.
330///
331/// A file entry (Blob/Executable) may address either a single
332/// [`Blob`](crate::object::Blob) or, for content above
333/// [`CHUNK_THRESHOLD`], a [`ChunkedBlob`]
334/// manifest — exactly the two shapes `store_file_object` (and hence
335/// `add`/`hash_file`/`build_tree`) can produce. Symlink entries must be
336/// a single `Blob`. Any other object kind under a file entry is rejected.
337///
338/// # Errors
339/// - [`WorktreeError::Io`] on a [`crate::object::TreeEntry::validate_name`]
340///   failure (the path's leaf segment is reserved or alias-prone), or
341///   when a file entry points at a non-blob/non-chunked-blob object.
342/// - Wraps [`crate::MkitError`] surfaced by `serialize` / `store.write`.
343pub fn build_tree_from_index(
344    store: &ObjectStore,
345    index: &crate::index::Index,
346) -> WorktreeResult<Hash> {
347    // The convenience wrapper publishes a durable tree (commit/merge/
348    // rebase/…), so it integrity-verifies staged objects by default.
349    build_tree_from_index_with(store, store, index, true)
350}
351
352/// [`build_tree_from_index`] writing tree objects through `sink` —
353/// pass a [`WriteBatch`](crate::batch::WriteBatch) to amortise the
354/// flush cost of all materialised trees into the batch's single commit.
355/// `store` is still needed read-only to validate that staged hashes
356/// point at blob-shaped objects (a sink cannot read).
357///
358/// `verify` selects the staged-object integrity check. With `verify =
359/// true` (every path that publishes a durable tree) each referenced
360/// object is read and re-hashed before the tree is materialised, so a
361/// corrupt staged object can never be published. With `verify = false`
362/// (ephemeral status/diff snapshots that publish nothing durable) only
363/// the 6-byte prologue is read for the blob-shape check — the read path
364/// still integrity-verifies the object whenever it is actually used.
365///
366/// # Errors
367/// See [`build_tree_from_index`].
368#[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    // Build an in-memory directory tree. Each node is either a leaf
378    // (one staged blob/symlink) or a directory containing children.
379    #[derive(Default)]
380    struct Node {
381        // Subdirectory name → child node.
382        children: std::collections::BTreeMap<String, Node>,
383        // Leaf entries directly under this dir: name → (mode, hash).
384        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                // Reserved-but-unused per SPEC-INDEX §3. Reject for
406                // now; if a subtree-staging design lands later it
407                // can populate this branch.
408                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        // A regular file (Blob/Executable) may be stored as a single
415        // Blob or, for content above CHUNK_THRESHOLD, a ChunkedBlob
416        // manifest — `add`/`hash_file`/`build_tree` all route through
417        // `store_file_object`. A Symlink is always a single Blob (its
418        // target path). Accept both blob shapes for file entries so the
419        // commit/index path agrees with the worktree-hashing path; a
420        // tree/commit/etc. under a file entry is still rejected.
421        // Publishing paths (`verify`) read + re-hash the staged object so
422        // a tree never references a corrupt blob; the read path's hash
423        // check is the same one `add` passed, so this only catches
424        // post-`add` corruption. Ephemeral status/diff snapshots skip it
425        // — re-reading every staged blob on every status dominates large
426        // repos of small files, and they publish nothing durable.
427        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        // Split "a/b/c.txt" into ["a", "b"] + "c.txt".
445        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            // Collision: this segment was previously staged as a blob
464            // (e.g. earlier index entry was `a` as a file, this one
465            // is `a/b`). Tree object format requires unique entry
466            // names per directory; emitting both would produce an
467            // invalid tree the deserializer rejects under its strict
468            // ascending-name rule.
469            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        // The reverse collision: this entry's leaf name already exists
487        // as a child directory under the same parent (an earlier
488        // entry staged `a/b` and now this one stages `a` as a file).
489        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        // Subdirectories first (alphabetical via BTreeMap).
519        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        // Then leaves.
535        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        // Tree-entry order is name-ascending per SPEC-OBJECTS §4.
550        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
559/// Read a file from disk, hash it, store it, and return the
560/// content-address of the resulting object.
561///
562/// Files at or below [`CHUNK_THRESHOLD`] become a single
563/// [`Blob`](crate::object::Blob). Files above the threshold are split
564/// with [`FastCdc::v1`]; each chunk is stored as a `Blob`, and the
565/// file is represented by a [`ChunkedBlob`]
566/// manifest whose hash is returned and lands in the parent tree. See
567/// `SPEC-FASTCDC.md` and `SPEC-OBJECTS.md` §7.
568///
569/// # Errors
570/// See [`WorktreeError`].
571pub fn hash_file<S: ObjectSink + ?Sized>(sink: &S, path: &Path) -> WorktreeResult<Hash> {
572    hash_file_with_metadata(sink, path).map(|(hash, _)| hash)
573}
574
575/// Read a regular file without following the final path component on
576/// Unix, enforcing [`MAX_FILE_BYTES`] against both the opened handle's
577/// metadata and the actual bytes read.
578pub 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
602/// Hash and store a regular file, returning its content-address and the
603/// [`fs::Metadata`] observed when it was opened (for stat-cache use by
604/// callers like `mkit add`).
605///
606/// Files at or below [`CHUNK_THRESHOLD`] are read fully into memory — a
607/// single [`Blob`](crate::object::Blob) needs its bytes contiguous
608/// anyway, and the threshold keeps this small (1 MiB). Files above the
609/// threshold are streamed chunk-by-chunk directly from the open file
610/// handle instead of first buffering the whole file (issue #828):
611/// ingest memory is bounded by one `FastCdc` window regardless of the
612/// file's total size.
613///
614/// # Errors
615/// See [`WorktreeError`].
616pub 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
650/// Store a large (> [`CHUNK_THRESHOLD`]) file's content as a
651/// [`ChunkedBlob`] manifest, streaming chunks directly from `reader`
652/// instead of requiring the whole file resident in memory first (issue
653/// #828). Bounds ingest memory to one `FastCdc::v1` window
654/// (`chunker::MAX_SIZE`, 256 KiB) plus the growing chunk-hash list (32
655/// bytes/chunk), regardless of the file's total size.
656///
657/// `path` is used only to name the file in a [`WorktreeError::FileTooLarge`]
658/// error if `reader` yields more than [`MAX_FILE_BYTES`].
659///
660/// # Errors
661/// See [`WorktreeError`].
662fn 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, // 0 = content-defined (FastCDC) per SPEC-OBJECTS §7
682        chunks,
683    });
684    let manifest_bytes = serialize::serialize(&manifest)?;
685    Ok(sink.put(&manifest_bytes)?)
686}
687
688/// Store a regular file's bytes as the canonical object and return its
689/// content-address.
690///
691/// This is the single source of truth for how file content maps to an
692/// object hash, shared by [`hash_file`], [`build_tree`], and `mkit add`
693/// so all three agree on the representation:
694///
695/// - At or below [`CHUNK_THRESHOLD`]: a single
696///   [`Blob`](crate::object::Blob).
697/// - Above the threshold: `FastCdc::v1` chunks, each stored as a `Blob`,
698///   addressed by a [`ChunkedBlob`] manifest.
699///
700/// # Errors
701/// See [`WorktreeError`].
702pub 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        // Zero-copy: the canonical Blob bytes are `prologue ‖ data`
705        // (pinned to serialize() by proptest), so the sink can hash and
706        // write straight from the source buffer.
707        let prologue = serialize::blob_prologue(data.len())?;
708        return Ok(sink.put_parts(&[&prologue, data])?);
709    }
710
711    // Large file: split with FastCDC v1 via the public ChunkIterator,
712    // store each chunk as a Blob, and assemble a ChunkedBlob manifest.
713    // Per-manifest chunk count is bounded by serialize::MAX_CHUNKS
714    // (1_000_000); MAX_FILE_BYTES (1 GiB) ÷ FastCDC MIN_SIZE (16 KiB)
715    // = ~65k, well under the cap.
716    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, // 0 = content-defined (FastCDC) per SPEC-OBJECTS §7
728        chunks,
729    });
730    let manifest_bytes = serialize::serialize(&manifest)?;
731    Ok(sink.put(&manifest_bytes)?)
732}
733
734/// Build the canonical [`ChunkedBlob`] manifest for `data`: split with
735/// [`FastCdc::v1`], address each chunk by its **Blob object id** — the hash of
736/// the chunk's canonical Blob bytes (`blob_prologue ‖ chunk`), i.e. the id the
737/// chunk Blob is stored under — and set `chunk_size = 0` (content-defined).
738///
739/// This is the single source of the manifest recipe: [`hash_file_object`]
740/// (read-only change detection) and the `mkit-wasm` encoder both build through
741/// it, and [`store_file_object`] writes the chunk Blobs under these same ids,
742/// so every path agrees on the manifest — and thus on its merkle root.
743///
744/// # Errors
745/// [`WorktreeError::Object`] if a chunk length exceeds the wire-format cap.
746pub 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            // Blob object id = BLAKE3(blob_prologue ‖ chunk) — the id
751            // store_file_object writes this chunk Blob under (the prologue ‖
752            // payload split is pinned to serialize(Blob) by proptest).
753            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
767/// Content-address `data` exactly as [`store_file_object`] would,
768/// **without storing anything**. Backs change detection (`status`, `rm`,
769/// restore safety checks) where only the answer "would this file hash to X?"
770/// is needed — writing objects there would turn a read-only query into store
771/// mutation. Equivalence with `store_file_object` is pinned by test.
772///
773/// # Errors
774/// [`WorktreeError::Object`] if a length exceeds the wire-format cap.
775pub 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    // A ChunkedBlob is addressed by its merkle BMT root, matching what the
784    // sink stores it under. This read-only mirror must agree with the write
785    // path or change detection breaks.
786    Ok(crate::merkle::compute_chunked_id(&chunked_blob_from_bytes(
787        data,
788    )?))
789}
790
791/// A file's mtime as nanoseconds since the Unix epoch, saturating; `0`
792/// (the "no cache" sentinel) when the mtime is unavailable or predates
793/// the epoch.
794#[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/// The full stat-cache observation for `meta`, in index-entry field
803/// order: `(mtime_ns, size, ino, ctime_ns)`. The single producer-side
804/// dual of [`stat_matches`] — every site that records the cache uses
805/// this so the recorded and compared field sets can never drift.
806/// `ino`/`ctime_ns` are 0 (= don't check) on platforms without them.
807#[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/// True iff `meta` proves the worktree file behind `entry` is
825/// byte-identical to `entry.object_hash` without reading it: the cached
826/// mtime is nonzero (cache present, not racy-smudged) and equal, the
827/// size is equal, the inode and ctime match when recorded (catching
828/// replace-by-rename and `touch -r`-style timestamp restoration —
829/// ctime cannot be set from userspace), and the live mode's exec class
830/// matches the staged status. Symlink entries never stat-match — the
831/// target re-read is cheap and `meta` semantics differ.
832#[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    // ino/ctime: compare only when both sides have a value — a v2 entry
843    // recorded on a platform without them (0) stays usable elsewhere.
844    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        // On non-unix the exec bit is not observable in the filesystem
852        // mode, so the recorded status is the source of truth — both
853        // classes stat-match (previously Executable entries could never
854        // match there, silently defeating the cache).
855        #[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    // Best-effort direct-symlink rejection on platforms without the
878    // Unix O_NOFOLLOW path. This does not close the swap race, but it
879    // keeps normal symlinks from being treated as regular files.
880    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        // Sorted lex: a.txt first, subdir second.
1020        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        // .mkitignore + keep.txt, but not debug.log.
1071        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        // File > CHUNK_THRESHOLD should land as a ChunkedBlob manifest
1115        // pointing at one Blob per FastCDC chunk. We pseudo-randomize
1116        // the buffer so FastCDC sees real boundary candidates instead
1117        // of running the entire file as one max-sized chunk.
1118        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            // splitmix64-ish; same construction as the gear table seed.
1125            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        // Every chunk hash must resolve to a Blob in the store, and
1150        // the concatenation must reproduce the original file bytes.
1151        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        // The streaming disk path (issue #828) must be content-address
1164        // equivalent to the original whole-buffer path for the same
1165        // bytes — same test data/PRNG shape as `large_file_becomes_chunked_blob`.
1166        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        // And the objects it actually wrote must round-trip.
1195        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    // ---- build_tree_from_index — the staging-area path -------------
1209
1210    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        // Insert out-of-order; the on-disk Tree must still be sorted
1368        // (SPEC-OBJECTS §4 normative).
1369        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        // ".mkit" is rejected by TreeEntry::validate_name as repo
1446        // metadata aliasing.
1447        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    /// The most important invariant: for a worktree whose contents
1461    /// match the index entry-for-entry, `build_tree` and
1462    /// `build_tree_from_index` MUST produce the identical root hash.
1463    /// If this drifts, attestations signed under one path won't
1464    /// verify against trees built under the other.
1465    #[test]
1466    fn from_index_matches_build_tree_for_equivalent_worktree() {
1467        let (_sd, store) = fresh_store();
1468
1469        // Build the same content two ways:
1470        //   1. drop files on disk, call build_tree.
1471        //   2. write blobs to the store directly, populate an index,
1472        //      call build_tree_from_index.
1473        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        // Walk down to the leaf.
1541        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    /// Path-collision: an index that stakes the same name as both a
1558    /// blob and a directory MUST be rejected. Without the check the
1559    /// builder would happily emit two `TreeEntries` with name `a`
1560    /// (one Blob, one Tree), which the deserializer rejects under
1561    /// its strict ascending-name rule. We catch it earlier with a
1562    /// clearer error so the user knows which path needs unstaging.
1563    /// (Reviewer finding 2 on PR #103.)
1564    #[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    /// Same collision in the opposite stage order: subdir entry
1596    /// staged first, then a blob at the parent.
1597    #[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    /// All-Removed index → empty root tree, NOT an error.
1683    /// (Reviewer finding 1 on PR #103.) `staged_count()` excludes
1684    /// Removed entries by design; the tree builder does too. The
1685    /// resulting empty tree is a valid commit target — applying a
1686    /// removals-only changeset to a tree that previously contained
1687    /// those paths produces an empty root.
1688    #[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    /// Sanity: `ObjectType::Tree` is what we materialise. Pin so a
1709    /// future enum reshuffle catches us.
1710    #[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    /// A file entry whose object is a `ChunkedBlob` (the canonical
1763    /// representation for > `CHUNK_THRESHOLD` content) is accepted by the
1764    /// commit/index tree builder, NOT rejected as "non-blob" (#203). The
1765    /// resulting tree carries an `EntryMode::Blob` pointing at the
1766    /// manifest, exactly as `build_tree` produces for a large worktree
1767    /// file.
1768    #[test]
1769    fn from_index_accepts_chunked_blob_for_file_entry() {
1770        let (_sd, store) = fresh_store();
1771        // Build a > CHUNK_THRESHOLD file's content and store it via the
1772        // shared object path (lands as a ChunkedBlob).
1773        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        // Reassembly via the shared helper round-trips the source bytes.
1812        assert_eq!(read_blob(&store, &chunked_hash).unwrap(), big);
1813    }
1814
1815    /// SPEC-OBJECTS §7: "The concatenated length MUST equal `total_size`."
1816    /// A manifest with valid chunks but a forged `total_size` (stored under
1817    /// its real merkle id — the forgery changes the id, not its validity as
1818    /// a store entry) must fail reassembly, not silently return
1819    /// wrong-length content.
1820    #[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    /// A symlink entry MUST still address a single `Blob` (its target
1850    /// path); a `ChunkedBlob` under a symlink entry is rejected.
1851    #[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    // ---- batched / zero-copy ingest ----------------------------------
1872
1873    /// Same chunked input through a batch and through the plain store
1874    /// must produce the identical manifest hash and identical readable
1875    /// bytes — this transitively pins the zero-copy `put_parts` chunk
1876    /// path to the golden `ChunkedBlob` vectors.
1877    #[test]
1878    fn store_file_object_via_batch_equals_via_store() {
1879        // 3 MiB of varied bytes: above CHUNK_THRESHOLD, multiple chunks.
1880        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        // Small (single-blob) shape too.
1899        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    /// Committing a staged index must cost exactly one full flush no
1908    /// matter how many tree objects it materialises.
1909    #[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        // Stage 20 files across nested dirs (many tree objects).
1917        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        // Equivalence: the per-object path yields the same root hash.
1951        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    /// A staged object corrupted after `add` must NOT be publishable: the
1962    /// verifying path (commit and friends) rejects it, while the cheap
1963    /// non-verifying path (status/diff snapshots) still accepts the shape.
1964    #[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        // Clean object: both paths succeed and agree.
1985        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        // Corrupt a payload byte past the 6-byte prologue (the prologue
1989        // shape stays valid, so only a re-hash can catch it).
1990        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        // Verifying path refuses to publish the corrupt object…
1997        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        // …but the cheap snapshot path still passes the prologue shape check.
2002        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    // ---- pure hashing + stat cache ------------------------------------
2009
2010    /// `hash_file_object` must agree with `store_file_object` on every
2011    /// input shape (single blob, chunked) without touching any store.
2012    #[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]; // chunked shape
2030        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        // Zero mtime sentinel: never matches.
2059        let mut zeroed = entry.clone();
2060        zeroed.mtime_ns = 0;
2061        assert!(!stat_matches(&zeroed, &meta), "zero sentinel must re-hash");
2062
2063        // Size mismatch.
2064        let mut wrong_size = entry.clone();
2065        wrong_size.size += 1;
2066        assert!(!stat_matches(&wrong_size, &meta));
2067
2068        // Mtime mismatch.
2069        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        // chmod +x without touching content, then restore the mtime so
2093        // ONLY the mode differs — the exec-class check must still fire.
2094        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    /// The killer observable: a stat-matched file is NEVER opened. With
2110    /// the file made unreadable (chmod 000), the tree build still
2111    /// succeeds and reuses the staged hash.
2112    #[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        // Make any read attempt error out.
2134        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        // Same content hashed normally yields the identical tree.
2146        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    /// Replace-by-rename with preserved mtime+size must be caught by
2154    /// the inode check: the replacement file has a different ino.
2155    #[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        // Same-size replacement via rename with timestamps restored —
2175        // the tar -x / rsync -t / mv-of-prepared-file shape.
2176        let staging = work.path().join(".swap.new");
2177        fs::write(&staging, b"REPLACED").unwrap(); // same 8-byte size
2178        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    /// A recorded ctime that disagrees with the live one must miss —
2192    /// ctime cannot be restored from userspace, so `touch -r` after an
2193    /// in-place edit is caught even when mtime+size+ino all match.
2194    #[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; // platform without ctime — check not applicable
2203        }
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    /// The worktree walk must report hash-time observations for entries
2220    /// whose cache was absent but whose content re-hashed to the staged
2221    /// hash — and the observation must carry the fd-stat, enabling the
2222    /// status command to heal the cache soundly.
2223    #[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, // racy-smudged: forces a re-hash
2238                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    /// A stat MISMATCH must fall back to re-hashing the live content.
2266    #[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            // size deliberately wrong → mismatch → re-hash.
2279            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}