1use 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
92pub const MAGIC: &[u8; 8] = b"GUNNARAF";
94
95pub const FORMAT_VERSION: u32 = 1;
98
99pub const PREFIX_BYTES: u64 = 65_536;
103
104pub const PACK: &str = "objects.pack";
106pub const JOURNAL: &str = "objects.pack.journal";
108pub const REFS: &str = "refs.log";
110pub const OBJECT_FORMAT: &str = "OBJECT_FORMAT";
112
113const COPY_BUF: usize = 1 << 20;
116
117#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct Segment {
120 pub name: String,
122 pub from_offset: u64,
124 pub len: u64,
126 pub sha256: [u8; 32],
128}
129
130#[derive(Debug, Clone, PartialEq, Eq)]
132pub struct ArchiveOutReceipt {
133 pub from_generation: u64,
135 pub to_generation: u64,
137 pub bytes_out: u64,
139 pub segments: Vec<Segment>,
141 pub file_sha256: [u8; 32],
143}
144
145impl ArchiveOutReceipt {
146 pub fn is_empty(&self) -> bool {
149 self.bytes_out == 0
150 }
151}
152
153#[derive(Debug, Clone, PartialEq, Eq)]
155pub struct ArchiveInReceipt {
156 pub repo_dir: PathBuf,
158 pub to_generation: u64,
160 pub bytes_in: u64,
162 pub restored: Vec<String>,
164}
165
166#[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 pub payload_start: u64,
177}
178
179pub 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
215pub 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
230pub 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
263pub 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 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 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
374pub 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
438pub 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 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
562fn 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
599struct 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 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 #[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 #[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 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 #[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 #[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 #[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 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 #[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; 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 #[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 #[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 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}