Skip to main content

znippy_plugin_git/
archive_file.rs

1//! **A repository's stored bytes, in ONE FILE.** `archive_out` writes it,
2//! `archive_in` puts it back.
3//!
4//! This is the file-shaped sibling of [`crate::replicate`], and it exists
5//! because that module's decisions were the right ones and its *transport* was
6//! the part nobody had settled. A file needs no transport: no socket, no
7//! protocol, no peer, no handshake — and a file can be copied to another
8//! appliance, to object storage, to a laptop or to a tape by whatever already
9//! moves files. **Nothing here opens a socket either**, for exactly the reason
10//! [`crate::replicate`] gives.
11//!
12//! Every decision below is [`crate::replicate`]'s, restated because it now has
13//! a body:
14//!
15//! * **STORE-side, one repository at a time.** The unit is one repository's
16//!   bytes between two watermarks. It lands whole or it does not land.
17//! * **`from_generation` is a WATERMARK, not a diff.** [`ArchiveWrite`] only
18//!   ever *appends* to `objects.pack`, so everything above a byte offset is new
19//!   and nothing below it has changed. That is why the parameter is one `u64`
20//!   and not a manifest — and why an incremental archive is a `pread` of a
21//!   suffix rather than a comparison of two trees.
22//! * **NO INDEX TRAVELS.** `objects.tail` (redb) and `objects.exploded` (Arrow)
23//!   are *derivations*, and shipping a derivation is how two copies of a
24//!   repository come to disagree about the same bytes. The receiver rebuilds
25//!   its own: [`crate::git_ops::GitStore::open`] diffs the journal it is handed
26//!   against the index rows it has (none, in a fresh store) and re-queues every
27//!   pack for indexing. **A restore is therefore three files and an `open`.**
28//!
29//! # What is truth, and what is not
30//!
31//! From the table in [`crate::git_ops::GitStore`]:
32//!
33//! | file | what it is | travels? |
34//! |---|---|---|
35//! | `objects.pack` | the verbatim pushed pack bytes | **yes** — the truth (§14) |
36//! | `objects.pack.journal` | the durable extent rows behind the ack | **yes** — the indexed bit is derived from it |
37//! | `refs.log` | the ref transaction log, one Arrow IPC frame per push | **yes** — refs are not derivable from objects |
38//! | `objects.tail` | the un-sealed index tail (redb) | no — derivation |
39//! | `objects.exploded` | §14's resolved content cache | no — derivation, explicitly droppable |
40//!
41//! Measured on a one-commit repository: the three that travel are **4 274
42//! bytes**; the directory on disk is **1.59 MiB**, because `objects.tail` is a
43//! redb file with redb's own floor. A block-level copy moves the second number.
44//!
45//! # The journal and the ref log ship WHOLE, and that is deliberate
46//!
47//! Only `objects.pack` is watermarked. `objects.pack.journal` is 24-byte rows
48//! and `refs.log` is one small Arrow frame per push: at a thousand pushes they
49//! are together a fraction of one pack. Cutting them too would buy little and
50//! would need three watermarks where the settled signature has one. The format
51//! nevertheless records `from_offset` **per segment**, so a later version can
52//! watermark them without a format change — the reader already honours it.
53//!
54//! # A GC invalidates a watermark, and the file says so rather than lying
55//!
56//! [`crate::gc`] compacts into a *new generation*: after it, byte 0 of
57//! `objects.pack` may not be the byte 0 the previous archive measured its
58//! watermark against. So every archive records `base_prefix_sha256` — the
59//! SHA-256 of the first [`PREFIX_BYTES`] bytes of `objects.pack` at the instant
60//! of the cut — and an incremental cut checks it against the base's. A
61//! mismatch is refused **by name** (`the base has been rewritten…`) and the
62//! answer is a full archive, not a silent half one.
63//!
64//! # The file
65//!
66//! ```text
67//! magic "GUNNARAF"          8
68//! version u32 LE            4   = FORMAT_VERSION
69//! flags   u32 LE            4   = 0
70//! from_generation u64 LE    8   the objects.pack watermark this file starts above
71//! to_generation   u64 LE    8   objects.pack length at the cut
72//! base_prefix_sha256       32   SHA-256 of objects.pack[0 .. min(len, PREFIX_BYTES)]
73//! segment_count u32 LE      4
74//! reserved      u32 LE      4
75//! per segment:  name_len u16 LE, name bytes, from_offset u64 LE, len u64 LE, sha256 [32]
76//! payloads, back to back, in the order the segments were listed
77//! trailer_sha256           32   SHA-256 of every byte before it
78//! ```
79//!
80//! Names are **relative to the repository directory** (`OBJECT_FORMAT`,
81//! `gunnar-git.znippy/objects.pack`, …), so a restore reconstructs the shape
82//! rather than guessing it, and a reader can say what a file contains without
83//! knowing which engine wrote it.
84
85use std::fs::File;
86use std::io::{BufWriter, Read, Seek, SeekFrom, Write};
87use std::path::{Path, PathBuf};
88
89use anyhow::{bail, Context, Result};
90use sha2::{Digest, Sha256};
91
92/// The first eight bytes of every archive file.
93pub const MAGIC: &[u8; 8] = b"GUNNARAF";
94
95/// Bumped when the layout above changes. A reader refuses a version it does not
96/// know rather than reading the bytes it recognises out of a file it does not.
97pub const FORMAT_VERSION: u32 = 1;
98
99/// How much of `objects.pack` the base fingerprint covers. One 64 KiB `pread`,
100/// which is cheap enough to do on every cut and long enough that two different
101/// bases do not collide by accident.
102pub const PREFIX_BYTES: u64 = 65_536;
103
104/// The file that is watermarked. Append-only; see the module docs.
105pub const PACK: &str = "objects.pack";
106/// The durable extent rows. The `indexed` bit is derived from this.
107pub const JOURNAL: &str = "objects.pack.journal";
108/// The ref transaction log.
109pub const REFS: &str = "refs.log";
110/// The hash-kind marker, one level up from the store directory.
111pub const OBJECT_FORMAT: &str = "OBJECT_FORMAT";
112
113/// Copy buffer. One page-aligned megabyte: big enough that a multi-gigabyte
114/// pack is not a syscall storm, small enough to sit on the stack of nobody.
115const COPY_BUF: usize = 1 << 20;
116
117/// One segment as the header describes it.
118#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct Segment {
120    /// Path relative to the repository directory.
121    pub name: String,
122    /// The offset in the source file this segment starts at. `0` = whole file.
123    pub from_offset: u64,
124    /// How many bytes travel.
125    pub len: u64,
126    /// SHA-256 of exactly those bytes.
127    pub sha256: [u8; 32],
128}
129
130/// What a completed [`archive_out`] did.
131#[derive(Debug, Clone, PartialEq, Eq)]
132pub struct ArchiveOutReceipt {
133    /// The watermark this archive started from.
134    pub from_generation: u64,
135    /// The watermark to pass as `from_generation` next time.
136    pub to_generation: u64,
137    /// Bytes of payload — what actually moved, not the file's size.
138    pub bytes_out: u64,
139    /// The segments, in the order they were written.
140    pub segments: Vec<Segment>,
141    /// SHA-256 of the whole file.
142    pub file_sha256: [u8; 32],
143}
144
145impl ArchiveOutReceipt {
146    /// Zero payload is a legitimate, successful outcome: the destination was
147    /// already current. It is reported rather than turned into an error.
148    pub fn is_empty(&self) -> bool {
149        self.bytes_out == 0
150    }
151}
152
153/// What a completed [`archive_in`] did.
154#[derive(Debug, Clone, PartialEq, Eq)]
155pub struct ArchiveInReceipt {
156    /// Where the repository was written.
157    pub repo_dir: PathBuf,
158    /// The watermark the restored `objects.pack` now stands at.
159    pub to_generation: u64,
160    /// Bytes written into the store.
161    pub bytes_in: u64,
162    /// Segment names restored, in order.
163    pub restored: Vec<String>,
164}
165
166/// The header, as read back.
167#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct ArchiveHeader {
169    pub version: u32,
170    pub flags: u32,
171    pub from_generation: u64,
172    pub to_generation: u64,
173    pub base_prefix_sha256: [u8; 32],
174    pub segments: Vec<Segment>,
175    /// Byte offset in the file where the first payload starts.
176    pub payload_start: u64,
177}
178
179/// **Find the directory that holds `objects.pack`.**
180///
181/// A repository directory either *is* the store root or holds exactly one
182/// subdirectory that is (gunnar's engine names it `gunnar-git.znippy`). This is
183/// the only place that shape is interpreted, and it is interpreted by looking
184/// for the file rather than by knowing the name — a store laid down by a
185/// differently-named engine restores the same way.
186pub fn find_store_dir(repo_dir: &Path) -> Result<PathBuf> {
187    if repo_dir.join(PACK).is_file() {
188        return Ok(repo_dir.to_path_buf());
189    }
190    let mut found = None;
191    for entry in std::fs::read_dir(repo_dir)
192        .with_context(|| format!("reading the repository directory {}", repo_dir.display()))?
193    {
194        let entry = entry?;
195        if entry.path().join(PACK).is_file() {
196            if found.is_some() {
197                bail!(
198                    "{} holds more than one store directory; refusing to guess which one is the \
199                     repository",
200                    repo_dir.display()
201                );
202            }
203            found = Some(entry.path());
204        }
205    }
206    found.ok_or_else(|| {
207        anyhow::anyhow!(
208            "no {PACK} under {} — that file is the truth an archive is made of, and a directory \
209             without one is not a store this can archive",
210            repo_dir.display()
211        )
212    })
213}
214
215/// SHA-256 of `objects.pack`'s first [`PREFIX_BYTES`] bytes: the base
216/// fingerprint an incremental cut is checked against. See the module docs on
217/// GC.
218pub fn base_prefix(store_dir: &Path) -> Result<[u8; 32]> {
219    let path = store_dir.join(PACK);
220    let mut f = File::open(&path).with_context(|| format!("opening {}", path.display()))?;
221    let len = f.metadata()?.len().min(PREFIX_BYTES);
222    let mut buf = vec![0u8; len as usize];
223    f.read_exact(&mut buf)
224        .with_context(|| format!("reading the base prefix of {}", path.display()))?;
225    let mut h = Sha256::new();
226    h.update(&buf);
227    Ok(h.finalize().into())
228}
229
230/// **Write one repository's bytes above `from_generation` into `out`.**
231///
232/// `repo_dir` is the repository directory (the one holding `OBJECT_FORMAT` and
233/// the store directory). `from_generation` is an `objects.pack` byte offset:
234/// `0` for a full archive, a previous receipt's `to_generation` for an
235/// incremental one.
236///
237/// The pack is read while the server may still be appending to it. That is safe
238/// and it is the whole point of an append-only file: the cut is taken at a
239/// length read once, up front, and every byte below that length is already
240/// durable (`ArchiveWrite::append` fsyncs the blob, then the journal row, then
241/// fsyncs that). Bytes appended *after* the cut simply belong to the next
242/// archive. **There is no torn state to quiesce for.**
243///
244/// This is [`archive_out_to`] plus a file: the create, the `flush` and the
245/// `sync_all` that a *file* destination owes and a socket does not.
246pub fn archive_out(repo_dir: &Path, out: &Path, from_generation: u64) -> Result<ArchiveOutReceipt> {
247    if let Some(parent) = out.parent() {
248        std::fs::create_dir_all(parent).ok();
249    }
250    let file = File::create(out).with_context(|| format!("creating {}", out.display()))?;
251    let mut w = BufWriter::new(file);
252    let receipt = archive_out_to(repo_dir, &mut w, from_generation)
253        .with_context(|| format!("writing the archive {}", out.display()))?;
254    w.flush()
255        .with_context(|| format!("flushing {}", out.display()))?;
256    w.into_inner()
257        .map_err(|e| anyhow::anyhow!("flushing {}: {e}", out.display()))?
258        .sync_all()
259        .with_context(|| format!("fsyncing {}", out.display()))?;
260    Ok(receipt)
261}
262
263/// **The same bytes, into any [`Write`] — a file, a socket, a gRPC stream.**
264///
265/// [`archive_out`] is this function with a `File` under it. The split exists
266/// because `archive-out` that can only write a path **on the appliance** is
267/// useless for off-box backup, and gunnar's box has no shell to copy the file
268/// out with: the control plane streams these bytes instead
269/// (`Repos.ArchiveOut`). Nothing here opens a socket — the caller owns the
270/// transport, exactly as `crate::replicate` says it must.
271///
272/// It writes strictly forward and never seeks, so a consumer may hand it a
273/// pipe. It does **not** flush or fsync: those belong to whoever owns the
274/// destination, and a network sink has neither.
275pub fn archive_out_to<W: Write>(
276    repo_dir: &Path,
277    out: &mut W,
278    from_generation: u64,
279) -> Result<ArchiveOutReceipt> {
280    let store = find_store_dir(repo_dir)?;
281    let pack = store.join(PACK);
282    let pack_len = std::fs::metadata(&pack)
283        .with_context(|| format!("measuring {}", pack.display()))?
284        .len();
285    if from_generation > pack_len {
286        bail!(
287            "the watermark {from_generation} is above {}'s length {pack_len} — the base has been \
288             rewritten (a gc compacts into a new generation, see the module docs). Take a full \
289             archive with --from-generation 0.",
290            pack.display()
291        );
292    }
293    let base_prefix_sha256 = base_prefix(&store)?;
294
295    // The segments, in the order they are written. `OBJECT_FORMAT` first
296    // because a restore needs the hash kind before anything else means
297    // anything; the pack next because it is the big one and a reader that
298    // streams gets the expensive part flowing early.
299    let rel_store = store
300        .strip_prefix(repo_dir)
301        .map(|p| p.to_path_buf())
302        .unwrap_or_default();
303    let mut planned: Vec<(PathBuf, String, u64)> = Vec::new();
304    let of = repo_dir.join(OBJECT_FORMAT);
305    if of.is_file() {
306        planned.push((of, OBJECT_FORMAT.to_string(), 0));
307    }
308    let rel = |name: &str| -> String {
309        if rel_store.as_os_str().is_empty() {
310            name.to_string()
311        } else {
312            format!("{}/{}", rel_store.display(), name)
313        }
314    };
315    planned.push((pack.clone(), rel(PACK), from_generation));
316    for name in [JOURNAL, REFS] {
317        let p = store.join(name);
318        if p.is_file() {
319            planned.push((p, rel(name), 0));
320        }
321    }
322
323    // Two passes: measure and hash, then write. The header carries every
324    // segment's length and digest, so a reader knows what it is getting before
325    // it has it — which is what makes the file self-describing rather than a
326    // tar that has to be unpacked to be understood.
327    let mut segments = Vec::with_capacity(planned.len());
328    for (path, name, from) in &planned {
329        let (len, sha256) = measure(path, *from, if name.ends_with(PACK) { Some(pack_len) } else { None })?;
330        segments.push(Segment {
331            name: name.clone(),
332            from_offset: *from,
333            len,
334            sha256,
335        });
336    }
337
338    let mut w = HashingWriter::new(out);
339
340    w.write_all(MAGIC)?;
341    w.write_all(&FORMAT_VERSION.to_le_bytes())?;
342    w.write_all(&0u32.to_le_bytes())?;
343    w.write_all(&from_generation.to_le_bytes())?;
344    w.write_all(&pack_len.to_le_bytes())?;
345    w.write_all(&base_prefix_sha256)?;
346    w.write_all(&(segments.len() as u32).to_le_bytes())?;
347    w.write_all(&0u32.to_le_bytes())?;
348    for s in &segments {
349        let nb = s.name.as_bytes();
350        w.write_all(&(nb.len() as u16).to_le_bytes())?;
351        w.write_all(nb)?;
352        w.write_all(&s.from_offset.to_le_bytes())?;
353        w.write_all(&s.len.to_le_bytes())?;
354        w.write_all(&s.sha256)?;
355    }
356    let mut bytes_out = 0u64;
357    for ((path, _, from), s) in planned.iter().zip(&segments) {
358        copy_range(path, *from, s.len, &mut w)?;
359        bytes_out += s.len;
360    }
361    let trailer = w.digest();
362    let inner = w.into_inner();
363    inner.write_all(&trailer)?;
364
365    Ok(ArchiveOutReceipt {
366        from_generation,
367        to_generation: pack_len,
368        bytes_out,
369        segments,
370        file_sha256: trailer,
371    })
372}
373
374/// Read an archive's header without reading its payload.
375pub fn read_header(path: &Path) -> Result<ArchiveHeader> {
376    let mut f = File::open(path).with_context(|| format!("opening {}", path.display()))?;
377    let mut fixed = [0u8; 72];
378    f.read_exact(&mut fixed)
379        .with_context(|| format!("{} is too short to be an archive", path.display()))?;
380    if &fixed[0..8] != MAGIC {
381        bail!(
382            "{} does not start with {} — it is not a gunnar repository archive",
383            path.display(),
384            String::from_utf8_lossy(MAGIC)
385        );
386    }
387    let version = u32::from_le_bytes(fixed[8..12].try_into().unwrap());
388    if version != FORMAT_VERSION {
389        bail!(
390            "{} is format version {version}; this build reads {FORMAT_VERSION} and will not guess \
391             at the difference",
392            path.display()
393        );
394    }
395    let flags = u32::from_le_bytes(fixed[12..16].try_into().unwrap());
396    let from_generation = u64::from_le_bytes(fixed[16..24].try_into().unwrap());
397    let to_generation = u64::from_le_bytes(fixed[24..32].try_into().unwrap());
398    let mut base_prefix_sha256 = [0u8; 32];
399    base_prefix_sha256.copy_from_slice(&fixed[32..64]);
400    let count = u32::from_le_bytes(fixed[64..68].try_into().unwrap());
401    let mut segments = Vec::with_capacity(count as usize);
402    let mut at = 72u64;
403    for _ in 0..count {
404        let mut nl = [0u8; 2];
405        f.read_exact(&mut nl)?;
406        let nl = u16::from_le_bytes(nl) as usize;
407        let mut nb = vec![0u8; nl];
408        f.read_exact(&mut nb)?;
409        let name = String::from_utf8(nb).context("a segment name that is not UTF-8")?;
410        if name.contains("..") || name.starts_with('/') {
411            bail!("refusing the segment name {name:?}: an archive may not write outside the repository directory");
412        }
413        let mut o = [0u8; 8];
414        f.read_exact(&mut o)?;
415        let mut l = [0u8; 8];
416        f.read_exact(&mut l)?;
417        let mut d = [0u8; 32];
418        f.read_exact(&mut d)?;
419        at += 2 + nl as u64 + 8 + 8 + 32;
420        segments.push(Segment {
421            name,
422            from_offset: u64::from_le_bytes(o),
423            len: u64::from_le_bytes(l),
424            sha256: d,
425        });
426    }
427    Ok(ArchiveHeader {
428        version,
429        flags,
430        from_generation,
431        to_generation,
432        base_prefix_sha256,
433        segments,
434        payload_start: at,
435    })
436}
437
438/// **Put an archive back into `repo_dir`.**
439///
440/// A full archive (`from_generation == 0`) needs an empty or absent directory:
441/// restoring over a store that already has bytes is how a restore silently
442/// half-happens. An incremental archive is *applied* to a directory whose
443/// `objects.pack` is already exactly `from_generation` bytes long and whose
444/// base fingerprint matches; anything else is refused by name.
445///
446/// **No index is written and none is expected.** The next
447/// [`crate::git_ops::GitStore::open`] on this directory reads the journal, sees
448/// index rows for none of it, and re-queues every pack — which is the same path
449/// a crash mid-index takes, and it is the reason nothing derived travels.
450pub fn archive_in(file: &Path, repo_dir: &Path) -> Result<ArchiveInReceipt> {
451    let header = read_header(file)?;
452    let mut f = File::open(file)?;
453
454    // Verify the trailer before writing one byte into the store: an archive
455    // that is short or corrupt must not become a half-restored repository.
456    let file_len = f.metadata()?.len();
457    if file_len < header.payload_start + 32 {
458        bail!("{} is truncated: no room for its payload and trailer", file.display());
459    }
460    let mut hasher = Sha256::new();
461    f.seek(SeekFrom::Start(0))?;
462    let mut left = file_len - 32;
463    let mut buf = vec![0u8; COPY_BUF];
464    while left > 0 {
465        let n = (left as usize).min(buf.len());
466        f.read_exact(&mut buf[..n])?;
467        hasher.update(&buf[..n]);
468        left -= n as u64;
469    }
470    let want: [u8; 32] = hasher.finalize().into();
471    let mut got = [0u8; 32];
472    f.read_exact(&mut got)?;
473    if want != got {
474        bail!(
475            "{} fails its own trailer digest — the file is corrupt or truncated and NOTHING has \
476             been written",
477            file.display()
478        );
479    }
480
481    let incremental = header.from_generation > 0;
482    if incremental {
483        let store = find_store_dir(repo_dir).with_context(|| {
484            format!(
485                "an incremental archive is applied to an existing store, and {} is not one",
486                repo_dir.display()
487            )
488        })?;
489        let have = std::fs::metadata(store.join(PACK))?.len();
490        if have != header.from_generation {
491            bail!(
492                "this archive starts at watermark {} but {}'s pack is {have} bytes: restore the \
493                 base first, or take a full archive",
494                header.from_generation,
495                store.display()
496            );
497        }
498        if base_prefix(&store)? != header.base_prefix_sha256 {
499            bail!(
500                "the base has been rewritten: {}'s pack prefix does not match the one this \
501                 archive was cut against (a gc compacts into a new generation). Take a full \
502                 archive with --from-generation 0.",
503                store.display()
504            );
505        }
506    } else if repo_dir.exists() {
507        let empty = std::fs::read_dir(repo_dir)?.next().is_none();
508        if !empty {
509            bail!(
510                "{} is not empty; a full restore refuses to write over an existing repository",
511                repo_dir.display()
512            );
513        }
514    }
515    std::fs::create_dir_all(repo_dir)
516        .with_context(|| format!("creating {}", repo_dir.display()))?;
517
518    f.seek(SeekFrom::Start(header.payload_start))?;
519    let mut bytes_in = 0u64;
520    let mut restored = Vec::new();
521    for s in &header.segments {
522        let dest = repo_dir.join(&s.name);
523        if let Some(parent) = dest.parent() {
524            std::fs::create_dir_all(parent)?;
525        }
526        let mut out = if s.from_offset == 0 {
527            File::create(&dest).with_context(|| format!("creating {}", dest.display()))?
528        } else {
529            let mut o = std::fs::OpenOptions::new()
530                .write(true)
531                .open(&dest)
532                .with_context(|| format!("opening {} to append a watermarked segment", dest.display()))?;
533            o.seek(SeekFrom::Start(s.from_offset))?;
534            o
535        };
536        let mut left = s.len;
537        let mut h = Sha256::new();
538        while left > 0 {
539            let n = (left as usize).min(buf.len());
540            f.read_exact(&mut buf[..n])?;
541            h.update(&buf[..n]);
542            out.write_all(&buf[..n])?;
543            left -= n as u64;
544        }
545        let d: [u8; 32] = h.finalize().into();
546        if d != s.sha256 {
547            bail!("segment {} does not match its digest", s.name);
548        }
549        out.sync_all()
550            .with_context(|| format!("fsyncing {}", dest.display()))?;
551        bytes_in += s.len;
552        restored.push(s.name.clone());
553    }
554    Ok(ArchiveInReceipt {
555        repo_dir: repo_dir.to_path_buf(),
556        to_generation: header.to_generation,
557        bytes_in,
558        restored,
559    })
560}
561
562// ── plumbing ────────────────────────────────────────────────────────────────
563
564/// Length and digest of `path[from..cap]`. `cap` pins the pack at the length
565/// the cut was taken at, so a push landing mid-archive cannot lengthen the
566/// segment out from under the header that already described it.
567fn measure(path: &Path, from: u64, cap: Option<u64>) -> Result<(u64, [u8; 32])> {
568    let mut f = File::open(path).with_context(|| format!("opening {}", path.display()))?;
569    let end = cap.unwrap_or(f.metadata()?.len());
570    let len = end.saturating_sub(from);
571    f.seek(SeekFrom::Start(from))?;
572    let mut h = Sha256::new();
573    let mut buf = vec![0u8; COPY_BUF];
574    let mut left = len;
575    while left > 0 {
576        let n = (left as usize).min(buf.len());
577        f.read_exact(&mut buf[..n])
578            .with_context(|| format!("reading {} at {from}", path.display()))?;
579        h.update(&buf[..n]);
580        left -= n as u64;
581    }
582    Ok((len, h.finalize().into()))
583}
584
585fn copy_range<W: Write>(path: &Path, from: u64, len: u64, w: &mut W) -> Result<()> {
586    let mut f = File::open(path)?;
587    f.seek(SeekFrom::Start(from))?;
588    let mut buf = vec![0u8; COPY_BUF];
589    let mut left = len;
590    while left > 0 {
591        let n = (left as usize).min(buf.len());
592        f.read_exact(&mut buf[..n])?;
593        w.write_all(&buf[..n])?;
594        left -= n as u64;
595    }
596    Ok(())
597}
598
599/// Writes through and hashes everything that passes. The trailer is this
600/// digest, so it covers the header as well as the payload.
601struct HashingWriter<W: Write> {
602    inner: W,
603    hasher: Sha256,
604}
605
606impl<W: Write> HashingWriter<W> {
607    fn new(inner: W) -> Self {
608        Self {
609            inner,
610            hasher: Sha256::new(),
611        }
612    }
613    fn digest(&self) -> [u8; 32] {
614        self.hasher.clone().finalize().into()
615    }
616    fn into_inner(self) -> W {
617        self.inner
618    }
619}
620
621impl<W: Write> Write for HashingWriter<W> {
622    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
623        let n = self.inner.write(buf)?;
624        self.hasher.update(&buf[..n]);
625        Ok(n)
626    }
627    fn flush(&mut self) -> std::io::Result<()> {
628        self.inner.flush()
629    }
630}
631
632#[cfg(test)]
633mod tests {
634    use super::*;
635
636    /// A store directory with the five files a real one has, three of them
637    /// truth and two of them derivations.
638    fn fixture(root: &Path, pack: &[u8]) -> PathBuf {
639        let repo = root.join("r.git");
640        let store = repo.join("gunnar-git.znippy");
641        std::fs::create_dir_all(&store).unwrap();
642        std::fs::write(repo.join(OBJECT_FORMAT), b"sha1").unwrap();
643        std::fs::write(store.join(PACK), pack).unwrap();
644        std::fs::write(store.join(JOURNAL), b"journal rows").unwrap();
645        std::fs::write(store.join(REFS), b"arrow ref frames").unwrap();
646        std::fs::write(store.join("objects.tail"), vec![0u8; 4096]).unwrap();
647        std::fs::write(store.join("objects.exploded"), vec![1u8; 2048]).unwrap();
648        repo
649    }
650
651    fn tmp(name: &str) -> PathBuf {
652        let d = std::env::temp_dir().join(format!("laneAB-{name}-{}", std::process::id()));
653        let _ = std::fs::remove_dir_all(&d);
654        std::fs::create_dir_all(&d).unwrap();
655        d
656    }
657
658    /// **The derivations do not travel.** This is the property the whole module
659    /// exists for, so it is asserted rather than described.
660    #[test]
661    fn no_index_travels() {
662        let d = tmp("noindex");
663        let repo = fixture(&d, b"PACK-bytes-0123456789");
664        let out = d.join("a.gaf");
665        let r = archive_out(&repo, &out, 0).unwrap();
666        let names: Vec<&str> = r.segments.iter().map(|s| s.name.as_str()).collect();
667        assert!(names.iter().any(|n| n.ends_with(PACK)));
668        assert!(names.iter().any(|n| n.ends_with(JOURNAL)));
669        assert!(names.iter().any(|n| n.ends_with(REFS)));
670        assert!(
671            !names.iter().any(|n| n.contains("objects.tail")),
672            "the redb index travelled: {names:?}"
673        );
674        assert!(
675            !names.iter().any(|n| n.contains("objects.exploded")),
676            "the exploded cache travelled: {names:?}"
677        );
678        let _ = std::fs::remove_dir_all(&d);
679    }
680
681    /// **The streamed bytes ARE the file's bytes.** `archive_out_to` is what a
682    /// gRPC `Repos.ArchiveOut` writes down a socket, and the only thing that
683    /// makes an off-box archive worth anything is that `archive_in` cannot tell
684    /// the two apart. Asserted on the bytes and on the receipt, not on a
685    /// round-trip of the receipt through itself.
686    ///
687    /// Seen red: with `archive_out_to` writing the trailer through the hashing
688    /// writer instead of past it, `file_sha256` differs and this fails.
689    #[test]
690    fn a_streamed_archive_is_the_same_bytes_as_the_file() {
691        let d = tmp("streamed");
692        let pack: Vec<u8> = (0..300_000u32).map(|i| (i % 253) as u8).collect();
693        let repo = fixture(&d, &pack);
694        let out = d.join("f.gaf");
695        let on_file = archive_out(&repo, &out, 0).unwrap();
696
697        let mut streamed = Vec::new();
698        let over_wire = archive_out_to(&repo, &mut streamed, 0).unwrap();
699
700        assert_eq!(on_file, over_wire, "the receipts disagree");
701        assert_eq!(
702            streamed,
703            std::fs::read(&out).unwrap(),
704            "the streamed archive is not the file's bytes"
705        );
706
707        // …and it restores, which is the only claim that matters.
708        let back = d.join("back");
709        let r = archive_in(&out, &back).unwrap();
710        assert_eq!(r.to_generation, pack.len() as u64);
711        let wire = d.join("wire.gaf");
712        std::fs::write(&wire, &streamed).unwrap();
713        let back2 = d.join("back2");
714        let r2 = archive_in(&wire, &back2).unwrap();
715        assert_eq!(r.to_generation, r2.to_generation);
716        let _ = std::fs::remove_dir_all(&d);
717    }
718
719    /// Full round trip, byte for byte.
720    #[test]
721    fn full_round_trip_is_byte_identical() {
722        let d = tmp("full");
723        let pack: Vec<u8> = (0..200_000u32).map(|i| (i % 251) as u8).collect();
724        let repo = fixture(&d, &pack);
725        let out = d.join("full.gaf");
726        let r = archive_out(&repo, &out, 0).unwrap();
727        assert_eq!(r.from_generation, 0);
728        assert_eq!(r.to_generation, pack.len() as u64);
729
730        let back = d.join("restored.git");
731        let got = archive_in(&out, &back).unwrap();
732        assert_eq!(got.to_generation, pack.len() as u64);
733        let store = find_store_dir(&back).unwrap();
734        assert_eq!(std::fs::read(store.join(PACK)).unwrap(), pack);
735        assert_eq!(std::fs::read(store.join(JOURNAL)).unwrap(), b"journal rows");
736        assert_eq!(std::fs::read(store.join(REFS)).unwrap(), b"arrow ref frames");
737        assert_eq!(std::fs::read(back.join(OBJECT_FORMAT)).unwrap(), b"sha1");
738        assert!(
739            !store.join("objects.tail").exists(),
740            "a restore must not fabricate an index"
741        );
742        let _ = std::fs::remove_dir_all(&d);
743    }
744
745    /// **The watermark is a watermark.** Append to the pack, cut from the
746    /// previous `to_generation`, and only the new bytes move.
747    #[test]
748    fn incremental_moves_only_the_new_bytes() {
749        let d = tmp("incr");
750        let base: Vec<u8> = (0..100_000u32).map(|i| (i % 97) as u8).collect();
751        let repo = fixture(&d, &base);
752        let a = d.join("base.gaf");
753        let r1 = archive_out(&repo, &a, 0).unwrap();
754
755        let store = find_store_dir(&repo).unwrap();
756        let extra: Vec<u8> = (0..30_000u32).map(|i| (i % 13) as u8).collect();
757        {
758            use std::io::Write as _;
759            let mut f = std::fs::OpenOptions::new()
760                .append(true)
761                .open(store.join(PACK))
762                .unwrap();
763            f.write_all(&extra).unwrap();
764        }
765        let b = d.join("incr.gaf");
766        let r2 = archive_out(&repo, &b, r1.to_generation).unwrap();
767        let pack_seg = r2
768            .segments
769            .iter()
770            .find(|s| s.name.ends_with(PACK))
771            .unwrap();
772        assert_eq!(
773            pack_seg.len,
774            extra.len() as u64,
775            "an incremental cut must move only the appended bytes"
776        );
777
778        let back = d.join("restored.git");
779        archive_in(&a, &back).unwrap();
780        archive_in(&b, &back).unwrap();
781        let rstore = find_store_dir(&back).unwrap();
782        let mut want = base.clone();
783        want.extend_from_slice(&extra);
784        assert_eq!(std::fs::read(rstore.join(PACK)).unwrap(), want);
785        let _ = std::fs::remove_dir_all(&d);
786    }
787
788    /// **A rewritten base is refused by name, not restored into a mixture.**
789    #[test]
790    fn a_rewritten_base_is_refused() {
791        let d = tmp("gc");
792        let base: Vec<u8> = (0..80_000u32).map(|i| (i % 89) as u8).collect();
793        let repo = fixture(&d, &base);
794        let a = d.join("base.gaf");
795        let r1 = archive_out(&repo, &a, 0).unwrap();
796        let store = find_store_dir(&repo).unwrap();
797        {
798            use std::io::Write as _;
799            let mut f = std::fs::OpenOptions::new()
800                .append(true)
801                .open(store.join(PACK))
802                .unwrap();
803            f.write_all(b"more").unwrap();
804        }
805        let b = d.join("incr.gaf");
806        archive_out(&repo, &b, r1.to_generation).unwrap();
807
808        // A gc: the base is compacted into different bytes at the same length.
809        let back = d.join("restored.git");
810        archive_in(&a, &back).unwrap();
811        let rstore = find_store_dir(&back).unwrap();
812        let mut rewritten: Vec<u8> = base.clone();
813        rewritten[0] ^= 0xff;
814        std::fs::write(rstore.join(PACK), &rewritten).unwrap();
815        let err = archive_in(&b, &back).expect_err("a rewritten base must be refused");
816        assert!(
817            format!("{err:#}").contains("the base has been rewritten"),
818            "refused, but not by name: {err:#}"
819        );
820        let _ = std::fs::remove_dir_all(&d);
821    }
822
823    /// **A corrupt file writes NOTHING.** Seen RED by flipping the assertion:
824    /// without the up-front trailer check the segments land and then the digest
825    /// fails, leaving a half-restored repository.
826    #[test]
827    fn a_corrupt_archive_restores_nothing() {
828        let d = tmp("corrupt");
829        let repo = fixture(&d, b"PACK-abcdefghijklmnop");
830        let out = d.join("a.gaf");
831        archive_out(&repo, &out, 0).unwrap();
832        let mut bytes = std::fs::read(&out).unwrap();
833        let n = bytes.len();
834        bytes[n - 40] ^= 0xff; // inside the payload, above the trailer
835        std::fs::write(&out, &bytes).unwrap();
836        let back = d.join("restored.git");
837        let err = archive_in(&out, &back).expect_err("a corrupt archive must be refused");
838        assert!(format!("{err:#}").contains("trailer digest"), "{err:#}");
839        assert!(
840            !back.exists() || std::fs::read_dir(&back).unwrap().next().is_none(),
841            "a refused restore left bytes behind"
842        );
843        let _ = std::fs::remove_dir_all(&d);
844    }
845
846    /// A full restore never writes over a repository that already has bytes.
847    #[test]
848    fn a_full_restore_refuses_a_non_empty_directory() {
849        let d = tmp("nonempty");
850        let repo = fixture(&d, b"PACK-xyz");
851        let out = d.join("a.gaf");
852        archive_out(&repo, &out, 0).unwrap();
853        let back = d.join("restored.git");
854        std::fs::create_dir_all(&back).unwrap();
855        std::fs::write(back.join("something"), b"in the way").unwrap();
856        let err = archive_in(&out, &back).expect_err("must refuse");
857        assert!(format!("{err:#}").contains("not empty"), "{err:#}");
858        let _ = std::fs::remove_dir_all(&d);
859    }
860
861    /// A segment name may not escape the repository directory.
862    #[test]
863    fn a_segment_name_cannot_escape() {
864        let d = tmp("escape");
865        let repo = fixture(&d, b"PACK-1");
866        let out = d.join("a.gaf");
867        archive_out(&repo, &out, 0).unwrap();
868        let mut bytes = std::fs::read(&out).unwrap();
869        // The first segment is OBJECT_FORMAT (13 bytes); overwrite it with the
870        // same-length traversal name.
871        let at = 72 + 2;
872        let evil = b"../../escaped";
873        assert_eq!(evil.len(), OBJECT_FORMAT.len());
874        bytes[at..at + evil.len()].copy_from_slice(evil);
875        std::fs::write(&out, &bytes).unwrap();
876        let err = read_header(&out).expect_err("traversal must be refused");
877        assert!(format!("{err:#}").contains("outside the repository"), "{err:#}");
878        let _ = std::fs::remove_dir_all(&d);
879    }
880}