Skip to main content

mkit_core/ops/
restore.rs

1//! Restore.
2//!
3//! Materialises a stored [`Tree`](crate::object::Tree) into a target
4//! directory: writes blobs as files, recurses into subtrees as
5//! directories, creates symlinks for symlink entries, and (when
6//! `clean=true`) deletes anything in the target dir that is not in the
7//! tree (preserving `.mkit/` and `.git/`).
8//!
9//! ### Symlink invariant
10//!
11//! Like `worktree::build_tree`, this module **never follows external
12//! symlinks**. Symlinks created here always have a relative,
13//! `..`-free target — checked by [`worktree::validate_symlink_target`]
14//! before the symlink is materialised.
15//!
16//! ### Sparse checkout
17//!
18//! When `RestoreOptions.sparse_patterns` is set, only files whose
19//! computed full path (relative to the root) matches the pattern set
20//! are restored. Pattern grammar:
21//!
22//! - Lines beginning with `#` are comments. Empty lines are skipped.
23//! - Leading `!` negates the pattern.
24//! - Trailing `/` makes the pattern dir-only.
25//! - Patterns are evaluated in order; **last match wins**. No match =
26//!   excluded.
27//! - Bare names without a `/` also match against the basename of
28//!   nested files.
29
30use std::fs;
31use std::io::{self, Write};
32use std::path::{Path, PathBuf};
33use std::process;
34use std::sync::atomic::{AtomicU64, Ordering};
35
36use crate::hash::Hash;
37use crate::ignore::{self, IgnoreList};
38use crate::layout::RepoLayout;
39use crate::object::{self, EntryMode, Object, TreeEntry};
40use crate::store::{MAX_TREE_DEPTH, ObjectStore};
41use crate::worktree;
42
43const MAX_SPARSE_BYTES: u64 = 1024 * 1024;
44
45/// Chunk-count bound per `read_chunks` batch when materialising a
46/// [`Object::ChunkedBlob`] — mirrors `worktree::STREAM_HASH_BATCH` (16
47/// MiB at the current 256 KiB max chunk size) so the read side of a
48/// large-file checkout bounds in-flight memory the same way the write
49/// side already does. Public so a caller-supplied `read_chunks`'s own
50/// fan-out threshold (e.g. `mkit-cli`'s `restore_fanout`) can be capped
51/// against the same number: a threshold above this value could never be
52/// reached by any batch this module ever hands `read_chunks`, silently
53/// disabling that caller's parallel path on a high-core-count host.
54pub const RESTORE_CHUNK_BATCH: usize = 64;
55
56static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
57
58/// Errors raised by this module.
59#[derive(Debug, thiserror::Error)]
60pub enum RestoreError {
61    #[error("requested object is not a tree")]
62    NotATree,
63    #[error("requested object is not a blob or chunked-blob")]
64    NotABlob,
65    #[error("symlink target '{0}' is invalid (absolute or contains '..')")]
66    InvalidSymlinkTarget(String),
67    #[error("path '{0}' is occupied by something other than a directory")]
68    NotADirectory(PathBuf),
69    #[error("path component is not valid UTF-8")]
70    InvalidUtf8,
71    #[error("tree nesting exceeds {} levels", MAX_TREE_DEPTH)]
72    TreeTooDeep,
73    #[error(transparent)]
74    Object(#[from] object::MkitError),
75    #[error(transparent)]
76    Store(#[from] crate::store::StoreError),
77    #[error(transparent)]
78    Io(#[from] io::Error),
79    /// A `read_chunks` callback passed to
80    /// [`restore_tree_to_worktree_with`] returned a different number of
81    /// chunk buffers than the batch it was given — a contract violation
82    /// by the caller (mirrors [`crate::worktree::WorktreeError::ChunkBatchLengthMismatch`]
83    /// on the ingest side), never a normal runtime condition.
84    #[error("read_chunks callback returned {actual} buffers for a {expected}-chunk batch")]
85    ChunkBatchLengthMismatch { expected: usize, actual: usize },
86}
87
88/// Result alias.
89pub type RestoreResult<T> = Result<T, RestoreError>;
90
91/// One sparse-checkout pattern.
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct SparsePattern {
94    pub pattern: String,
95    pub negated: bool,
96    pub dir_only: bool,
97}
98
99/// Options for [`restore_tree`].
100#[derive(Debug, Clone)]
101pub struct RestoreOptions {
102    /// If `true`, delete anything in the target dir that is not in the
103    /// tree (preserving `.mkit/` and `.git/`). Default `true`.
104    pub clean: bool,
105    /// If `Some`, only restore entries whose path matches the patterns.
106    pub sparse_patterns: Option<Vec<SparsePattern>>,
107}
108
109impl Default for RestoreOptions {
110    fn default() -> Self {
111        Self {
112            clean: true,
113            sparse_patterns: None,
114        }
115    }
116}
117
118/// Parse the contents of a `.mkit/sparse-checkout` file into patterns.
119#[must_use]
120pub fn parse_sparse_patterns(content: &str) -> Vec<SparsePattern> {
121    let mut out = Vec::new();
122    for raw in content.split('\n') {
123        let line = raw.trim_end_matches(['\r', ' ']);
124        if line.is_empty() || line.starts_with('#') {
125            continue;
126        }
127        let (negated, rest) = if let Some(stripped) = line.strip_prefix('!') {
128            (true, stripped)
129        } else {
130            (false, line)
131        };
132        let (dir_only, pat) = if let Some(stripped) = rest.strip_suffix('/') {
133            (true, stripped)
134        } else {
135            (false, rest)
136        };
137        if pat.is_empty() {
138            continue;
139        }
140        out.push(SparsePattern {
141            pattern: pat.to_string(),
142            negated,
143            dir_only,
144        });
145    }
146    out
147}
148
149/// True iff `path` matches at least one (non-negated, last-match-wins)
150/// pattern in `patterns`.
151#[must_use]
152pub fn matches_sparse(patterns: &[SparsePattern], path: &str, is_dir: bool) -> bool {
153    let mut matched = false;
154    for pat in patterns {
155        if path_matches_pattern(&pat.pattern, path) {
156            if pat.dir_only && !is_dir {
157                let pat_stripped = pat.pattern.strip_suffix('/').unwrap_or(&pat.pattern);
158                if pat_stripped == path {
159                    continue;
160                }
161            }
162            matched = !pat.negated;
163        }
164    }
165    matched
166}
167
168/// True iff a directory at `dir_prefix` could contain matched
169/// descendants. Used to short-circuit recursion under sparse mode.
170#[must_use]
171pub fn could_match_descendant(patterns: &[SparsePattern], dir_prefix: &str) -> bool {
172    for pat in patterns {
173        if pat.negated {
174            continue;
175        }
176        if pat.pattern.starts_with(dir_prefix) {
177            return true;
178        }
179        if dir_prefix.starts_with(&pat.pattern) {
180            return true;
181        }
182        if !dir_prefix.is_empty()
183            && !dir_prefix.ends_with('/')
184            && pat.pattern.len() > dir_prefix.len()
185            && pat.pattern.starts_with(dir_prefix)
186            && pat.pattern.as_bytes()[dir_prefix.len()] == b'/'
187        {
188            return true;
189        }
190    }
191    false
192}
193
194/// Load patterns from `<repo_root>/.mkit/sparse-checkout`. Returns
195/// `Ok(None)` if the file does not exist or has zero patterns.
196///
197/// # Errors
198/// - [`RestoreError::Io`] for filesystem failures other than "not found".
199pub fn load_sparse_checkout(layout: &RepoLayout) -> RestoreResult<Option<Vec<SparsePattern>>> {
200    let path = layout.sparse_checkout_file();
201    let meta = match fs::metadata(&path) {
202        Ok(m) => m,
203        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
204        Err(e) => return Err(RestoreError::Io(e)),
205    };
206    if meta.len() > MAX_SPARSE_BYTES {
207        return Err(RestoreError::Io(io::Error::other(
208            "sparse-checkout too large",
209        )));
210    }
211    let raw = fs::read_to_string(&path)?;
212    let patterns = parse_sparse_patterns(&raw);
213    if patterns.is_empty() {
214        Ok(None)
215    } else {
216        Ok(Some(patterns))
217    }
218}
219
220/// Write a sparse-checkout file (one pattern per line). Atomic on the
221/// destination.
222///
223/// # Errors
224/// - [`RestoreError::Io`] for filesystem failures.
225pub fn write_sparse_checkout(layout: &RepoLayout, lines: &[&str]) -> RestoreResult<()> {
226    let mkit_dir = layout.worktree_state_dir().to_path_buf();
227    fs::create_dir_all(&mkit_dir)?;
228    let mut buf = String::new();
229    for l in lines {
230        buf.push_str(l);
231        buf.push('\n');
232    }
233    let path = layout.sparse_checkout_file();
234    crate::atomic::write_atomic(&path, buf.as_bytes(), true)?;
235    Ok(())
236}
237
238fn path_matches_pattern(pattern: &str, path: &str) -> bool {
239    let pat = pattern.strip_suffix('/').unwrap_or(pattern);
240    if pat == path {
241        return true;
242    }
243    if path.len() > pat.len() && path.starts_with(pat) && path.as_bytes()[pat.len()] == b'/' {
244        return true;
245    }
246    if !pat.contains('/')
247        && let Some(last) = path.rfind('/')
248    {
249        let basename = &path[last + 1..];
250        if basename == pat {
251            return true;
252        }
253    }
254    false
255}
256
257/// Summary of a [`restore_tree_to_worktree`] call. The counts are no
258/// longer printed by any caller (`mkit checkout` emits a git-shaped
259/// switch confirmation instead); they are retained for programmatic
260/// callers and asserted by this module's own unit tests.
261#[derive(Debug, Default, Clone, PartialEq, Eq)]
262pub struct RestoreReport {
263    /// Number of regular / executable files materialised.
264    pub files_written: u32,
265    /// Number of symlinks materialised.
266    pub symlinks_written: u32,
267    /// Number of directories created (or that already existed as dirs).
268    pub directories_created: u32,
269}
270
271/// Materialise `tree_hash` into `root` as a working tree.
272///
273/// Thin wrapper around [`restore_tree`] that additionally:
274/// 1. Loads `<root>/.gitignore` + `<root>/.mkitignore`. Ignore rules do NOT
275///    gate which tree entries are materialised — tracked content is always
276///    written (git parity) — they only protect *untracked* worktree files
277///    (editor swapfiles, local-only build artefacts, …) from the
278///    `clean=true` sweep.
279/// 2. Returns a [`RestoreReport`] with counts of what was materialised
280///    (consumed by programmatic callers and this module's tests; not
281///    printed by the CLI).
282///
283/// Symlink safety is inherited from [`restore_tree`] /
284/// [`worktree::validate_symlink_target`]: targets are validated BEFORE
285/// the symlink is created, and any target that is absolute or contains
286/// `..` is rejected with [`RestoreError::InvalidSymlinkTarget`]. The
287/// net effect is that no symlink produced by this function can point
288/// outside `root`.
289///
290/// # Errors
291///
292/// Same variants as [`restore_tree`]. [`RestoreError::InvalidSymlinkTarget`]
293/// on a rejected target is the load-bearing "outside-of-root" check.
294pub fn restore_tree_to_worktree(
295    store: &ObjectStore,
296    tree: &Hash,
297    root: &Path,
298    opts: &RestoreOptions,
299) -> RestoreResult<RestoreReport> {
300    // Batch size 1, not `RESTORE_CHUNK_BATCH`: `sequential_read_chunks`
301    // never fans anything out, so batching it would only raise peak
302    // memory from one chunk to a full batch for zero benefit — the same
303    // regression `restore_blob` (the `restore_tree`/stash path) avoids
304    // by not batching at all. `restore_tree_to_worktree_with` below is
305    // the path real (parallel-capable) `read_chunks` callers want.
306    restore_tree_to_worktree_impl(store, tree, root, opts, 1, &sequential_read_chunks)
307}
308
309/// [`restore_tree_to_worktree`], parameterised over how a
310/// [`Object::ChunkedBlob`]'s chunk objects are read back during
311/// materialisation.
312///
313/// `read_chunks` receives up to [`RESTORE_CHUNK_BATCH`] chunk hashes at a
314/// time, in file order, and MUST return exactly one chunk's raw bytes per
315/// input hash, **in the same order** — this function only verifies the
316/// returned count against the input batch's length (surfacing a
317/// mismatch as [`RestoreError::ChunkBatchLengthMismatch`]); a same-length
318/// but reordered result is undetectable here (each chunk's *content* is
319/// separately BLAKE3-verified against its own hash inside
320/// [`ObjectStore::read_object`], but nothing at this layer ties a
321/// returned buffer back to *which* input hash it answered, so a
322/// transposed pair of same-length chunks would silently produce a
323/// byte-swapped file instead of an error). This is the exact same trust
324/// boundary [`worktree::store_large_file_streaming_with`]'s `hash_chunks`
325/// callback already documents on the write side (also length-checked
326/// only, via its own `ChunkBatchLengthMismatch`) — order is a documented
327/// caller contract on both sides, not a runtime-verified one, because
328/// verifying it here would mean re-hashing every chunk a second time
329/// after `read_chunks` already did the real, expensive read; that would
330/// undo most of the point of fanning the read out in the first place.
331/// Both of this crate's own `read_chunks` implementations satisfy the
332/// contract by construction: `sequential_read_chunks`'s plain
333/// `iter().map().collect()` and `mkit-cli`'s `rayon::par_iter().map().collect()`
334/// (the latter relies on rayon's `IndexedParallelIterator` guarantee that
335/// collecting into a `Vec` preserves input order regardless of which
336/// worker finishes a given index first) are both order-preserving, so
337/// this gap has no live exploit through any code path this crate or
338/// `mkit-cli` ships — it is a contract a *hand-written, buggy or hostile*
339/// third-party `read_chunks` could violate, same as an equally
340/// third-party `hash_chunks` already could on the write side.
341/// `mkit-core` has no thread-pool dependency of its own — it stays
342/// usable from wasm targets, which have no OS threads — so the fan-out
343/// decision lives with the caller instead of living in this function.
344/// Reading each chunk (open + integrity-verify + decode) is independent
345/// of every other chunk in a batch, so `mkit-cli`'s native
346/// checkout/clone/reset/restore paths pass a rayon-backed `read_chunks`,
347/// the read-side counterpart of `add`'s ingest-side chunk-hashing
348/// fan-out.
349///
350/// # Errors
351/// Same variants as [`restore_tree_to_worktree`], plus
352/// [`RestoreError::ChunkBatchLengthMismatch`] if `read_chunks` returns a
353/// different number of buffers than the batch it was given (see above
354/// for what this check does and does not catch).
355pub fn restore_tree_to_worktree_with<F>(
356    store: &ObjectStore,
357    tree: &Hash,
358    root: &Path,
359    opts: &RestoreOptions,
360    read_chunks: &F,
361) -> RestoreResult<RestoreReport>
362where
363    F: Fn(&ObjectStore, &[Hash]) -> RestoreResult<Vec<Vec<u8>>> + Sync,
364{
365    restore_tree_to_worktree_impl(store, tree, root, opts, RESTORE_CHUNK_BATCH, read_chunks)
366}
367
368/// Shared implementation behind [`restore_tree_to_worktree`] and
369/// [`restore_tree_to_worktree_with`]. `batch_size` is not part of either
370/// public signature — it is 1 for the sequential default (see
371/// [`restore_tree_to_worktree`]'s doc) and [`RESTORE_CHUNK_BATCH`] for a
372/// caller-supplied `read_chunks`.
373fn restore_tree_to_worktree_impl<F>(
374    store: &ObjectStore,
375    tree: &Hash,
376    root: &Path,
377    opts: &RestoreOptions,
378    batch_size: usize,
379    read_chunks: &F,
380) -> RestoreResult<RestoreReport>
381where
382    F: Fn(&ObjectStore, &[Hash]) -> RestoreResult<Vec<Vec<u8>>> + Sync,
383{
384    // Load the root-level ignore list. Missing = empty list.
385    let ignore_list = match ignore::load(root) {
386        Ok(il) => il,
387        Err(_) => IgnoreList::new(),
388    };
389    fs::create_dir_all(root)?;
390    let mut report = RestoreReport::default();
391    restore_tree_to_worktree_inner(
392        store,
393        *tree,
394        root,
395        opts,
396        "",
397        &ignore_list,
398        &mut report,
399        0,
400        batch_size,
401        read_chunks,
402    )?;
403    Ok(report)
404}
405
406#[allow(clippy::too_many_arguments)]
407fn restore_tree_to_worktree_inner<F>(
408    store: &ObjectStore,
409    tree_hash: Hash,
410    target_dir: &Path,
411    options: &RestoreOptions,
412    path_prefix: &str,
413    ignore: &IgnoreList,
414    report: &mut RestoreReport,
415    depth: usize,
416    batch_size: usize,
417    read_chunks: &F,
418) -> RestoreResult<()>
419where
420    F: Fn(&ObjectStore, &[Hash]) -> RestoreResult<Vec<Vec<u8>>> + Sync,
421{
422    if depth > MAX_TREE_DEPTH {
423        return Err(RestoreError::TreeTooDeep);
424    }
425    let obj = store.read_object(&tree_hash)?;
426    let Object::Tree(tree) = obj else {
427        return Err(RestoreError::NotATree);
428    };
429
430    if options.clean {
431        clean_directory(
432            target_dir,
433            &tree.entries,
434            options.sparse_patterns.as_deref(),
435            path_prefix,
436            Some(ignore),
437        )?;
438    }
439
440    for entry in &tree.entries {
441        if !crate::object::TreeEntry::validate_name(&entry.name) {
442            continue;
443        }
444        let name = std::str::from_utf8(&entry.name).map_err(|_| RestoreError::InvalidUtf8)?;
445        let full_path = if path_prefix.is_empty() {
446            name.to_string()
447        } else {
448            format!("{path_prefix}/{name}")
449        };
450        // NOTE: ignore rules do NOT gate materialization. Tree entries are
451        // tracked content and must always be written (git parity — skipping
452        // them would desync the index from the worktree). Ignore rules only
453        // protect *untracked* worktree files during the ignore-aware
454        // `clean_directory` sweep below.
455        match entry.mode {
456            EntryMode::Blob | EntryMode::Executable => {
457                if let Some(patterns) = options.sparse_patterns.as_deref()
458                    && !matches_sparse(patterns, &full_path, false)
459                {
460                    continue;
461                }
462                restore_blob_with(
463                    store,
464                    target_dir,
465                    name,
466                    entry.object_hash,
467                    entry.mode == EntryMode::Executable,
468                    batch_size,
469                    read_chunks,
470                )?;
471                report.files_written += 1;
472            }
473            EntryMode::Tree => {
474                if let Some(patterns) = options.sparse_patterns.as_deref()
475                    && !could_match_descendant(patterns, &full_path)
476                {
477                    continue;
478                }
479                ensure_directory(target_dir, name)?;
480                report.directories_created += 1;
481                let dir_path = target_dir.join(name);
482                let dir_meta = fs::symlink_metadata(&dir_path)?;
483                if !dir_meta.is_dir() {
484                    return Err(RestoreError::NotADirectory(dir_path));
485                }
486                restore_tree_to_worktree_inner(
487                    store,
488                    entry.object_hash,
489                    &dir_path,
490                    options,
491                    &full_path,
492                    ignore,
493                    report,
494                    depth + 1,
495                    batch_size,
496                    read_chunks,
497                )?;
498            }
499            EntryMode::Symlink => {
500                if let Some(patterns) = options.sparse_patterns.as_deref()
501                    && !matches_sparse(patterns, &full_path, false)
502                {
503                    continue;
504                }
505                restore_symlink(store, target_dir, name, entry.object_hash)?;
506                report.symlinks_written += 1;
507            }
508        }
509    }
510    Ok(())
511}
512
513/// Materialise `tree_hash` into `target_dir`. See module docs for the
514/// invariants.
515///
516/// Unlike [`restore_tree_to_worktree`]/[`restore_tree_to_worktree_with`],
517/// this has no `_with` counterpart — `stash` (this function's only
518/// caller) restores a snapshot on `push`/`pop`/`apply`, not the
519/// large-file checkout/clone/reset/restore path the perf work behind
520/// `restore_tree_to_worktree_with` targeted, so its `ChunkedBlob` chunks
521/// are always read sequentially via the plain `restore_blob`. Giving
522/// `stash` the same rayon fan-out would mean threading a `read_chunks`
523/// callback through `ops::stash`'s own public API (`save`/`pop`/`apply`)
524/// and `mkit-cli`'s stash command — a separate, larger change, not a
525/// side effect of this one.
526///
527/// # Errors
528/// - [`RestoreError::NotATree`] if `tree_hash` is not a tree object.
529/// - [`RestoreError::InvalidSymlinkTarget`] if a symlink entry's target
530///   is absolute or contains `..`.
531pub fn restore_tree(
532    store: &ObjectStore,
533    tree_hash: Hash,
534    target_dir: &Path,
535    options: &RestoreOptions,
536) -> RestoreResult<()> {
537    fs::create_dir_all(target_dir)?;
538    restore_tree_inner(store, tree_hash, target_dir, options, "")
539}
540
541fn restore_tree_inner(
542    store: &ObjectStore,
543    tree_hash: Hash,
544    target_dir: &Path,
545    options: &RestoreOptions,
546    path_prefix: &str,
547) -> RestoreResult<()> {
548    let obj = store.read_object(&tree_hash)?;
549    let Object::Tree(tree) = obj else {
550        return Err(RestoreError::NotATree);
551    };
552
553    if options.clean {
554        clean_directory(
555            target_dir,
556            &tree.entries,
557            options.sparse_patterns.as_deref(),
558            path_prefix,
559            None,
560        )?;
561    }
562
563    for entry in &tree.entries {
564        if !crate::object::TreeEntry::validate_name(&entry.name) {
565            continue;
566        }
567        let name = std::str::from_utf8(&entry.name).map_err(|_| RestoreError::InvalidUtf8)?;
568        let full_path = if path_prefix.is_empty() {
569            name.to_string()
570        } else {
571            format!("{path_prefix}/{name}")
572        };
573        match entry.mode {
574            EntryMode::Blob | EntryMode::Executable => {
575                if let Some(patterns) = options.sparse_patterns.as_deref()
576                    && !matches_sparse(patterns, &full_path, false)
577                {
578                    continue;
579                }
580                restore_blob(
581                    store,
582                    target_dir,
583                    name,
584                    entry.object_hash,
585                    entry.mode == EntryMode::Executable,
586                )?;
587            }
588            EntryMode::Tree => {
589                if let Some(patterns) = options.sparse_patterns.as_deref()
590                    && !could_match_descendant(patterns, &full_path)
591                {
592                    continue;
593                }
594                ensure_directory(target_dir, name)?;
595                let dir_path = target_dir.join(name);
596                // Refuse to follow a symlink that took the place of the dir.
597                let dir_meta = fs::symlink_metadata(&dir_path)?;
598                if !dir_meta.is_dir() {
599                    return Err(RestoreError::NotADirectory(dir_path));
600                }
601                restore_tree_inner(store, entry.object_hash, &dir_path, options, &full_path)?;
602            }
603            EntryMode::Symlink => {
604                if let Some(patterns) = options.sparse_patterns.as_deref()
605                    && !matches_sparse(patterns, &full_path, false)
606                {
607                    continue;
608                }
609                restore_symlink(store, target_dir, name, entry.object_hash)?;
610            }
611        }
612    }
613    Ok(())
614}
615
616/// Sequential blob restore: reads each chunk of a [`Object::ChunkedBlob`]
617/// one at a time on the calling thread, straight into the open tmp file.
618/// Used by [`restore_tree`] (the non-worktree, `stash`-facing path,
619/// which has no parallel-capable `read_chunks` to offer). Kept as its
620/// own true one-chunk-at-a-time loop rather than delegating to
621/// [`restore_blob_with`] with [`sequential_read_chunks`]: batching
622/// chunks before writing them (as `restore_blob_with` does, to give a
623/// parallel `read_chunks` something to fan out) would raise this path's
624/// peak memory from one chunk (≤256 KiB) to a full [`RESTORE_CHUNK_BATCH`]
625/// batch (≤16 MiB) for no benefit, regressing issue #828's original
626/// "reassemble without ever holding more than one chunk" guarantee on a
627/// path that can never use the extra batch size for anything.
628fn restore_blob(
629    store: &ObjectStore,
630    dir: &Path,
631    name: &str,
632    blob_hash: Hash,
633    executable: bool,
634) -> RestoreResult<()> {
635    let obj = store.read_object(&blob_hash)?;
636    match obj {
637        Object::Blob(b) => write_file_atomic(dir, name, &b.data, executable)?,
638        Object::ChunkedBlob(cb) => {
639            let (tmp_path, final_path, mut tmp) = create_tmp_for_write(dir, name)?;
640            let mut written: u64 = 0;
641            for ch in &cb.chunks {
642                let buf = read_chunk_bytes(store, ch)?;
643                tmp.write_all(&buf)?;
644                written += buf.len() as u64;
645            }
646            cb.check_reassembled_size(usize::try_from(written).unwrap_or(usize::MAX))?;
647            #[cfg(not(target_arch = "wasm32"))] // wasm File has no destructor
648            drop(tmp);
649            finish_atomic_write(&tmp_path, &final_path, executable)?;
650        }
651        _ => return Err(RestoreError::NotABlob),
652    }
653    Ok(())
654}
655
656/// Materialise `blob_hash` (a [`Object::Blob`] or [`Object::ChunkedBlob`])
657/// as `dir/name`. For a `ChunkedBlob`, `read_chunks` reads back each
658/// batch of up to `batch_size` chunk hashes (at most [`RESTORE_CHUNK_BATCH`]
659/// — see [`restore_tree_to_worktree_with`]'s doc for the callback
660/// contract; `batch_size` itself is `restore_tree_to_worktree_impl`'s
661/// internal knob, 1 for the sequential default or `RESTORE_CHUNK_BATCH`
662/// for a caller-supplied `read_chunks`, never part of either public
663/// entry point's own signature).
664///
665/// # Panics
666/// If `batch_size` is 0 (`[T]::chunks` itself panics on that) — never
667/// true for either internal caller.
668fn restore_blob_with<F>(
669    store: &ObjectStore,
670    dir: &Path,
671    name: &str,
672    blob_hash: Hash,
673    executable: bool,
674    batch_size: usize,
675    read_chunks: &F,
676) -> RestoreResult<()>
677where
678    F: Fn(&ObjectStore, &[Hash]) -> RestoreResult<Vec<Vec<u8>>> + Sync,
679{
680    let obj = store.read_object(&blob_hash)?;
681    match obj {
682        Object::Blob(b) => write_file_atomic(dir, name, &b.data, executable)?,
683        Object::ChunkedBlob(cb) => {
684            // Stream each batch straight to the open tmp file instead of
685            // concatenating the whole reassembled file into memory first
686            // (issue #828): peak memory is one `batch_size`-chunk batch
687            // (bounded, see this function's doc), not the file's total
688            // size. Batching (rather than one chunk at a time) lets a
689            // parallel-capable `read_chunks` fan a batch's independent
690            // reads out across threads — see this function's and
691            // `restore_tree_to_worktree_with`'s docs.
692            let (tmp_path, final_path, mut tmp) = create_tmp_for_write(dir, name)?;
693            let mut written: u64 = 0;
694            for batch in cb.chunks.chunks(batch_size) {
695                let bufs = read_chunks(store, batch)?;
696                if bufs.len() != batch.len() {
697                    return Err(RestoreError::ChunkBatchLengthMismatch {
698                        expected: batch.len(),
699                        actual: bufs.len(),
700                    });
701                }
702                for buf in &bufs {
703                    tmp.write_all(buf)?;
704                    written += buf.len() as u64;
705                }
706            }
707            cb.check_reassembled_size(usize::try_from(written).unwrap_or(usize::MAX))?;
708            #[cfg(not(target_arch = "wasm32"))] // wasm File has no destructor
709            drop(tmp);
710            finish_atomic_write(&tmp_path, &final_path, executable)?;
711        }
712        _ => return Err(RestoreError::NotABlob),
713    }
714    Ok(())
715}
716
717/// Default `read_chunks`: reads and type-checks each chunk hash in
718/// `hashes` one at a time via [`ObjectStore::read_object`] (which
719/// integrity-verifies the bytes against the hash).
720fn sequential_read_chunks(store: &ObjectStore, hashes: &[Hash]) -> RestoreResult<Vec<Vec<u8>>> {
721    hashes.iter().map(|h| read_chunk_bytes(store, h)).collect()
722}
723
724fn read_chunk_bytes(store: &ObjectStore, h: &Hash) -> RestoreResult<Vec<u8>> {
725    match store.read_object(h)? {
726        Object::Blob(b) => Ok(b.data),
727        _ => Err(RestoreError::NotABlob),
728    }
729}
730
731fn restore_symlink(
732    store: &ObjectStore,
733    dir: &Path,
734    name: &str,
735    blob_hash: Hash,
736) -> RestoreResult<()> {
737    let obj = store.read_object(&blob_hash)?;
738    let Object::Blob(b) = obj else {
739        return Err(RestoreError::NotABlob);
740    };
741    let target = std::str::from_utf8(&b.data).map_err(|_| RestoreError::InvalidUtf8)?;
742    if !worktree::validate_symlink_target(target) {
743        return Err(RestoreError::InvalidSymlinkTarget(target.to_string()));
744    }
745    let tmp_name = make_tmp_sibling_name(name);
746    let tmp_path = dir.join(&tmp_name);
747    let final_path = dir.join(name);
748    let _ = fs::remove_file(&tmp_path);
749    create_symlink(target, &tmp_path)?;
750    prepare_path_for_rename(&final_path)?;
751    fs::rename(&tmp_path, &final_path)?;
752    Ok(())
753}
754
755#[cfg(unix)]
756fn create_symlink(target: &str, link: &Path) -> io::Result<()> {
757    std::os::unix::fs::symlink(target, link)
758}
759
760#[cfg(windows)]
761fn create_symlink(target: &str, link: &Path) -> io::Result<()> {
762    // Symlinks on Windows require either Developer Mode or admin
763    // privileges. We pick `symlink_file` because every blob is a file
764    // when materialised through this code path.
765    std::os::windows::fs::symlink_file(target, link)
766}
767
768#[cfg(not(any(unix, windows)))]
769fn create_symlink(_target: &str, _link: &Path) -> io::Result<()> {
770    // Targets without a filesystem (notably `wasm32-unknown-unknown`)
771    // cannot materialise symlinks. The demo wasm crate does not exercise
772    // the restore path; this stub exists so the crate still compiles.
773    Err(io::Error::new(
774        io::ErrorKind::Unsupported,
775        "symlink creation is not supported on this target",
776    ))
777}
778
779/// Write a restored worktree file via tmp + rename so concurrent
780/// readers never observe a torn file.
781///
782/// Deliberately NOT flushed: worktree contents are not part of the
783/// store's durability invariant (SPEC-OBJECTS §10.1) — the object
784/// store is the source of truth and checkout is re-runnable after a
785/// crash. Flushing every restored file made checkout O(files) full
786/// flushes (`F_FULLFSYNC` each on macOS) for no recoverable state; git
787/// likewise does not flush checked-out files.
788fn write_file_atomic(dir: &Path, name: &str, data: &[u8], executable: bool) -> io::Result<()> {
789    let (tmp_path, final_path, mut tmp) = create_tmp_for_write(dir, name)?;
790    tmp.write_all(data)?;
791    #[cfg(not(target_arch = "wasm32"))] // wasm File has no destructor
792    drop(tmp);
793    finish_atomic_write(&tmp_path, &final_path, executable)
794}
795
796/// Open a fresh tmp file beside `name`, removing any stale tmp file a
797/// prior aborted attempt at the same name left behind. Returns the tmp
798/// path, final path, and the open handle so a caller can write
799/// incrementally (see [`restore_blob`]'s `ChunkedBlob` arm) instead of
800/// buffering the whole file first — pair with [`finish_atomic_write`]
801/// to apply the executable bit and atomically rename into place.
802fn create_tmp_for_write(dir: &Path, name: &str) -> io::Result<(PathBuf, PathBuf, fs::File)> {
803    let tmp_name = make_tmp_sibling_name(name);
804    let tmp_path = dir.join(&tmp_name);
805    let final_path = dir.join(name);
806    let _ = fs::remove_file(&tmp_path);
807    let tmp = fs::File::create(&tmp_path)?;
808    Ok((tmp_path, final_path, tmp))
809}
810
811/// Apply the executable bit (if requested) and atomically rename the
812/// tmp file from [`create_tmp_for_write`] into its final path. The tmp
813/// file's handle must already be closed/dropped before calling this
814/// (renaming an open handle is unreliable on some platforms).
815fn finish_atomic_write(tmp_path: &Path, final_path: &Path, executable: bool) -> io::Result<()> {
816    if executable {
817        apply_executable_bit(tmp_path)?;
818    }
819    prepare_path_for_rename(final_path)?;
820    fs::rename(tmp_path, final_path)?;
821    Ok(())
822}
823
824#[cfg(unix)]
825fn apply_executable_bit(path: &Path) -> io::Result<()> {
826    use std::os::unix::fs::PermissionsExt;
827    let mut perm = fs::metadata(path)?.permissions();
828    perm.set_mode(0o755);
829    fs::set_permissions(path, perm)
830}
831
832#[cfg(not(unix))]
833#[allow(clippy::unnecessary_wraps)]
834fn apply_executable_bit(_path: &Path) -> io::Result<()> {
835    Ok(())
836}
837
838fn ensure_directory(parent: &Path, name: &str) -> io::Result<()> {
839    let path = parent.join(name);
840    match fs::symlink_metadata(&path) {
841        Ok(meta) if meta.is_dir() => return Ok(()),
842        Ok(_) => fs::remove_file(&path)?,
843        Err(e) if e.kind() == io::ErrorKind::NotFound => {}
844        Err(e) => return Err(e),
845    }
846    match fs::create_dir_all(&path) {
847        Ok(()) => Ok(()),
848        Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
849            let meta = fs::symlink_metadata(&path)?;
850            if meta.is_dir() {
851                Ok(())
852            } else {
853                Err(io::Error::new(
854                    io::ErrorKind::AlreadyExists,
855                    "expected directory",
856                ))
857            }
858        }
859        Err(e) => Err(e),
860    }
861}
862
863fn prepare_path_for_rename(final_path: &Path) -> io::Result<()> {
864    match fs::symlink_metadata(final_path) {
865        Ok(meta) if meta.is_dir() => fs::remove_dir_all(final_path),
866        Ok(_) => Ok(()),
867        Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
868        Err(e) => Err(e),
869    }
870}
871
872fn make_tmp_sibling_name(name: &str) -> String {
873    let pid = process::id();
874    let counter = TMP_COUNTER.fetch_add(1, Ordering::Relaxed);
875    format!(".{name}.tmp.{pid}.{counter}")
876}
877
878/// Sweep `target_dir` of untracked entries before re-materialising a
879/// tree. Entries present in `tree_entries`, the repo-metadata dirs
880/// (`.mkit`/`.git`, ASCII case-insensitive so case-insensitive
881/// filesystems can't smuggle a `.MKIT`/`.Git` entry past the sweep — Git
882/// CVE-2021-21300 family), and `.mkitignore` are always preserved.
883///
884/// When `ignore` is `Some`, this is the worktree-checkout path: entries
885/// matching the ignore list are additionally preserved (ancestor-aware,
886/// so an untracked file *under* an ignored directory survives too), and
887/// `.gitignore` is preserved as well.
888fn clean_directory(
889    target_dir: &Path,
890    tree_entries: &[TreeEntry],
891    sparse_patterns: Option<&[SparsePattern]>,
892    path_prefix: &str,
893    ignore: Option<&IgnoreList>,
894) -> RestoreResult<()> {
895    struct CleanItem {
896        name: String,
897        is_dir: bool,
898    }
899    let mut to_delete: Vec<CleanItem> = Vec::new();
900
901    let read = match fs::read_dir(target_dir) {
902        Ok(r) => r,
903        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
904        Err(e) => return Err(RestoreError::Io(e)),
905    };
906    for entry in read {
907        let entry = entry?;
908        let file_name = entry.file_name();
909        let name_str = file_name
910            .to_str()
911            .ok_or(RestoreError::InvalidUtf8)?
912            .to_string();
913        if name_str.eq_ignore_ascii_case(".mkit") || name_str.eq_ignore_ascii_case(".git") {
914            continue;
915        }
916        if name_str == ".mkitignore" {
917            continue;
918        }
919        // The ignore-aware checkout path also preserves `.gitignore`.
920        if ignore.is_some() && name_str == ".gitignore" {
921            continue;
922        }
923        let mut found = false;
924        for te in tree_entries {
925            if te.name.as_slice() == name_str.as_bytes() {
926                found = true;
927                break;
928            }
929        }
930        if found {
931            continue;
932        }
933        let meta = entry.metadata()?;
934        let is_dir = meta.is_dir();
935        let full_path = if path_prefix.is_empty() {
936            name_str.clone()
937        } else {
938            format!("{path_prefix}/{name_str}")
939        };
940        // Respect ignore rules — don't touch locally-ignored files. Use
941        // ancestor-aware matching so an untracked file *under* an ignored
942        // directory is preserved too (the safety gate exempts it, so the
943        // sweep must not delete it).
944        if let Some(ignore) = ignore
945            && ignore.is_ignored_with_ancestors(&full_path, is_dir)
946        {
947            continue;
948        }
949        if let Some(patterns) = sparse_patterns {
950            let allow = matches_sparse(patterns, &full_path, is_dir)
951                || (is_dir && could_match_descendant(patterns, &full_path));
952            if !allow {
953                continue;
954            }
955        }
956        to_delete.push(CleanItem {
957            name: name_str,
958            is_dir,
959        });
960    }
961
962    for item in to_delete {
963        let path = target_dir.join(&item.name);
964        if item.is_dir {
965            let _ = fs::remove_dir_all(&path);
966        } else {
967            let _ = fs::remove_file(&path);
968        }
969    }
970    Ok(())
971}
972
973#[cfg(test)]
974mod tests {
975    use super::*;
976    use crate::object::{Tree, TreeEntry};
977    use crate::serialize;
978    use tempfile::TempDir;
979
980    fn fresh_store() -> (TempDir, ObjectStore) {
981        let dir = TempDir::new().unwrap();
982        let store = ObjectStore::init(&RepoLayout::single(dir.path())).unwrap();
983        (dir, store)
984    }
985
986    fn put_blob(store: &ObjectStore, data: &[u8]) -> Hash {
987        let bytes = serialize::serialize(&Object::Blob(crate::object::Blob {
988            data: data.to_vec(),
989        }))
990        .unwrap();
991        store.write(&bytes).unwrap()
992    }
993
994    fn put_tree_with(store: &ObjectStore, entries: Vec<TreeEntry>) -> Hash {
995        let bytes = serialize::serialize(&Object::Tree(Tree { entries })).unwrap();
996        store.write(&bytes).unwrap()
997    }
998
999    #[test]
1000    fn parse_sparse_basic() {
1001        let content = "# comment line\nsrc\n!tests\ndocs/\n\nREADME.md\n";
1002        let p = parse_sparse_patterns(content);
1003        assert_eq!(p.len(), 4);
1004        assert_eq!(p[0].pattern, "src");
1005        assert!(!p[0].negated);
1006        assert!(!p[0].dir_only);
1007        assert_eq!(p[1].pattern, "tests");
1008        assert!(p[1].negated);
1009        assert_eq!(p[2].pattern, "docs");
1010        assert!(p[2].dir_only);
1011        assert_eq!(p[3].pattern, "README.md");
1012    }
1013
1014    #[test]
1015    fn matches_sparse_exact_and_prefix() {
1016        let p = vec![SparsePattern {
1017            pattern: "src".to_string(),
1018            negated: false,
1019            dir_only: false,
1020        }];
1021        assert!(matches_sparse(&p, "src/main.rs", false));
1022        assert!(matches_sparse(&p, "src/lib/util.rs", false));
1023        assert!(!matches_sparse(&p, "tests/foo", false));
1024    }
1025
1026    #[test]
1027    fn matches_sparse_negation() {
1028        let p = vec![
1029            SparsePattern {
1030                pattern: "src".to_string(),
1031                negated: false,
1032                dir_only: false,
1033            },
1034            SparsePattern {
1035                pattern: "src/secret".to_string(),
1036                negated: true,
1037                dir_only: false,
1038            },
1039        ];
1040        assert!(matches_sparse(&p, "src/main.rs", false));
1041        assert!(!matches_sparse(&p, "src/secret/key.pem", false));
1042    }
1043
1044    #[test]
1045    fn matches_sparse_dir_only() {
1046        let p = vec![SparsePattern {
1047            pattern: "build".to_string(),
1048            negated: false,
1049            dir_only: true,
1050        }];
1051        assert!(matches_sparse(&p, "build", true));
1052        assert!(!matches_sparse(&p, "build", false));
1053    }
1054
1055    #[test]
1056    fn matches_sparse_last_match_wins() {
1057        let p = vec![
1058            SparsePattern {
1059                pattern: "src".to_string(),
1060                negated: false,
1061                dir_only: false,
1062            },
1063            SparsePattern {
1064                pattern: "src".to_string(),
1065                negated: true,
1066                dir_only: false,
1067            },
1068        ];
1069        assert!(!matches_sparse(&p, "src/main.rs", false));
1070    }
1071
1072    #[test]
1073    fn matches_sparse_bare_basename() {
1074        let p = vec![SparsePattern {
1075            pattern: "Makefile".to_string(),
1076            negated: false,
1077            dir_only: false,
1078        }];
1079        assert!(matches_sparse(&p, "Makefile", false));
1080        assert!(matches_sparse(&p, "sub/Makefile", false));
1081        assert!(!matches_sparse(&p, "Makefile.bak", false));
1082    }
1083
1084    #[test]
1085    fn could_match_descendant_basic() {
1086        let p = vec![SparsePattern {
1087            pattern: "src/lib".to_string(),
1088            negated: false,
1089            dir_only: false,
1090        }];
1091        assert!(could_match_descendant(&p, "src"));
1092        assert!(could_match_descendant(&p, "src/lib"));
1093        assert!(!could_match_descendant(&p, "tests"));
1094    }
1095
1096    #[test]
1097    fn restore_empty_tree_creates_no_files() {
1098        let (_d, store) = fresh_store();
1099        let target = TempDir::new().unwrap();
1100        let tree_h = put_tree_with(&store, vec![]);
1101        restore_tree(&store, tree_h, target.path(), &RestoreOptions::default()).unwrap();
1102        let count = fs::read_dir(target.path()).unwrap().count();
1103        assert_eq!(count, 0);
1104    }
1105
1106    #[test]
1107    fn restore_single_file() {
1108        let (_d, store) = fresh_store();
1109        let target = TempDir::new().unwrap();
1110        let blob = put_blob(&store, b"hello");
1111        let tree = put_tree_with(
1112            &store,
1113            vec![TreeEntry {
1114                name: b"file.txt".to_vec(),
1115                mode: EntryMode::Blob,
1116                object_hash: blob,
1117            }],
1118        );
1119        restore_tree(&store, tree, target.path(), &RestoreOptions::default()).unwrap();
1120        let content = fs::read(target.path().join("file.txt")).unwrap();
1121        assert_eq!(content, b"hello");
1122    }
1123
1124    #[test]
1125    fn restore_nested_directories() {
1126        let (_d, store) = fresh_store();
1127        let target = TempDir::new().unwrap();
1128        let blob = put_blob(&store, b"const main = 0;");
1129        let inner = put_tree_with(
1130            &store,
1131            vec![TreeEntry {
1132                name: b"main.rs".to_vec(),
1133                mode: EntryMode::Blob,
1134                object_hash: blob,
1135            }],
1136        );
1137        let root = put_tree_with(
1138            &store,
1139            vec![TreeEntry {
1140                name: b"src".to_vec(),
1141                mode: EntryMode::Tree,
1142                object_hash: inner,
1143            }],
1144        );
1145        restore_tree(&store, root, target.path(), &RestoreOptions::default()).unwrap();
1146        let content = fs::read(target.path().join("src/main.rs")).unwrap();
1147        assert_eq!(content, b"const main = 0;");
1148    }
1149
1150    #[test]
1151    fn restore_overwrites_existing_files() {
1152        let (_d, store) = fresh_store();
1153        let target = TempDir::new().unwrap();
1154        fs::write(target.path().join("file.txt"), b"old").unwrap();
1155        let blob = put_blob(&store, b"new");
1156        let tree = put_tree_with(
1157            &store,
1158            vec![TreeEntry {
1159                name: b"file.txt".to_vec(),
1160                mode: EntryMode::Blob,
1161                object_hash: blob,
1162            }],
1163        );
1164        restore_tree(&store, tree, target.path(), &RestoreOptions::default()).unwrap();
1165        assert_eq!(fs::read(target.path().join("file.txt")).unwrap(), b"new");
1166    }
1167
1168    #[test]
1169    fn restore_removes_untracked_when_clean() {
1170        let (_d, store) = fresh_store();
1171        let target = TempDir::new().unwrap();
1172        fs::write(target.path().join("extra.txt"), b"gone").unwrap();
1173        let blob = put_blob(&store, b"keep");
1174        let tree = put_tree_with(
1175            &store,
1176            vec![TreeEntry {
1177                name: b"tracked.txt".to_vec(),
1178                mode: EntryMode::Blob,
1179                object_hash: blob,
1180            }],
1181        );
1182        restore_tree(&store, tree, target.path(), &RestoreOptions::default()).unwrap();
1183        assert!(!target.path().join("extra.txt").exists());
1184        assert_eq!(
1185            fs::read(target.path().join("tracked.txt")).unwrap(),
1186            b"keep"
1187        );
1188    }
1189
1190    #[test]
1191    fn restore_clean_false_keeps_untracked() {
1192        let (_d, store) = fresh_store();
1193        let target = TempDir::new().unwrap();
1194        fs::write(target.path().join("extra.txt"), b"survive").unwrap();
1195        let blob = put_blob(&store, b"keep");
1196        let tree = put_tree_with(
1197            &store,
1198            vec![TreeEntry {
1199                name: b"tracked.txt".to_vec(),
1200                mode: EntryMode::Blob,
1201                object_hash: blob,
1202            }],
1203        );
1204        restore_tree(
1205            &store,
1206            tree,
1207            target.path(),
1208            &RestoreOptions {
1209                clean: false,
1210                sparse_patterns: None,
1211            },
1212        )
1213        .unwrap();
1214        assert_eq!(
1215            fs::read(target.path().join("extra.txt")).unwrap(),
1216            b"survive"
1217        );
1218    }
1219
1220    #[test]
1221    fn restore_preserves_mkit_directory() {
1222        let (_d, store) = fresh_store();
1223        let target = TempDir::new().unwrap();
1224        fs::create_dir_all(target.path().join(".mkit")).unwrap();
1225        fs::write(target.path().join(".mkit/config"), b"important").unwrap();
1226        let tree = put_tree_with(&store, vec![]);
1227        restore_tree(&store, tree, target.path(), &RestoreOptions::default()).unwrap();
1228        assert_eq!(
1229            fs::read(target.path().join(".mkit/config")).unwrap(),
1230            b"important"
1231        );
1232    }
1233
1234    #[test]
1235    fn clean_directory_preserves_case_variant_mkit_and_git() {
1236        // Regression for Git CVE-2021-21300 family: on case-insensitive
1237        // filesystems a worktree directory named `.MKIT` or `.Git` must
1238        // never be swept by `clean_directory`, otherwise a hostile tree
1239        // entry could trick restore into deleting repo metadata.
1240        let target = TempDir::new().unwrap();
1241        fs::create_dir_all(target.path().join(".MKIT")).unwrap();
1242        fs::write(target.path().join(".MKIT/config"), b"meta").unwrap();
1243        fs::create_dir_all(target.path().join(".Git")).unwrap();
1244        fs::write(target.path().join(".Git/HEAD"), b"ref").unwrap();
1245        // Empty tree — without the case-insensitive guard, everything
1246        // unknown is removed.
1247        clean_directory(target.path(), &[], None, "", None).unwrap();
1248        assert!(
1249            target.path().join(".MKIT/config").exists(),
1250            ".MKIT swept by clean_directory (case-fold bypass)"
1251        );
1252        assert!(
1253            target.path().join(".Git/HEAD").exists(),
1254            ".Git swept by clean_directory (case-fold bypass)"
1255        );
1256    }
1257
1258    #[test]
1259    fn clean_directory_with_ignore_list_preserves_case_variant_mkit_and_git() {
1260        let target = TempDir::new().unwrap();
1261        fs::create_dir_all(target.path().join(".MKIT")).unwrap();
1262        fs::write(target.path().join(".MKIT/config"), b"meta").unwrap();
1263        fs::create_dir_all(target.path().join(".GIT")).unwrap();
1264        fs::write(target.path().join(".GIT/HEAD"), b"ref").unwrap();
1265        let ignore = crate::ignore::IgnoreList::new();
1266        clean_directory(target.path(), &[], None, "", Some(&ignore)).unwrap();
1267        assert!(
1268            target.path().join(".MKIT/config").exists(),
1269            ".MKIT swept by clean_directory (ignore-aware, case-fold bypass)"
1270        );
1271        assert!(
1272            target.path().join(".GIT/HEAD").exists(),
1273            ".GIT swept by clean_directory (ignore-aware, case-fold bypass)"
1274        );
1275    }
1276
1277    #[test]
1278    fn restore_chunked_blob_reassembled() {
1279        let (_d, store) = fresh_store();
1280        let target = TempDir::new().unwrap();
1281        let c0 = put_blob(&store, b"Hello, ");
1282        let c1 = put_blob(&store, b"chunked ");
1283        let c2 = put_blob(&store, b"world!");
1284        let cb = Object::ChunkedBlob(crate::object::ChunkedBlob {
1285            total_size: 7 + 8 + 6,
1286            chunk_size: 64 * 1024,
1287            chunks: vec![c0, c1, c2],
1288        });
1289        let cb_h = store.write(&serialize::serialize(&cb).unwrap()).unwrap();
1290        let tree = put_tree_with(
1291            &store,
1292            vec![TreeEntry {
1293                name: b"out.txt".to_vec(),
1294                mode: EntryMode::Blob,
1295                object_hash: cb_h,
1296            }],
1297        );
1298        restore_tree(&store, tree, target.path(), &RestoreOptions::default()).unwrap();
1299        let content = fs::read(target.path().join("out.txt")).unwrap();
1300        assert_eq!(content, b"Hello, chunked world!");
1301    }
1302
1303    /// Mirrors `worktree`'s
1304    /// `streaming_rejects_miscounted_hashes_in_full_and_partial_batches`
1305    /// on the read side: a `read_chunks` callback that returns fewer
1306    /// buffers than its input batch must surface as
1307    /// [`RestoreError::ChunkBatchLengthMismatch`], not a panic or a
1308    /// silently short/garbled restored file.
1309    #[test]
1310    fn restore_tree_to_worktree_with_rejects_miscounted_chunks() {
1311        let (_d, store) = fresh_store();
1312        let target = TempDir::new().unwrap();
1313        let c0 = put_blob(&store, b"Hello, ");
1314        let c1 = put_blob(&store, b"chunked ");
1315        let c2 = put_blob(&store, b"world!");
1316        let cb = Object::ChunkedBlob(crate::object::ChunkedBlob {
1317            total_size: 7 + 8 + 6,
1318            chunk_size: 0,
1319            chunks: vec![c0, c1, c2],
1320        });
1321        let cb_h = store.write(&serialize::serialize(&cb).unwrap()).unwrap();
1322        let tree = put_tree_with(
1323            &store,
1324            vec![TreeEntry {
1325                name: b"out.txt".to_vec(),
1326                mode: EntryMode::Blob,
1327                object_hash: cb_h,
1328            }],
1329        );
1330        let err = restore_tree_to_worktree_with(
1331            &store,
1332            &tree,
1333            target.path(),
1334            &RestoreOptions::default(),
1335            &|_store, batch| Ok(vec![Vec::new(); batch.len() - 1]),
1336        )
1337        .unwrap_err();
1338        assert!(
1339            matches!(
1340                err,
1341                RestoreError::ChunkBatchLengthMismatch {
1342                    expected: 3,
1343                    actual: 2
1344                }
1345            ),
1346            "unexpected error: {err:?}"
1347        );
1348    }
1349
1350    /// Mirrors `worktree`'s
1351    /// `hash_file_with_metadata_with_batch_fanout_matches_sequential`: a
1352    /// `read_chunks` callback that processes a batch out of order
1353    /// internally (simulating a rayon fan-out) but returns results in
1354    /// input order — the documented contract — must still reassemble the
1355    /// exact same bytes as the sequential default. Pins the `_with`
1356    /// contract `mkit-cli`'s `restore_fanout::read_chunks_fanout` relies
1357    /// on.
1358    #[test]
1359    fn restore_tree_to_worktree_with_out_of_order_processing_matches_sequential() {
1360        let (_d, store) = fresh_store();
1361        let chunk_data: Vec<Vec<u8>> = (0..10)
1362            .map(|i| format!("chunk-{i:02}-").into_bytes())
1363            .collect();
1364        let total_size: u64 = chunk_data.iter().map(|c| c.len() as u64).sum();
1365        let chunks: Vec<Hash> = chunk_data.iter().map(|c| put_blob(&store, c)).collect();
1366        let cb = Object::ChunkedBlob(crate::object::ChunkedBlob {
1367            total_size,
1368            chunk_size: 0,
1369            chunks: chunks.clone(),
1370        });
1371        let cb_h = store.write(&serialize::serialize(&cb).unwrap()).unwrap();
1372        let tree = put_tree_with(
1373            &store,
1374            vec![TreeEntry {
1375                name: b"out.txt".to_vec(),
1376                mode: EntryMode::Blob,
1377                object_hash: cb_h,
1378            }],
1379        );
1380
1381        let sequential_target = TempDir::new().unwrap();
1382        restore_tree_to_worktree(
1383            &store,
1384            &tree,
1385            sequential_target.path(),
1386            &RestoreOptions::default(),
1387        )
1388        .unwrap();
1389        let sequential_content = fs::read(sequential_target.path().join("out.txt")).unwrap();
1390
1391        let fanout_target = TempDir::new().unwrap();
1392        restore_tree_to_worktree_with(
1393            &store,
1394            &tree,
1395            fanout_target.path(),
1396            &RestoreOptions::default(),
1397            &|store, batch| {
1398                // Read in reverse, then un-reverse before returning —
1399                // proves the contract cares about the *returned* order,
1400                // not the order chunks are actually processed in.
1401                let mut out: Vec<Vec<u8>> = batch
1402                    .iter()
1403                    .rev()
1404                    .map(|h| read_chunk_bytes(store, h))
1405                    .collect::<RestoreResult<_>>()?;
1406                out.reverse();
1407                Ok(out)
1408            },
1409        )
1410        .unwrap();
1411        let fanout_content = fs::read(fanout_target.path().join("out.txt")).unwrap();
1412
1413        assert_eq!(
1414            sequential_content, fanout_content,
1415            "an out-of-order-processing read_chunks callback must still match \
1416             the sequential default when it returns results in input order"
1417        );
1418    }
1419
1420    /// SPEC-OBJECTS §7: "The concatenated length MUST equal `total_size`."
1421    /// A manifest whose forged `total_size` disagrees with its (valid)
1422    /// chunks must fail restore instead of writing wrong-length content.
1423    #[test]
1424    fn restore_rejects_chunked_total_size_mismatch() {
1425        let (_d, store) = fresh_store();
1426        let target = TempDir::new().unwrap();
1427        let c0 = put_blob(&store, b"Hello, ");
1428        let c1 = put_blob(&store, b"world!");
1429        let cb = Object::ChunkedBlob(crate::object::ChunkedBlob {
1430            total_size: 7 + 6 + 1,
1431            chunk_size: 0,
1432            chunks: vec![c0, c1],
1433        });
1434        let cb_h = store.write(&serialize::serialize(&cb).unwrap()).unwrap();
1435        let tree = put_tree_with(
1436            &store,
1437            vec![TreeEntry {
1438                name: b"out.txt".to_vec(),
1439                mode: EntryMode::Blob,
1440                object_hash: cb_h,
1441            }],
1442        );
1443        let err =
1444            restore_tree(&store, tree, target.path(), &RestoreOptions::default()).unwrap_err();
1445        assert!(
1446            matches!(
1447                err,
1448                RestoreError::Object(object::MkitError::ChunkedBlobSizeMismatch {
1449                    expected: 14,
1450                    actual: 13,
1451                })
1452            ),
1453            "expected ChunkedBlobSizeMismatch, got {err:?}"
1454        );
1455        assert!(!target.path().join("out.txt").exists());
1456    }
1457
1458    #[cfg(unix)]
1459    #[test]
1460    fn restore_with_symlink() {
1461        let (_d, store) = fresh_store();
1462        let target = TempDir::new().unwrap();
1463        let link_target = put_blob(&store, b"target.txt");
1464        let file = put_blob(&store, b"real");
1465        let tree = put_tree_with(
1466            &store,
1467            vec![
1468                TreeEntry {
1469                    name: b"link".to_vec(),
1470                    mode: EntryMode::Symlink,
1471                    object_hash: link_target,
1472                },
1473                TreeEntry {
1474                    name: b"target.txt".to_vec(),
1475                    mode: EntryMode::Blob,
1476                    object_hash: file,
1477                },
1478            ],
1479        );
1480        restore_tree(&store, tree, target.path(), &RestoreOptions::default()).unwrap();
1481        let read = fs::read_link(target.path().join("link")).unwrap();
1482        assert_eq!(read.to_str().unwrap(), "target.txt");
1483        assert_eq!(fs::read(target.path().join("target.txt")).unwrap(), b"real");
1484    }
1485
1486    #[cfg(unix)]
1487    #[test]
1488    fn restore_rejects_invalid_symlink_targets() {
1489        let (_d, store) = fresh_store();
1490        let target = TempDir::new().unwrap();
1491        let bad = put_blob(&store, b"/etc/passwd");
1492        let tree = put_tree_with(
1493            &store,
1494            vec![TreeEntry {
1495                name: b"link".to_vec(),
1496                mode: EntryMode::Symlink,
1497                object_hash: bad,
1498            }],
1499        );
1500        let err =
1501            restore_tree(&store, tree, target.path(), &RestoreOptions::default()).unwrap_err();
1502        assert!(matches!(err, RestoreError::InvalidSymlinkTarget(_)));
1503    }
1504
1505    #[test]
1506    fn sparse_restore_only_restores_matched() {
1507        let (_d, store) = fresh_store();
1508        let target = TempDir::new().unwrap();
1509        let main = put_blob(&store, b"pub fn main(){}");
1510        let test = put_blob(&store, b"test {}");
1511        let readme = put_blob(&store, b"# Project");
1512        let src = put_tree_with(
1513            &store,
1514            vec![TreeEntry {
1515                name: b"main.rs".to_vec(),
1516                mode: EntryMode::Blob,
1517                object_hash: main,
1518            }],
1519        );
1520        let tests = put_tree_with(
1521            &store,
1522            vec![TreeEntry {
1523                name: b"test.rs".to_vec(),
1524                mode: EntryMode::Blob,
1525                object_hash: test,
1526            }],
1527        );
1528        let root = put_tree_with(
1529            &store,
1530            vec![
1531                TreeEntry {
1532                    name: b"README.md".to_vec(),
1533                    mode: EntryMode::Blob,
1534                    object_hash: readme,
1535                },
1536                TreeEntry {
1537                    name: b"src".to_vec(),
1538                    mode: EntryMode::Tree,
1539                    object_hash: src,
1540                },
1541                TreeEntry {
1542                    name: b"tests".to_vec(),
1543                    mode: EntryMode::Tree,
1544                    object_hash: tests,
1545                },
1546            ],
1547        );
1548        let opts = RestoreOptions {
1549            clean: true,
1550            sparse_patterns: Some(vec![SparsePattern {
1551                pattern: "src".to_string(),
1552                negated: false,
1553                dir_only: false,
1554            }]),
1555        };
1556        restore_tree(&store, root, target.path(), &opts).unwrap();
1557        assert!(target.path().join("src/main.rs").exists());
1558        assert!(!target.path().join("tests/test.rs").exists());
1559        assert!(!target.path().join("README.md").exists());
1560    }
1561
1562    #[test]
1563    fn sparse_restore_with_negation_excludes_subtree() {
1564        let (_d, store) = fresh_store();
1565        let target = TempDir::new().unwrap();
1566        let main = put_blob(&store, b"main");
1567        let key = put_blob(&store, b"secret");
1568        let secret_tree = put_tree_with(
1569            &store,
1570            vec![TreeEntry {
1571                name: b"key.pem".to_vec(),
1572                mode: EntryMode::Blob,
1573                object_hash: key,
1574            }],
1575        );
1576        let src = put_tree_with(
1577            &store,
1578            vec![
1579                TreeEntry {
1580                    name: b"main.rs".to_vec(),
1581                    mode: EntryMode::Blob,
1582                    object_hash: main,
1583                },
1584                TreeEntry {
1585                    name: b"secret".to_vec(),
1586                    mode: EntryMode::Tree,
1587                    object_hash: secret_tree,
1588                },
1589            ],
1590        );
1591        let root = put_tree_with(
1592            &store,
1593            vec![TreeEntry {
1594                name: b"src".to_vec(),
1595                mode: EntryMode::Tree,
1596                object_hash: src,
1597            }],
1598        );
1599        let opts = RestoreOptions {
1600            clean: true,
1601            sparse_patterns: Some(vec![
1602                SparsePattern {
1603                    pattern: "src".to_string(),
1604                    negated: false,
1605                    dir_only: false,
1606                },
1607                SparsePattern {
1608                    pattern: "src/secret".to_string(),
1609                    negated: true,
1610                    dir_only: false,
1611                },
1612            ]),
1613        };
1614        restore_tree(&store, root, target.path(), &opts).unwrap();
1615        assert!(target.path().join("src/main.rs").exists());
1616        assert!(!target.path().join("src/secret/key.pem").exists());
1617    }
1618
1619    #[test]
1620    fn sparse_checkout_roundtrip() {
1621        let target = TempDir::new().unwrap();
1622        let layout = RepoLayout::single(target.path());
1623        write_sparse_checkout(&layout, &["src", "!src/secret", "docs/"]).unwrap();
1624        let p = load_sparse_checkout(&layout).unwrap().unwrap();
1625        assert_eq!(p.len(), 3);
1626        assert_eq!(p[0].pattern, "src");
1627        assert!(p[1].negated);
1628        assert!(p[2].dir_only);
1629    }
1630
1631    #[test]
1632    fn load_sparse_checkout_returns_none_when_missing() {
1633        let target = TempDir::new().unwrap();
1634        fs::create_dir_all(target.path().join(".mkit")).unwrap();
1635        let p = load_sparse_checkout(&RepoLayout::single(target.path())).unwrap();
1636        assert!(p.is_none());
1637    }
1638
1639    // =================================================================
1640    // restore_tree_to_worktree — checkout-facing wrapper.
1641    // =================================================================
1642
1643    #[test]
1644    fn worktree_restore_counts_files_and_dirs() {
1645        let (_d, store) = fresh_store();
1646        let target = TempDir::new().unwrap();
1647        let blob_a = put_blob(&store, b"a");
1648        let blob_b = put_blob(&store, b"b");
1649        let sub = put_tree_with(
1650            &store,
1651            vec![TreeEntry {
1652                name: b"b.txt".to_vec(),
1653                mode: EntryMode::Blob,
1654                object_hash: blob_b,
1655            }],
1656        );
1657        let root = put_tree_with(
1658            &store,
1659            vec![
1660                TreeEntry {
1661                    name: b"a.txt".to_vec(),
1662                    mode: EntryMode::Blob,
1663                    object_hash: blob_a,
1664                },
1665                TreeEntry {
1666                    name: b"sub".to_vec(),
1667                    mode: EntryMode::Tree,
1668                    object_hash: sub,
1669                },
1670            ],
1671        );
1672        let report =
1673            restore_tree_to_worktree(&store, &root, target.path(), &RestoreOptions::default())
1674                .unwrap();
1675        assert_eq!(report.files_written, 2);
1676        assert_eq!(report.directories_created, 1);
1677        assert!(target.path().join("a.txt").exists());
1678        assert!(target.path().join("sub/b.txt").exists());
1679    }
1680
1681    #[test]
1682    fn worktree_restore_writes_tracked_entries_and_keeps_untracked_ignored() {
1683        let (_d, store) = fresh_store();
1684        let target = TempDir::new().unwrap();
1685        // Pre-seed an ignore file, an UNTRACKED ignored file (must survive the
1686        // clean sweep), and a tracked path that happens to match the ignore
1687        // pattern but IS in the target tree (must be written — git parity).
1688        fs::write(target.path().join(".mkitignore"), "*.tmp\nsecret.txt\n").unwrap();
1689        fs::write(target.path().join("scratch.tmp"), b"local-only").unwrap();
1690        fs::write(target.path().join("secret.txt"), b"OLD-LOCAL").unwrap();
1691        let secret_blob = put_blob(&store, b"COMMITTED-SECRET");
1692        let ok_blob = put_blob(&store, b"ok");
1693        let root = put_tree_with(
1694            &store,
1695            vec![
1696                TreeEntry {
1697                    name: b"ok.txt".to_vec(),
1698                    mode: EntryMode::Blob,
1699                    object_hash: ok_blob,
1700                },
1701                TreeEntry {
1702                    name: b"secret.txt".to_vec(),
1703                    mode: EntryMode::Blob,
1704                    object_hash: secret_blob,
1705                },
1706            ],
1707        );
1708        let report =
1709            restore_tree_to_worktree(&store, &root, target.path(), &RestoreOptions::default())
1710                .unwrap();
1711        // Both tracked entries materialise — ignore rules never gate writes.
1712        assert_eq!(report.files_written, 2);
1713        assert_eq!(
1714            fs::read(target.path().join("secret.txt")).unwrap(),
1715            b"COMMITTED-SECRET",
1716            "a tracked tree entry is written even if it matches an ignore rule"
1717        );
1718        assert_eq!(fs::read(target.path().join("ok.txt")).unwrap(), b"ok");
1719        // The UNTRACKED ignored file is preserved by the clean sweep.
1720        assert_eq!(
1721            fs::read(target.path().join("scratch.tmp")).unwrap(),
1722            b"local-only",
1723            "an untracked ignored file must survive the clean sweep"
1724        );
1725    }
1726
1727    #[test]
1728    fn worktree_restore_clean_keeps_untracked_under_ignored_dir() {
1729        // The clean sweep must not delete an untracked file that lives *under*
1730        // an ignored directory, even when that directory is part of the
1731        // target tree (so it gets recursed into).
1732        let (_d, store) = fresh_store();
1733        let target = TempDir::new().unwrap();
1734        fs::write(target.path().join(".mkitignore"), "dist/\n").unwrap();
1735        fs::create_dir(target.path().join("dist")).unwrap();
1736        fs::write(target.path().join("dist/local.tmp"), b"local").unwrap();
1737        // Target tree: dist/ (tree) holding a tracked app.js.
1738        let app_blob = put_blob(&store, b"APP");
1739        let dist_tree = put_tree_with(
1740            &store,
1741            vec![TreeEntry {
1742                name: b"app.js".to_vec(),
1743                mode: EntryMode::Blob,
1744                object_hash: app_blob,
1745            }],
1746        );
1747        let root = put_tree_with(
1748            &store,
1749            vec![TreeEntry {
1750                name: b"dist".to_vec(),
1751                mode: EntryMode::Tree,
1752                object_hash: dist_tree,
1753            }],
1754        );
1755        restore_tree_to_worktree(&store, &root, target.path(), &RestoreOptions::default()).unwrap();
1756        // Tracked content materialised...
1757        assert_eq!(fs::read(target.path().join("dist/app.js")).unwrap(), b"APP");
1758        // ...and the untracked file under the ignored dir is preserved.
1759        assert_eq!(
1760            fs::read(target.path().join("dist/local.tmp")).unwrap(),
1761            b"local",
1762            "an untracked file under an ignored dir must survive the clean sweep"
1763        );
1764    }
1765
1766    #[cfg(unix)]
1767    #[test]
1768    fn worktree_restore_rejects_escaping_symlink() {
1769        let (_d, store) = fresh_store();
1770        let target = TempDir::new().unwrap();
1771        let bad = put_blob(&store, b"../outside");
1772        let root = put_tree_with(
1773            &store,
1774            vec![TreeEntry {
1775                name: b"link".to_vec(),
1776                mode: EntryMode::Symlink,
1777                object_hash: bad,
1778            }],
1779        );
1780        let err =
1781            restore_tree_to_worktree(&store, &root, target.path(), &RestoreOptions::default())
1782                .unwrap_err();
1783        assert!(matches!(err, RestoreError::InvalidSymlinkTarget(_)));
1784    }
1785}