#![cfg(feature = "tar")]
use std::collections::BTreeMap;
use std::io::Read;
use limnifs_core::{
parse_feature_flags_section, parse_manifest_header, parse_metadata_blob,
parse_metadata_reference, ManifestCursor,
};
use limnifs_write::stream::{EntryMeta, StreamWriter};
use limnifs_write::WriteConfig;
fn pack_tar<R: Read>(tar_bytes: &mut R) -> limnifs_write::WriteArtifact {
let mut tar_bytes_owned = Vec::new();
tar_bytes
.read_to_end(&mut tar_bytes_owned)
.expect("read tar");
let tar_bytes = tar_bytes_owned.as_slice();
let mut archive = tar::Archive::new(tar_bytes);
let config = WriteConfig::default_v0_1();
let mut writer = StreamWriter::new(&config).expect("default config");
for entry in archive.entries().expect("entries") {
let mut entry = entry.expect("entry");
let mtime_ns = entry.header().mtime().unwrap_or(0) * 1_000_000_000;
let mode = entry.header().mode().unwrap_or(0o644) & 0o7777;
let uid = u32::try_from(entry.header().uid().unwrap_or(0)).unwrap_or(u32::MAX);
let gid = u32::try_from(entry.header().gid().unwrap_or(0)).unwrap_or(u32::MAX);
let meta = EntryMeta::new(mtime_ns, mode).owner(uid, gid);
if entry
.path()
.ok()
.and_then(|p| {
p.file_name()
.map(|f| f.to_string_lossy().starts_with("._").to_owned())
})
.unwrap_or(false)
{
continue;
}
let mut pax_xattrs: Vec<(String, Vec<u8>)> = Vec::new();
if let Ok(Some(exts)) = entry.pax_extensions() {
for ext in exts.flatten() {
if let Ok(key) = ext.key() {
if let Some(name) = key.strip_prefix("SCHILY.xattr.") {
pax_xattrs.push((name.to_owned(), ext.value_bytes().to_vec()));
}
}
}
}
let name = entry
.path()
.expect("path")
.to_str()
.expect("utf-8")
.trim_end_matches('/')
.to_owned();
match entry.header().entry_type() {
tar::EntryType::Directory => writer.add_dir(&name, meta).expect("dir"),
tar::EntryType::Symlink => {
let target = entry.link_name().expect("link").expect("target");
writer
.add_symlink(&name, &target.to_string_lossy(), meta)
.expect("symlink");
}
tar::EntryType::Regular => {
let raw_size = entry.header().size().unwrap_or(u64::MAX);
let declared = entry.size();
if raw_size == declared {
let start = usize::try_from(entry.raw_file_position()).expect("offset");
let end = start + usize::try_from(declared).expect("size");
let data = tar_bytes.get(start..end).expect("entry range");
writer
.stage_file(&name, meta, &pax_xattrs, data)
.expect("staged file");
} else {
writer
.add_file(&name, meta, &pax_xattrs, &mut entry)
.expect("streamed file");
}
}
other => panic!("unsupported entry type {other:?}"),
}
}
writer.finish().expect("finish")
}
#[derive(Debug, PartialEq, Eq)]
enum Expected {
File(Vec<u8>),
Dir,
Symlink(String),
}
fn build_tar(entries: &[(&str, Expected)]) -> Vec<u8> {
let mut builder = tar::Builder::new(Vec::new());
for (name, expected) in entries {
let mut header = tar::Header::new_gnu();
match expected {
Expected::File(data) => {
header.set_entry_type(tar::EntryType::Regular);
header.set_size(data.len() as u64);
header.set_mode(0o644);
builder
.append_data(&mut header, name, data.as_slice())
.expect("append file");
}
Expected::Dir => {
header.set_entry_type(tar::EntryType::Directory);
header.set_size(0);
header.set_mode(0o755);
let dir = format!("{name}/");
builder
.append_data(&mut header, dir, std::io::empty())
.expect("append dir");
}
Expected::Symlink(target) => {
header.set_entry_type(tar::EntryType::Symlink);
header.set_size(0);
header.set_mode(0o777);
header.set_link_name(target).expect("link name");
builder
.append_data(&mut header, name, std::io::empty())
.expect("append symlink");
}
}
}
builder.into_inner().expect("tar bytes")
}
struct CollectSink<'s> {
out: BTreeMap<String, Expected>,
store: &'s limnifs_core::slab_store::SlabStore,
}
impl limnifs_core::live_tree::LiveTreeSink for CollectSink<'_> {
fn on_directory(&mut self, path: &std::path::Path) -> Result<(), limnifs_core::CoreError> {
if !path.as_os_str().is_empty() {
self.out
.insert(path.to_string_lossy().into_owned(), Expected::Dir);
}
Ok(())
}
fn on_regular_file(
&mut self,
path: &std::path::Path,
inode: &limnifs_core::Inode,
) -> Result<(), limnifs_core::CoreError> {
let data =
limnifs_core::live_tree::file_plaintext(inode, Some(self.store)).expect("plaintext");
self.out
.insert(path.to_string_lossy().into_owned(), Expected::File(data));
Ok(())
}
fn on_symlink(
&mut self,
path: &std::path::Path,
target: &str,
) -> Result<(), limnifs_core::CoreError> {
self.out.insert(
path.to_string_lossy().into_owned(),
Expected::Symlink(target.to_owned()),
);
Ok(())
}
}
fn parse_blob(artifact: &limnifs_write::WriteArtifact) -> limnifs_core::MetadataBlob {
let mut cursor = limnifs_core::ManifestCursor::new(&artifact.bytes);
limnifs_core::parse_manifest_header(&mut cursor).expect("header");
limnifs_core::parse_feature_flags_section(&mut cursor).expect("flags");
let meta_ref = limnifs_core::parse_metadata_reference(&mut cursor).expect("metadata reference");
let inline = meta_ref.inline_metadata.as_ref().expect("inline");
let mut blob_cursor = limnifs_core::ManifestCursor::new(inline);
limnifs_core::parse_metadata_blob(&mut blob_cursor).expect("blob")
}
fn collect_tree(artifact: &limnifs_write::WriteArtifact) -> BTreeMap<String, Expected> {
use limnifs_core::{
parse_feature_flags_section, parse_manifest_header, parse_metadata_reference,
ManifestCursor,
};
let mut cursor = ManifestCursor::new(&artifact.bytes);
parse_manifest_header(&mut cursor).expect("header");
parse_feature_flags_section(&mut cursor).expect("flags");
let meta_ref = parse_metadata_reference(&mut cursor).expect("metadata reference");
let inline = meta_ref
.inline_metadata
.as_ref()
.expect("test trees are small; metadata stays inline");
let mut blob_cursor = ManifestCursor::new(inline);
let blob = limnifs_core::parse_metadata_blob(&mut blob_cursor).expect("blob");
let slab_bytes: Vec<Vec<u8>> = artifact.slabs.iter().map(|s| s.bytes.clone()).collect();
let store = limnifs_core::slab_store::SlabStore::from_bytes(slab_bytes).expect("slabs");
let mut sink = CollectSink {
out: BTreeMap::new(),
store: &store,
};
let root = blob
.root_inode_number()
.expect("stream images have a root dir");
limnifs_core::live_tree::walk_live_tree(&blob, root, &mut sink).expect("walk");
sink.out
}
fn expected_map(entries: &[(&str, Expected)]) -> BTreeMap<String, Expected> {
let mut out: BTreeMap<String, Expected> = entries
.iter()
.map(|(name, e)| ((*name).to_owned(), clone_expected(e)))
.collect();
for (name, _) in entries {
let components: Vec<&str> = name.split('/').collect();
for depth in 1..components.len() {
out.entry(components[..depth].join("/"))
.or_insert(Expected::Dir);
}
}
out
}
fn clone_expected(e: &Expected) -> Expected {
match e {
Expected::File(d) => Expected::File(d.clone()),
Expected::Dir => Expected::Dir,
Expected::Symlink(t) => Expected::Symlink(t.clone()),
}
}
#[test]
fn tar_in_tree_out_round_trip() {
let entries: Vec<(&str, Expected)> = vec![
(
"README.md",
Expected::File(b"tar streaming round trip\n".to_vec()),
),
("docs", Expected::Dir),
("docs/guide.md", Expected::File(b"docs content\n".to_vec())),
("docs/latest", Expected::Symlink("guide.md".to_owned())),
("src/sub", Expected::Dir),
(
"src/sub/data.txt",
Expected::File(vec![0x41; 8192]), ),
(
"src/big.bin",
Expected::File(pseudo_random(11, 600 * 1024)), ),
];
let tar_bytes = build_tar(&entries);
let artifact = pack_tar(&mut tar_bytes.as_slice());
let actual = collect_tree(&artifact);
let expected = expected_map(&entries);
assert_eq!(actual, expected);
}
#[test]
fn same_tar_packs_identically() {
let entries: Vec<(&str, Expected)> = vec![
("a.txt", Expected::File(b"same bytes\n".to_vec())),
("dir", Expected::Dir),
("dir/b.bin", Expected::File(pseudo_random(3, 700 * 1024))),
];
let tar_bytes = build_tar(&entries);
let a = pack_tar(&mut tar_bytes.as_slice()).bytes;
let b = pack_tar(&mut tar_bytes.as_slice()).bytes;
assert_eq!(a, b);
}
fn pseudo_random(seed: u64, count: usize) -> Vec<u8> {
let mut state = seed;
let mut out = Vec::with_capacity(count);
for _ in 0..count {
state = state
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
out.push(u8::try_from(state >> 56).expect("fits u8"));
}
out
}
fn build_pax_tar() -> Vec<u8> {
let line = "SCHILY.xattr.user.test=abc";
let len = line.len() + 4; let records = format!("{len} {line}\n");
let mut builder = tar::Builder::new(Vec::new());
let mut x = tar::Header::new_gnu();
x.set_entry_type(tar::EntryType::XHeader);
x.set_size(records.len() as u64);
x.set_mode(0o644);
x.set_cksum();
builder
.append_data(&mut x, "PaxHeaders/f.txt", records.as_bytes())
.expect("x entry");
let mut h = tar::Header::new_gnu();
h.set_size(4);
h.set_mode(0o644);
h.set_cksum();
builder
.append_data(&mut h, "f.txt", &b"body"[..])
.expect("file entry");
builder.into_inner().expect("tar bytes")
}
fn pack_pax_tar(tar_bytes: &[u8]) -> limnifs_write::WriteArtifact {
let config: &'static limnifs_write::WriteConfig =
Box::leak(Box::new(limnifs_write::WriteConfig::default_v0_1()));
let mut archive = tar::Archive::new(tar_bytes);
let mut writer = limnifs_write::stream::StreamWriter::new(config).expect("writer");
for entry in archive.entries().expect("entries") {
let mut entry = entry.expect("entry");
let mtime_ns = entry.header().mtime().unwrap_or(0) * 1_000_000_000;
let mode = entry.header().mode().unwrap_or(0o644) & 0o7777;
let meta = EntryMeta::new(mtime_ns, mode);
let mut pax_xattrs: Vec<(String, Vec<u8>)> = Vec::new();
if let Ok(Some(exts)) = entry.pax_extensions() {
for ext in exts.flatten() {
if let Ok(key) = ext.key() {
if let Some(name) = key.strip_prefix("SCHILY.xattr.") {
pax_xattrs.push((name.to_owned(), ext.value_bytes().to_vec()));
}
}
}
}
match entry.header().entry_type() {
tar::EntryType::Regular => writer
.stage_file("f.txt", meta, &pax_xattrs, b"body")
.expect("stage"),
other => panic!("unexpected {other:?}"),
}
}
writer.finish().expect("finish")
}
#[test]
fn schily_records_reach_the_inode() {
let artifact = pack_pax_tar(&build_pax_tar());
let mut cursor = ManifestCursor::new(&artifact.bytes);
parse_manifest_header(&mut cursor).expect("header");
parse_feature_flags_section(&mut cursor).expect("flags");
let meta_ref = parse_metadata_reference(&mut cursor).expect("meta");
let inline = meta_ref.inline_metadata.as_ref().expect("inline");
let mut blob_cursor = ManifestCursor::new(inline);
let blob = parse_metadata_blob(&mut blob_cursor).expect("blob");
let file = blob
.inodes
.iter()
.find(|i| i.is_regular())
.expect("file inode");
assert_eq!(file.xattrs.len(), 1);
assert_eq!(file.xattrs[0].key, "user.test");
assert_eq!(file.xattrs[0].value, b"abc");
assert_eq!(file.xattrs[0].namespace, 0);
}
#[test]
fn unrepresentable_xattrs_are_rejected() {
let config: &'static limnifs_write::WriteConfig =
Box::leak(Box::new(limnifs_write::WriteConfig::default_v0_1()));
let mut writer = limnifs_write::stream::StreamWriter::new(config).expect("writer");
let bad = vec![("bad\nkey".to_owned(), b"v".to_vec())];
assert!(
writer
.stage_file("f.txt", EntryMeta::new(0, 0o644), &bad, b"body")
.is_err(),
"newline in key is unrepresentable in pax framing"
);
}
#[test]
fn tar_header_ownership_lands_on_inodes() {
let mut builder = tar::Builder::new(Vec::new());
let mut h = tar::Header::new_gnu();
h.set_size(5);
h.set_mode(0o640);
h.set_uid(1000);
h.set_gid(20);
h.set_mtime(1_700_000_123);
h.set_cksum();
builder
.append_data(&mut h, "owned.txt", &b"hello"[..])
.expect("owned");
let tar_bytes = builder.into_inner().expect("tar");
let artifact = pack_tar(&mut tar_bytes.as_slice());
let blob = parse_blob(&artifact);
let inode = blob
.inodes
.iter()
.find(|i| i.uid == 1000 && i.gid == 20)
.expect("owned inode");
assert_eq!(inode.mode & 0o7777, 0o640);
assert_eq!(inode.mtime_ns, 1_700_000_123 * 1_000_000_000);
}
#[test]
fn appledouble_members_are_skipped() {
let mut builder = tar::Builder::new(Vec::new());
let mut h = tar::Header::new_gnu();
h.set_size(3);
h.set_mode(0o644);
h.set_cksum();
builder
.append_data(&mut h, "real.txt", &b"abc"[..])
.expect("real");
let mut ad = tar::Header::new_gnu();
ad.set_size(4);
ad.set_mode(0o644);
ad.set_cksum();
builder
.append_data(&mut ad, "._real.txt", &b"junk"[..])
.expect("appledouble");
let tar_bytes = builder.into_inner().expect("tar");
let artifact = pack_tar(&mut tar_bytes.as_slice());
let tree = collect_tree(&artifact);
assert!(tree.contains_key("real.txt"), "real content packed");
assert!(
!tree.keys().any(|k| k.starts_with("._")),
"AppleDouble members must not become tree content: {:?}",
tree.keys().collect::<Vec<_>>()
);
}