use anyhow::{Context, Result};
use cadmpeg_container::compound::{CompoundAllocation, CompoundEntry, CompoundSnapshot};
use cadmpeg_container::{ArchiveSnapshot, EntryRecord};
use cadmpeg_core::decode::{DecodeArena, DecodeContext, DecodePolicy, ResourceLimits};
const CFB_MAGIC: [u8; 8] = [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1];
enum ContainerKind {
Cfb,
Zip,
}
fn detect(bytes: &[u8]) -> ContainerKind {
if bytes.starts_with(&CFB_MAGIC) {
ContainerKind::Cfb
} else {
ContainerKind::Zip
}
}
fn allocation_label(allocation: Option<CompoundAllocation>) -> Option<&'static str> {
allocation.map(|allocation| match allocation {
CompoundAllocation::Regular => "fat",
CompoundAllocation::Mini => "mini-fat",
})
}
pub(in crate::inspect) enum Listing {
Zip(Vec<EntryRecord>),
Cfb(Vec<CompoundEntry>),
}
pub(super) fn list(bytes: &[u8], limits: ResourceLimits) -> Result<Listing> {
let arena = DecodeArena::new();
let policy = DecodePolicy {
limits,
..DecodePolicy::default()
};
let (ctx, root) = DecodeContext::from_root_bytes(bytes, &arena, &policy)
.context("the file does not fit the resource-limit profile")?;
match detect(bytes) {
ContainerKind::Cfb => {
let snapshot =
CompoundSnapshot::new(&ctx, root).context("reading the CFB directory")?;
Ok(Listing::Cfb(snapshot.entries().to_vec()))
}
ContainerKind::Zip => {
let snapshot =
ArchiveSnapshot::new(&ctx, root).context("reading the ZIP central directory")?;
Ok(Listing::Zip(snapshot.entries().to_vec()))
}
}
}
pub(super) fn extract(bytes: &[u8], limits: ResourceLimits, name: &str) -> Result<Vec<u8>> {
let arena = DecodeArena::new();
let policy = DecodePolicy {
limits,
..DecodePolicy::default()
};
let (ctx, root) = DecodeContext::from_root_bytes(bytes, &arena, &policy)
.context("the file does not fit the resource-limit profile")?;
match detect(bytes) {
ContainerKind::Cfb => {
let snapshot =
CompoundSnapshot::new(&ctx, root).context("reading the CFB directory")?;
let entry = snapshot.stream(name).ok_or_else(|| {
anyhow::anyhow!("{}", missing_compound_member_message(&snapshot, name))
})?;
let view = snapshot
.open(&ctx, entry)
.with_context(|| format!("opening stream {}", shell_quote(name)))?;
Ok(view.window().to_vec())
}
ContainerKind::Zip => {
let snapshot =
ArchiveSnapshot::new(&ctx, root).context("reading the ZIP central directory")?;
let entry = snapshot
.entry(name)
.ok_or_else(|| anyhow::anyhow!("{}", missing_member_message(&snapshot, name)))?;
let view = snapshot
.open(&ctx, &entry.name)
.with_context(|| format!("opening entry {}", shell_quote(name)))?;
Ok(view.window().to_vec())
}
}
}
fn missing_compound_member_message(snapshot: &CompoundSnapshot<'_>, name: &str) -> String {
const SHOWN: usize = 10;
let lower = name.to_lowercase();
let mut label = "close stream names";
let mut names = snapshot
.entries()
.iter()
.filter(|entry| matches!(entry, CompoundEntry::Stream(_)))
.filter(|entry| {
entry.path().to_lowercase().contains(&lower)
|| entry.path().rsplit('/').next() == Some(name)
})
.take(SHOWN)
.map(|entry| shell_quote(entry.path()))
.collect::<Vec<_>>();
if names.is_empty() {
label = "streams include";
names = snapshot
.entries()
.iter()
.filter(|entry| matches!(entry, CompoundEntry::Stream(_)))
.take(SHOWN)
.map(|entry| shell_quote(entry.path()))
.collect();
}
format!(
"no stream is named exactly {}; {label}: {}; run `cadmpeg inspect FILE` for the full list",
shell_quote(name),
names.join(", ")
)
}
fn missing_member_message(snapshot: &ArchiveSnapshot<'_>, name: &str) -> String {
const SHOWN: usize = 10;
let lower = name.to_lowercase();
let mut label = "close names";
let mut names: Vec<String> = snapshot
.entries()
.iter()
.filter(|entry| {
entry.name.to_lowercase().contains(&lower)
|| entry.name.rsplit('/').next() == Some(name)
})
.take(SHOWN)
.map(|entry| shell_quote(&entry.name))
.collect();
if names.is_empty() {
label = "entries include";
names = snapshot
.entries()
.iter()
.take(SHOWN)
.map(|entry| shell_quote(&entry.name))
.collect();
}
format!(
"no entry is named exactly {}; {label}: {}; run `cadmpeg inspect container FILE` \
for the full list",
shell_quote(name),
names.join(", ")
)
}
fn shell_quote(name: &str) -> String {
let mut out = String::with_capacity(name.len() + 2);
out.push('\'');
for c in name.chars() {
if c == '\'' {
out.push_str("'\\''");
} else {
out.push(c);
}
}
out.push('\'');
out
}
pub(super) fn render_json(listing: &Listing) -> Result<String> {
let (container_kind, entries): (&str, Vec<serde_json::Value>) = match listing {
Listing::Zip(entries) => (
"zip",
entries
.iter()
.map(|entry| {
serde_json::json!({
"name": entry.name,
"compression": entry.compression.label(),
"crc32": entry.crc32,
"compressed_size": entry.compressed_size,
"uncompressed_size": entry.uncompressed_size,
"header_start": entry.header_start,
"data_start": entry.data_start,
"central_start": entry.central_start,
})
})
.collect(),
),
Listing::Cfb(rows) => (
"cfb",
rows.iter()
.map(|entry| match entry {
CompoundEntry::Storage(storage) => serde_json::json!({
"kind": "storage",
"path": storage.path(),
"size": null,
"allocation": null,
"directory_id": entry.directory_id(),
}),
CompoundEntry::Stream(stream) => serde_json::json!({
"kind": "stream",
"path": stream.path(),
"size": stream.logical_size(),
"allocation": allocation_label(stream.allocation()),
"directory_id": entry.directory_id(),
}),
})
.collect(),
),
};
let payload = serde_json::json!({
"subcommand": "container",
"container_kind": container_kind,
"entries": entries,
});
let mut rendered = crate::commands::reporting::command_report_json("inspect", &payload)?;
rendered.push('\n');
Ok(rendered)
}
pub(super) fn render(listing: &Listing) -> String {
let mut rows = Vec::new();
match listing {
Listing::Zip(entries) => {
rows.push(format!(
"{:>10} {:>10} {:>12} {:>12} {:>8} {:>10} name",
"header", "data", "packed", "unpacked", "method", "crc32"
));
for entry in entries {
rows.push(format!(
"0x{:08x} {:>10} {:>12} {:>12} {:>8} 0x{:08x} {}",
entry.header_start,
entry
.data_start
.map_or_else(|| "unreadable".into(), |offset| format!("0x{offset:08x}")),
entry.compressed_size,
entry.uncompressed_size,
entry.compression.label(),
entry.crc32,
shell_quote(&entry.name)
));
}
}
Listing::Cfb(entries) => {
rows.push(format!(
"{:>4} {:>8} {:>12} {:>8} path",
"id", "kind", "size", "alloc"
));
for entry in entries {
let (kind, size, allocation) = match entry {
CompoundEntry::Storage(_) => ("storage", String::new(), ""),
CompoundEntry::Stream(stream) => (
"stream",
stream.logical_size().to_string(),
allocation_label(stream.allocation()).unwrap_or(""),
),
};
rows.push(format!(
"{:>4} {:>8} {:>12} {:>8} {}",
entry.directory_id(),
kind,
size,
allocation,
shell_quote(entry.path())
));
}
}
}
let mut out = rows.join("\n");
out.push('\n');
out
}
#[cfg(test)]
mod tests {
use super::{extract, list, shell_quote, Listing};
use cadmpeg_container::compound::CompoundEntry;
use cadmpeg_core::decode::ResourceLimits;
use cadmpeg_test_support::compound::compound_fixture;
#[test]
fn quotes_plain_names() {
assert_eq!(shell_quote("Document.xml"), "'Document.xml'");
}
#[test]
fn quotes_bracketed_and_spaced_names() {
assert_eq!(
shell_quote("FusionAssetName[Active]/Design.dat"),
"'FusionAssetName[Active]/Design.dat'"
);
assert_eq!(shell_quote("a b"), "'a b'");
assert_eq!(shell_quote("$HOME`x`"), "'$HOME`x`'");
}
#[test]
fn closes_reopens_around_an_embedded_single_quote() {
assert_eq!(shell_quote("it's"), "'it'\\''s'");
}
#[test]
fn extracts_a_compound_stream_by_exact_path() {
let file = compound_fixture();
assert_eq!(
extract(&file, ResourceLimits::desktop(), "Payload")
.expect("synthetic CFB stream extracts"),
vec![0x5a; 4096]
);
}
#[test]
fn zip_member_past_the_zstd_window_allowance_names_the_allowance() {
use cadmpeg_test_support::zip_fixture::{self, RawEntry};
let file = zip_fixture::archive(&[RawEntry::compressed(
"streamed.bin",
zip_fixture::ZSTD,
&zip_fixture::zstd_streamed_frame(27, b"payload"),
b"payload",
)]);
let error = extract(&file, ResourceLimits::desktop(), "streamed.bin")
.expect_err("the member cannot be expanded");
let message = format!("{error:#}");
assert!(
message.starts_with("opening entry 'streamed.bin': ")
&& message.ends_with("per-expansion allowance"),
"{message}"
);
}
#[test]
fn lists_compound_storages_and_streams() {
let file = compound_fixture();
let Listing::Cfb(rows) =
list(&file, ResourceLimits::desktop()).expect("synthetic CFB lists")
else {
panic!("expected a CFB listing");
};
let payload = rows
.iter()
.find(|entry| entry.path() == "Payload")
.expect("Payload row");
assert!(matches!(payload, CompoundEntry::Stream(stream) if stream.logical_size() == 4096));
}
}