use std::io::{Read, Write};
use crate::Result;
use crate::fs::DeviceKind;
use crate::fs::ext::xattr::Xattr;
use super::header::{self, BLOCK_SIZE, Header};
use super::pax;
use super::{Entry, EntryKind, PaxOverrides, TarEntryMeta, build_header, normalise_path};
const PUMP_BUF: usize = 64 * 1024;
pub struct TarStreamWriter<W: Write> {
inner: W,
bytes_written: u64,
finished: bool,
}
impl<W: Write> TarStreamWriter<W> {
pub fn new(inner: W) -> Self {
Self {
inner,
bytes_written: 0,
finished: false,
}
}
pub fn bytes_written(&self) -> u64 {
self.bytes_written
}
pub fn add_file(
&mut self,
path: &str,
reader: &mut dyn Read,
size: u64,
meta: TarEntryMeta,
xattrs: &[Xattr],
) -> Result<()> {
let needs_size_pax = size > 0o7777_7777_7777; let mut records = pax::records_for_entry(path, None, needs_size_pax, xattrs);
if needs_size_pax {
records.push(pax::Record {
key: pax::KEY_SIZE.into(),
value: size.to_string().into_bytes(),
});
}
if !records.is_empty() {
self.write_pax_header(path, &records)?;
}
let h = build_header(
path,
header::TYPEFLAG_REG,
size,
None,
(0, 0),
&meta,
!records.is_empty(),
)?;
self.write_all_block(&h.encode()?)?;
let mut remaining = size;
let mut buf = vec![0u8; PUMP_BUF];
while remaining > 0 {
let want = remaining.min(buf.len() as u64) as usize;
reader.read_exact(&mut buf[..want])?;
self.write_all_at(&buf[..want])?;
remaining -= want as u64;
}
let pad = (BLOCK_SIZE - (size as usize % BLOCK_SIZE)) % BLOCK_SIZE;
if pad > 0 {
self.write_all_at(&[0u8; BLOCK_SIZE][..pad])?;
}
Ok(())
}
pub fn add_dir(&mut self, path: &str, meta: TarEntryMeta, xattrs: &[Xattr]) -> Result<()> {
let records = pax::records_for_entry(path, None, false, xattrs);
if !records.is_empty() {
self.write_pax_header(path, &records)?;
}
let h = build_header(
path,
header::TYPEFLAG_DIR,
0,
None,
(0, 0),
&meta,
!records.is_empty(),
)?;
self.write_all_block(&h.encode()?)
}
pub fn add_symlink(
&mut self,
path: &str,
target: &str,
meta: TarEntryMeta,
xattrs: &[Xattr],
) -> Result<()> {
let records = pax::records_for_entry(path, Some(target), false, xattrs);
if !records.is_empty() {
self.write_pax_header(path, &records)?;
}
let h = build_header(
path,
header::TYPEFLAG_SYMLINK,
0,
Some(target),
(0, 0),
&meta,
!records.is_empty(),
)?;
self.write_all_block(&h.encode()?)
}
pub fn add_device(
&mut self,
path: &str,
kind: DeviceKind,
major: u32,
minor: u32,
meta: TarEntryMeta,
xattrs: &[Xattr],
) -> Result<()> {
let records = pax::records_for_entry(path, None, false, xattrs);
if !records.is_empty() {
self.write_pax_header(path, &records)?;
}
let typeflag = match kind {
DeviceKind::Char => header::TYPEFLAG_CHAR,
DeviceKind::Block => header::TYPEFLAG_BLOCK,
DeviceKind::Fifo => header::TYPEFLAG_FIFO,
DeviceKind::Socket => {
eprintln!("tar: socket {path:?} archived as FIFO (tar can't represent sockets)");
header::TYPEFLAG_FIFO
}
};
let h = build_header(
path,
typeflag,
0,
None,
(major, minor),
&meta,
!records.is_empty(),
)?;
self.write_all_block(&h.encode()?)
}
pub fn finish(&mut self) -> Result<()> {
if self.finished {
return Ok(());
}
self.write_all_block(&[0u8; BLOCK_SIZE])?;
self.write_all_block(&[0u8; BLOCK_SIZE])?;
self.inner.flush()?;
self.finished = true;
Ok(())
}
pub fn into_inner(self) -> W {
self.inner
}
fn write_pax_header(&mut self, ref_path: &str, records: &[pax::Record]) -> Result<()> {
let body = pax::encode_records(records);
let meta = TarEntryMeta {
mode: 0o644,
uid: 0,
gid: 0,
mtime: 0,
uname: String::new(),
gname: String::new(),
};
let pax_name = format!(
"./PaxHeaders/{}",
ref_path.rsplit('/').next().unwrap_or("entry")
);
let mut h = build_header(
&pax_name,
header::TYPEFLAG_PAX,
body.len() as u64,
None,
(0, 0),
&meta,
false,
)?;
h.size = body.len() as u64;
self.write_all_block(&h.encode()?)?;
self.write_all_at(&body)?;
let pad = (BLOCK_SIZE - (body.len() % BLOCK_SIZE)) % BLOCK_SIZE;
if pad > 0 {
self.write_all_at(&[0u8; BLOCK_SIZE][..pad])?;
}
Ok(())
}
fn write_all_block(&mut self, block: &[u8; BLOCK_SIZE]) -> Result<()> {
self.write_all_at(block)
}
fn write_all_at(&mut self, buf: &[u8]) -> Result<()> {
if self.finished {
return Err(crate::Error::InvalidArgument(
"tar: stream writer already finished".into(),
));
}
self.inner.write_all(buf)?;
self.bytes_written += buf.len() as u64;
Ok(())
}
}
pub struct TarStreamReader<R: Read> {
inner: R,
pending: PaxOverrides,
body_remaining: u64,
body_padding: usize,
eof: bool,
bytes_consumed: u64,
}
impl<R: Read> TarStreamReader<R> {
pub fn new(inner: R) -> Self {
Self {
inner,
pending: PaxOverrides::default(),
body_remaining: 0,
body_padding: 0,
eof: false,
bytes_consumed: 0,
}
}
pub fn bytes_consumed(&self) -> u64 {
self.bytes_consumed
}
pub fn next_entry(&mut self) -> Result<Option<StreamEntry<'_, R>>> {
if self.body_remaining > 0 || self.body_padding > 0 {
self.skip_current_body()?;
}
if self.eof {
return Ok(None);
}
let mut block = [0u8; BLOCK_SIZE];
let mut consecutive_zero = 0u32;
loop {
match self.read_one_block(&mut block) {
Ok(true) => {}
Ok(false) => {
self.eof = true;
return Ok(None);
}
Err(e) => return Err(e),
}
if header::is_zero_block(&block) {
consecutive_zero += 1;
if consecutive_zero >= 2 {
self.eof = true;
return Ok(None);
}
continue;
}
consecutive_zero = 0;
if !Header::checksum_ok(&block) {
return Err(crate::Error::InvalidImage(
"tar: bad header checksum in stream".into(),
));
}
let h = Header::decode(&block)?;
let size_padded = ((h.size + 511) & !511) as usize - h.size as usize;
match h.typeflag {
header::TYPEFLAG_PAX => {
let body = self.read_exact_padded(h.size as usize)?;
self.pending.merge(pax::decode_records(&body)?);
continue;
}
header::TYPEFLAG_PAX_GLOBAL => {
let _ = self.read_exact_padded(h.size as usize)?;
continue;
}
header::TYPEFLAG_GNU_LONGNAME => {
let body = self.read_exact_padded(h.size as usize)?;
self.pending.path = Some(trim_nul(body));
continue;
}
header::TYPEFLAG_GNU_LONGLINK => {
let body = self.read_exact_padded(h.size as usize)?;
self.pending.linkpath = Some(trim_nul(body));
continue;
}
_ => {}
}
let Some(kind) = EntryKind::from_typeflag(h.typeflag) else {
eprintln!(
"tar: skipping entry {:?} with unknown typeflag {:?}",
h.full_name(),
h.typeflag as char
);
let _ = self.read_exact_padded(h.size as usize)?;
continue;
};
let path = self.pending.path.take().unwrap_or_else(|| h.full_name());
let link_target = self.pending.linkpath.take().or_else(|| {
if matches!(kind, EntryKind::Symlink | EntryKind::HardLink) {
Some(h.linkname.clone())
} else {
None
}
});
let size = self.pending.size.take().unwrap_or(h.size);
let mtime = self.pending.mtime.take().unwrap_or(h.mtime);
let xattrs = std::mem::take(&mut self.pending.xattrs);
let mut path = path;
if path.ends_with('/') {
path.pop();
}
let entry = Entry {
path: normalise_path(&path),
kind,
mode: h.mode,
uid: h.uid,
gid: h.gid,
mtime,
size,
link_target,
device_major: h.devmajor,
device_minor: h.devminor,
data_offset: 0,
xattrs,
};
self.body_remaining = if matches!(kind, EntryKind::Regular) {
size
} else {
0
};
self.body_padding = if matches!(kind, EntryKind::Regular) && size > 0 {
(BLOCK_SIZE - (size as usize % BLOCK_SIZE)) % BLOCK_SIZE
} else {
0
};
let _ = size_padded;
let body_offset = self.bytes_consumed;
return Ok(Some(StreamEntry {
entry,
body_offset,
parent: self,
}));
}
}
fn read_one_block(&mut self, block: &mut [u8; BLOCK_SIZE]) -> Result<bool> {
let mut got = 0;
while got < BLOCK_SIZE {
match self.inner.read(&mut block[got..]) {
Ok(0) => {
if got == 0 {
return Ok(false);
}
return Err(crate::Error::InvalidImage(format!(
"tar: short read inside header (got {got} / 512)"
)));
}
Ok(n) => {
got += n;
self.bytes_consumed += n as u64;
}
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(e) => return Err(e.into()),
}
}
Ok(true)
}
fn read_exact_padded(&mut self, len: usize) -> Result<Vec<u8>> {
let mut buf = vec![0u8; len];
self.inner.read_exact(&mut buf)?;
self.bytes_consumed += len as u64;
let pad = (BLOCK_SIZE - (len % BLOCK_SIZE)) % BLOCK_SIZE;
if pad > 0 {
let mut sink = [0u8; BLOCK_SIZE];
self.inner.read_exact(&mut sink[..pad])?;
self.bytes_consumed += pad as u64;
}
Ok(buf)
}
fn skip_current_body(&mut self) -> Result<()> {
let mut sink = [0u8; PUMP_BUF];
while self.body_remaining > 0 {
let want = (self.body_remaining as usize).min(sink.len());
self.inner.read_exact(&mut sink[..want])?;
self.body_remaining -= want as u64;
self.bytes_consumed += want as u64;
}
if self.body_padding > 0 {
self.inner.read_exact(&mut sink[..self.body_padding])?;
self.bytes_consumed += self.body_padding as u64;
self.body_padding = 0;
}
Ok(())
}
}
fn trim_nul(mut v: Vec<u8>) -> String {
while let Some(&b) = v.last() {
if b == 0 {
v.pop();
} else {
break;
}
}
String::from_utf8_lossy(&v).into_owned()
}
pub struct StreamEntry<'a, R: Read> {
pub entry: Entry,
pub body_offset: u64,
parent: &'a mut TarStreamReader<R>,
}
impl<'a, R: Read> StreamEntry<'a, R> {
pub fn remaining(&self) -> u64 {
self.parent.body_remaining
}
}
impl<'a, R: Read> Read for StreamEntry<'a, R> {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
if self.parent.body_remaining == 0 {
return Ok(0);
}
let want = (self.parent.body_remaining as usize).min(buf.len());
let n = self.parent.inner.read(&mut buf[..want])?;
self.parent.body_remaining -= n as u64;
self.parent.bytes_consumed += n as u64;
Ok(n)
}
}
#[derive(Debug, Clone)]
pub struct IndexedEntry {
pub entry: Entry,
pub body_offset: u64,
}
#[derive(Debug, Clone, Default)]
pub struct TarStreamIndex {
entries: Vec<IndexedEntry>,
by_path: std::collections::HashMap<String, usize>,
}
impl TarStreamIndex {
pub fn build<R: Read>(reader: &mut TarStreamReader<R>) -> Result<Self> {
let mut entries: Vec<IndexedEntry> = Vec::new();
let mut by_path: std::collections::HashMap<String, usize> =
std::collections::HashMap::new();
while let Some(ent) = reader.next_entry()? {
let ix = IndexedEntry {
entry: ent.entry.clone(),
body_offset: ent.body_offset,
};
let path = ix.entry.path.clone();
if let Some(&idx) = by_path.get(&path) {
entries[idx] = ix;
} else {
by_path.insert(path, entries.len());
entries.push(ix);
}
}
Ok(Self { entries, by_path })
}
pub fn build_from<R: Read>(mut reader: TarStreamReader<R>) -> Result<Self> {
Self::build(&mut reader)
}
pub fn entries(&self) -> &[IndexedEntry] {
&self.entries
}
pub fn lookup(&self, path: &str) -> Option<&IndexedEntry> {
let key = normalise_path(path);
self.by_path.get(&key).map(|&i| &self.entries[i])
}
pub fn open_body<R, F>(&self, path: &str, factory: F) -> Result<BoundedReader<R>>
where
R: Read,
F: FnOnce() -> Result<R>,
{
let ent = self
.lookup(path)
.ok_or_else(|| crate::Error::InvalidArgument(format!("tar: no such entry {path:?}")))?;
let (offset, size) = match ent.entry.kind {
EntryKind::Regular => (ent.body_offset, ent.entry.size),
EntryKind::HardLink => {
let target = ent.entry.link_target.as_deref().unwrap_or("");
let abs = if target.starts_with('/') {
target.to_string()
} else {
format!("/{target}")
};
let tgt = self.lookup(&abs).ok_or_else(|| {
crate::Error::InvalidImage(format!(
"tar: hard link {path:?} → {abs:?} (target missing from index)"
))
})?;
if !matches!(tgt.entry.kind, EntryKind::Regular) {
return Err(crate::Error::InvalidImage(format!(
"tar: hard link {path:?} → {abs:?} target is not a regular file"
)));
}
(tgt.body_offset, tgt.entry.size)
}
other => {
return Err(crate::Error::InvalidArgument(format!(
"tar: {path:?} is not a regular file (kind: {other:?})"
)));
}
};
let mut inner = factory()?;
skip_n(&mut inner, offset)?;
Ok(BoundedReader {
inner,
remaining: size,
})
}
}
#[derive(Debug)]
pub struct BoundedReader<R: Read> {
inner: R,
remaining: u64,
}
impl<R: Read> BoundedReader<R> {
pub fn remaining(&self) -> u64 {
self.remaining
}
}
impl<R: Read> Read for BoundedReader<R> {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
if self.remaining == 0 {
return Ok(0);
}
let want = (self.remaining as usize).min(buf.len());
let n = self.inner.read(&mut buf[..want])?;
self.remaining -= n as u64;
Ok(n)
}
}
fn skip_n<R: Read>(r: &mut R, n: u64) -> Result<()> {
if n == 0 {
return Ok(());
}
let mut buf = [0u8; PUMP_BUF];
let mut remaining = n;
while remaining > 0 {
let want = (remaining as usize).min(buf.len());
r.read_exact(&mut buf[..want])?;
remaining -= want as u64;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn meta() -> TarEntryMeta {
TarEntryMeta {
mode: 0o640,
uid: 1000,
gid: 1000,
mtime: 0x6000_0000,
uname: "user".into(),
gname: "group".into(),
}
}
#[test]
fn stream_round_trip_basic() {
let mut sink: Vec<u8> = Vec::new();
{
let mut w = TarStreamWriter::new(&mut sink);
let body = b"hello stream tar\n";
let mut r: &[u8] = body;
w.add_file(
"/hello.txt",
&mut r,
body.len() as u64,
meta(),
&[Xattr::new("user.tag", b"flag".to_vec())],
)
.unwrap();
w.add_dir("/sub", meta(), &[]).unwrap();
let nested = b"nested body\n";
let mut nr: &[u8] = nested;
w.add_file("/sub/inner.txt", &mut nr, nested.len() as u64, meta(), &[])
.unwrap();
w.add_symlink("/link-to-hello", "hello.txt", meta(), &[])
.unwrap();
w.finish().unwrap();
}
let mut reader = TarStreamReader::new(&sink[..]);
let mut seen = Vec::new();
while let Some(mut ent) = reader.next_entry().unwrap() {
let mut body = Vec::new();
if matches!(ent.entry.kind, EntryKind::Regular) {
ent.read_to_end(&mut body).unwrap();
}
seen.push((ent.entry.path.clone(), ent.entry.kind, body));
}
assert_eq!(seen.len(), 4);
assert_eq!(seen[0].0, "/hello.txt");
assert_eq!(seen[0].1, EntryKind::Regular);
assert_eq!(seen[0].2, b"hello stream tar\n");
assert_eq!(seen[1].0, "/sub");
assert_eq!(seen[1].1, EntryKind::Dir);
assert_eq!(seen[2].0, "/sub/inner.txt");
assert_eq!(seen[2].1, EntryKind::Regular);
assert_eq!(seen[2].2, b"nested body\n");
assert_eq!(seen[3].0, "/link-to-hello");
assert_eq!(seen[3].1, EntryKind::Symlink);
}
#[test]
fn stream_reader_skips_unread_bodies() {
let mut sink: Vec<u8> = Vec::new();
{
let mut w = TarStreamWriter::new(&mut sink);
for i in 0..5 {
let body = vec![i as u8; 1000 + i * 137];
let mut r: &[u8] = &body;
w.add_file(
&format!("/f{i}.bin"),
&mut r,
body.len() as u64,
meta(),
&[],
)
.unwrap();
}
w.finish().unwrap();
}
let mut reader = TarStreamReader::new(&sink[..]);
let mut paths = Vec::new();
while let Some(ent) = reader.next_entry().unwrap() {
paths.push(ent.entry.path.clone());
}
assert_eq!(
paths,
vec!["/f0.bin", "/f1.bin", "/f2.bin", "/f3.bin", "/f4.bin"]
);
}
#[test]
fn stream_round_trip_long_path_via_pax() {
let long_path = format!("/{}", "a".repeat(200));
let mut sink: Vec<u8> = Vec::new();
{
let mut w = TarStreamWriter::new(&mut sink);
let body = b"X";
let mut r: &[u8] = body;
w.add_file(&long_path, &mut r, 1, meta(), &[]).unwrap();
w.finish().unwrap();
}
let mut reader = TarStreamReader::new(&sink[..]);
let ent = reader.next_entry().unwrap().unwrap();
assert_eq!(ent.entry.path, long_path);
}
#[test]
fn stream_round_trip_xattrs_via_pax() {
let mut sink: Vec<u8> = Vec::new();
{
let mut w = TarStreamWriter::new(&mut sink);
let body = b"x";
let mut r: &[u8] = body;
w.add_file(
"/with-xattr",
&mut r,
1,
meta(),
&[
Xattr::new("user.foo", b"bar".to_vec()),
Xattr::new("user.bin", b"\x00\x01\x02".to_vec()),
],
)
.unwrap();
w.finish().unwrap();
}
let mut reader = TarStreamReader::new(&sink[..]);
let ent = reader.next_entry().unwrap().unwrap();
assert_eq!(ent.entry.xattrs.len(), 2);
assert_eq!(ent.entry.xattrs[0].name, "user.foo");
assert_eq!(ent.entry.xattrs[0].value, b"bar");
assert_eq!(ent.entry.xattrs[1].name, "user.bin");
assert_eq!(ent.entry.xattrs[1].value, b"\x00\x01\x02");
}
fn build_indexed_fixture() -> (Vec<u8>, Vec<(&'static str, &'static [u8])>) {
let bodies: Vec<(&'static str, &'static [u8])> = vec![
("/a.txt", b"alpha-body-1234567890\n" as &[u8]),
("/dir/b.txt", b"beta-body" as &[u8]),
("/dir/c.bin", &[0u8; 1_000][..]),
];
let mut sink: Vec<u8> = Vec::new();
{
let mut w = TarStreamWriter::new(&mut sink);
for (path, body) in &bodies {
let mut r: &[u8] = body;
w.add_file(path, &mut r, body.len() as u64, meta(), &[])
.unwrap();
}
w.add_dir("/dir", meta(), &[]).unwrap();
w.add_symlink("/sym-to-a", "a.txt", meta(), &[]).unwrap();
w.finish().unwrap();
}
(sink, bodies)
}
#[test]
fn index_builds_records_offsets_for_regular_files() {
let (archive, bodies) = build_indexed_fixture();
let reader = TarStreamReader::new(&archive[..]);
let index = TarStreamIndex::build_from(reader).unwrap();
for (path, body) in &bodies {
let entry = index.lookup(path).expect("indexed entry");
assert_eq!(entry.entry.kind, EntryKind::Regular);
assert_eq!(entry.entry.size, body.len() as u64);
assert_eq!(entry.body_offset % 512, 0);
}
let sym = index.lookup("/sym-to-a").unwrap();
assert_eq!(sym.entry.kind, EntryKind::Symlink);
assert_eq!(sym.entry.link_target.as_deref(), Some("a.txt"));
}
#[test]
fn index_open_body_seeks_to_each_regular_file() {
let (archive, bodies) = build_indexed_fixture();
let reader = TarStreamReader::new(&archive[..]);
let index = TarStreamIndex::build_from(reader).unwrap();
for (path, expected) in bodies.iter().rev() {
let archive = archive.clone();
let mut body = Vec::new();
let mut bounded = index
.open_body::<&[u8], _>(path, || Ok(&archive[..]))
.expect("open_body");
bounded.read_to_end(&mut body).unwrap();
assert_eq!(body, *expected, "mismatch for {path}");
}
}
#[test]
fn index_open_body_resolves_hard_link_to_target_body() {
let body = b"the-real-content";
let mut sink: Vec<u8> = Vec::new();
{
let mut w = TarStreamWriter::new(&mut sink);
let mut r: &[u8] = body;
w.add_file("/target.txt", &mut r, body.len() as u64, meta(), &[])
.unwrap();
}
let header_block = {
let hl_meta = meta();
let mut h = super::super::build_header(
"/hardlink",
super::super::header::TYPEFLAG_HARDLINK,
0,
Some("target.txt"),
(0, 0),
&hl_meta,
false,
)
.unwrap();
h.size = 0;
h.encode().unwrap()
};
sink.extend_from_slice(&header_block);
sink.extend_from_slice(&[0u8; BLOCK_SIZE]);
sink.extend_from_slice(&[0u8; BLOCK_SIZE]);
let reader = TarStreamReader::new(&sink[..]);
let index = TarStreamIndex::build_from(reader).unwrap();
let hl = index.lookup("/hardlink").expect("hardlink entry");
assert_eq!(hl.entry.kind, EntryKind::HardLink);
let archive = sink.clone();
let mut got = Vec::new();
let mut bounded = index
.open_body::<&[u8], _>("/hardlink", || Ok(&archive[..]))
.expect("open_body for hardlink");
bounded.read_to_end(&mut got).unwrap();
assert_eq!(got, body);
}
#[test]
fn index_open_body_rejects_non_regular_entries() {
let (archive, _) = build_indexed_fixture();
let reader = TarStreamReader::new(&archive[..]);
let index = TarStreamIndex::build_from(reader).unwrap();
let err = index
.open_body::<&[u8], _>("/sym-to-a", || Ok(&archive[..]))
.unwrap_err();
assert!(
matches!(err, crate::Error::InvalidArgument(_)),
"got {err:?}"
);
let err = index
.open_body::<&[u8], _>("/does-not-exist", || Ok(&archive[..]))
.unwrap_err();
assert!(
matches!(err, crate::Error::InvalidArgument(_)),
"got {err:?}"
);
}
#[test]
fn index_preserves_archive_order_and_dedupes_paths() {
let mut sink: Vec<u8> = Vec::new();
{
let mut w = TarStreamWriter::new(&mut sink);
let one = b"v1";
let mut r1: &[u8] = one;
w.add_file("/dup", &mut r1, one.len() as u64, meta(), &[])
.unwrap();
let two = b"second-version";
let mut r2: &[u8] = two;
w.add_file("/dup", &mut r2, two.len() as u64, meta(), &[])
.unwrap();
w.finish().unwrap();
}
let reader = TarStreamReader::new(&sink[..]);
let index = TarStreamIndex::build_from(reader).unwrap();
let dup = index.lookup("/dup").unwrap();
let archive = sink.clone();
let mut bounded = index
.open_body::<&[u8], _>("/dup", || Ok(&archive[..]))
.unwrap();
let mut got = Vec::new();
bounded.read_to_end(&mut got).unwrap();
assert_eq!(got, b"second-version");
assert_eq!(dup.entry.size, b"second-version".len() as u64);
}
}