cadmpeg 0.6.1

Convert and inspect native CAD files.
// SPDX-License-Identifier: Apache-2.0
//! Container member listing (ZIP or CFB) and exact member extraction.

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
    }
}

/// An empty stream owns no sectors, so it has no allocation.
fn allocation_label(allocation: Option<CompoundAllocation>) -> Option<&'static str> {
    allocation.map(|allocation| match allocation {
        CompoundAllocation::Regular => "fat",
        CompoundAllocation::Mini => "mini-fat",
    })
}

/// Container members listed from a ZIP archive or a CFB file.
pub(in crate::inspect) enum Listing {
    /// ZIP central-directory entries.
    Zip(Vec<EntryRecord>),
    /// CFB directory rows (storages and streams).
    Cfb(Vec<CompoundEntry>),
}

/// Lists ZIP entries or CFB directory members in `bytes`.
///
/// # Errors
///
/// Returns an error when the bytes exceed the resource-limit profile or the
/// ZIP central directory or CFB directory does not parse.
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()))
        }
    }
}

/// Extracts one ZIP entry or CFB stream.
///
/// The archive `ctx` and arena stay alive in this scope until the view's
/// bytes are copied out; `list` drops them, which is why extraction cannot
/// reuse it.
///
/// # Errors
///
/// Returns an error when the bytes are not a supported container within the
/// limit profile, when no stream or entry has exactly `name`, or when opening
/// the member fails structural, size, or integrity checks.
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(", ")
    )
}

/// Builds the error text for a member name with no exact match.
///
/// Names that contain the request case-insensitively, or whose final path
/// component equals it, are suggested first; with no near-miss the first
/// entries are listed instead. Every name is shell-quoted the way the
/// listing prints it.
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(", ")
    )
}

/// Quotes an entry name so it survives a POSIX shell verbatim.
///
/// Fusion `.f3d` entry names hold `[` and `]`, which a shell expands as a glob
/// character class. Single quotes suppress every expansion, and an embedded
/// single quote is closed, escaped, and reopened.
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
}

/// Formats an entry listing as the JSON command-report envelope.
///
/// Names are raw strings here — shell quoting belongs to the table
/// rendering, not to JSON.
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)
}

/// Formats an entry listing as an aligned table.
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() {
        // 'it'\''s' concatenates to the four characters it's in a POSIX shell.
        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 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));
    }
}