use std::fmt::Write as _;
use anyhow::{Context, Result};
use cadmpeg_container::compound::{CompoundEntry, CompoundSnapshot};
use cadmpeg_container::{ArchiveSnapshot, EntryRecord};
use cadmpeg_core::decode::{DecodeArena, DecodeContext, DecodePolicy, ResourceLimits};
pub fn list(bytes: &[u8], limits: ResourceLimits) -> Result<Vec<EntryRecord>> {
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")?;
let snapshot = ArchiveSnapshot::new(root).context("reading the ZIP central directory")?;
Ok(snapshot.entries().to_vec())
}
pub 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")?;
if bytes.starts_with(&[0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]) {
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)))?;
return Ok(view.window().to_vec());
}
let snapshot = ArchiveSnapshot::new(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)
.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(", ")
)
}
pub 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 fn render_json(entries: &[EntryRecord]) -> String {
let entries: Vec<serde_json::Value> = 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();
let envelope = serde_json::json!({
"schema_version": crate::commands::CLI_SCHEMA_VERSION,
"command": "inspect container",
"status": "ok",
"refusal": null,
"entries": entries,
});
let mut rendered = serde_json::to_string_pretty(&envelope).expect("the envelope serializes");
rendered.push('\n');
rendered
}
pub fn render(entries: &[EntryRecord]) -> String {
let mut out = String::new();
let _ = writeln!(
out,
"{:>10} {:>10} {:>12} {:>12} {:>8} {:>10} name",
"header", "data", "packed", "unpacked", "method", "crc32"
);
for entry in entries {
let _ = writeln!(
out,
"0x{:08x} 0x{:08x} {:>12} {:>12} {:>8} 0x{:08x} {}",
entry.header_start,
entry.data_start,
entry.compressed_size,
entry.uncompressed_size,
entry.compression.label(),
entry.crc32,
shell_quote(&entry.name)
);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
const CFB_SECTOR: usize = 512;
const CFB_FREE: u32 = 0xffff_ffff;
const CFB_END: u32 = 0xffff_fffe;
const CFB_FAT: u32 = 0xffff_fffd;
#[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]
);
}
fn compound_fixture() -> Vec<u8> {
let mut file = vec![0_u8; CFB_SECTOR * 11];
file[..8].copy_from_slice(&[0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]);
put_u16(&mut file, 24, 0x003e);
put_u16(&mut file, 26, 3);
put_u16(&mut file, 28, 0xfffe);
put_u16(&mut file, 30, 9);
put_u16(&mut file, 32, 6);
put_u32(&mut file, 44, 1);
put_u32(&mut file, 48, 0);
put_u32(&mut file, 56, 4096);
put_u32(&mut file, 60, CFB_END);
put_u32(&mut file, 68, CFB_END);
for index in 0..109 {
put_u32(&mut file, 76 + index * 4, CFB_FREE);
}
put_u32(&mut file, 76, 9);
let directory = sector_mut(&mut file, 0);
for entry in directory.chunks_exact_mut(128) {
entry[68..80].fill(0xff);
}
directory_entry(directory, 0, "Root Entry", 5, 1, CFB_END, 0);
directory_entry(directory, 1, "Payload", 2, CFB_FREE, 1, 4096);
for sector in 1..=8 {
sector_mut(&mut file, sector).fill(0x5a);
}
let fat = sector_mut(&mut file, 9);
fat.fill(0xff);
put_u32(fat, 0, CFB_END);
for sector in 1..8 {
put_u32(fat, sector * 4, (sector + 1) as u32);
}
put_u32(fat, 8 * 4, CFB_END);
put_u32(fat, 9 * 4, CFB_FAT);
file
}
fn directory_entry(
directory: &mut [u8],
index: usize,
name: &str,
object_type: u8,
child: u32,
start: u32,
size: u64,
) {
let entry = &mut directory[index * 128..(index + 1) * 128];
let units = name.encode_utf16().collect::<Vec<_>>();
for (offset, unit) in units.iter().enumerate() {
put_u16(entry, offset * 2, *unit);
}
put_u16(entry, 64, ((units.len() + 1) * 2) as u16);
entry[66] = object_type;
entry[67] = 1;
put_u32(entry, 68, CFB_FREE);
put_u32(entry, 72, CFB_FREE);
put_u32(entry, 76, child);
put_u32(entry, 116, start);
entry[120..128].copy_from_slice(&size.to_le_bytes());
}
fn sector_mut(file: &mut [u8], sector: usize) -> &mut [u8] {
let start = (sector + 1) * CFB_SECTOR;
&mut file[start..start + CFB_SECTOR]
}
fn put_u16(bytes: &mut [u8], offset: usize, value: u16) {
bytes[offset..offset + 2].copy_from_slice(&value.to_le_bytes());
}
fn put_u32(bytes: &mut [u8], offset: usize, value: u32) {
bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
}
}