pub mod container;
pub mod diff;
pub mod hexdump;
pub mod layout;
pub mod numeric;
pub mod search;
use std::fs::File;
use std::io::{Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use clap::{Args, Subcommand};
use crate::LimitProfile;
use numeric::{parse_offset, EndianArgs, ScalarType};
const DEFAULT_HEX_LEN: u64 = 256;
#[derive(Debug, Subcommand)]
pub enum ByteCommand {
#[command(flatten)]
Tool(ByteTool),
#[command(hide = true)]
Bytes {
#[command(subcommand)]
tool: ByteTool,
},
}
#[derive(Debug, Subcommand)]
pub enum ByteTool {
Hex(HexArgs),
Read(ReadArgs),
Find(FindArgs),
Strings(StringsArgs),
Struct(StructArgs),
Container(ContainerArgs),
Extract(ExtractArgs),
Diff(DiffArgs),
}
#[derive(Debug, Args)]
pub struct FileArg {
#[arg(value_name = "FILE", required_unless_present = "input_flag")]
pub file: Option<PathBuf>,
#[arg(
long = "input",
value_name = "FILE",
hide = true,
conflicts_with = "file"
)]
pub input_flag: Option<PathBuf>,
}
impl FileArg {
pub fn path(&self) -> &Path {
self.file
.as_deref()
.or(self.input_flag.as_deref())
.expect("clap requires one file spelling")
}
}
#[derive(Debug, Args)]
pub struct HexArgs {
#[command(flatten)]
pub file: FileArg,
#[arg(long, alias = "start", default_value = "0", value_parser = parse_offset)]
pub offset: u64,
#[arg(long, visible_alias = "length", value_parser = parse_offset)]
pub len: Option<u64>,
#[arg(long, default_value_t = 16)]
pub width: usize,
#[arg(long, hide = true)]
pub json: bool,
}
#[derive(Debug, Args)]
pub struct ReadArgs {
#[command(flatten)]
pub file: FileArg,
#[arg(long = "type", value_parser = numeric::ScalarTypeParser)]
pub ty: ScalarType,
#[arg(long, alias = "start", default_value = "0", value_parser = parse_offset)]
pub offset: u64,
#[arg(short = 'n', long, default_value_t = 1)]
pub count: u64,
#[arg(long, alias = "step", value_parser = parse_offset)]
pub stride: Option<u64>,
#[command(flatten)]
pub endian: EndianArgs,
#[arg(long, hide = true)]
pub json: bool,
}
#[derive(Debug, Args)]
#[command(group(clap::ArgGroup::new("needle").args(["hex", "ascii", "utf16le"])))]
pub struct FindArgs {
#[arg(value_name = "FILE", required_unless_present = "input_flag")]
pub file: Option<PathBuf>,
#[arg(long = "input", value_name = "FILE", hide = true)]
pub input_flag: Option<PathBuf>,
#[arg(hide = true)]
pub misplaced_pattern: Option<String>,
#[arg(long)]
pub hex: Option<String>,
#[arg(long)]
pub ascii: Option<String>,
#[arg(long)]
pub utf16le: Option<String>,
#[arg(long, default_value_t = 100)]
pub max: usize,
#[arg(long, default_value = "0", value_parser = parse_offset)]
pub context: u64,
#[arg(long = "type", hide = true)]
pub misplaced_type: Option<String>,
#[arg(long, conflicts_with = "context")]
pub json: bool,
}
#[derive(Debug, Args)]
pub struct StringsArgs {
#[command(flatten)]
pub file: FileArg,
#[arg(
long,
visible_alias = "min-len",
alias = "min-length",
default_value_t = 4
)]
pub min: usize,
#[arg(long, value_enum, default_value_t = search::StringEncoding::Ascii)]
pub encoding: search::StringEncoding,
#[arg(long, hide = true)]
pub json: bool,
}
#[derive(Debug, Args)]
pub struct StructArgs {
#[command(flatten)]
pub file: FileArg,
#[arg(long)]
pub layout: String,
#[arg(long, alias = "start", default_value = "0", value_parser = parse_offset)]
pub offset: u64,
#[arg(short = 'n', long, default_value_t = 1)]
pub count: u64,
#[arg(long, hide = true)]
pub json: bool,
}
#[derive(Debug, Args)]
pub struct ContainerArgs {
#[command(flatten)]
pub file: FileArg,
#[arg(long)]
pub json: bool,
#[arg(long, value_enum, default_value_t = LimitProfile::Desktop)]
pub limits: LimitProfile,
}
#[derive(Debug, Args)]
pub struct ExtractArgs {
pub file: PathBuf,
pub member: String,
#[arg(short = 'o', long)]
pub output: Option<PathBuf>,
#[arg(long)]
pub force: bool,
#[arg(long, value_enum, default_value_t = LimitProfile::Desktop)]
pub limits: LimitProfile,
#[arg(long, hide = true)]
pub json: bool,
}
#[derive(Debug, Args)]
pub struct DiffArgs {
pub a: PathBuf,
pub b: PathBuf,
#[arg(long, default_value_t = 8)]
pub gap: u64,
#[arg(long, default_value_t = 32)]
pub max_runs: usize,
#[arg(long, default_value = "32", value_parser = parse_offset)]
pub context: u64,
#[arg(long, hide = true)]
pub json: bool,
}
pub fn run(command: ByteCommand) -> Result<()> {
let tool = match command {
ByteCommand::Tool(tool) | ByteCommand::Bytes { tool } => tool,
};
match tool {
ByteTool::Hex(args) => hex(&args),
ByteTool::Read(args) => read(&args),
ByteTool::Find(args) => find(&args),
ByteTool::Strings(args) => strings(&args),
ByteTool::Struct(args) => structure(&args),
ByteTool::Container(args) => container_list(&args),
ByteTool::Extract(args) => extract_entry(&args),
ByteTool::Diff(args) => diff_files(&args),
}
}
fn file_len(path: &Path) -> Result<u64> {
let metadata = std::fs::metadata(path)
.with_context(|| format!("reading metadata for {}", path.display()))?;
Ok(metadata.len())
}
fn read_window(path: &Path, offset: u64, len: u64) -> Result<Vec<u8>> {
let size = file_len(path)?;
if offset > size {
bail!(
"offset 0x{offset:x} ({offset}) is past the end of {}, which is 0x{size:x} ({size}) bytes",
path.display()
);
}
let available = size - offset;
let want = usize::try_from(len.min(available))
.context("the requested length does not fit in memory on this target")?;
let mut file = File::open(path).with_context(|| format!("opening {}", path.display()))?;
file.seek(SeekFrom::Start(offset))
.with_context(|| format!("seeking to 0x{offset:x} in {}", path.display()))?;
let mut buffer = vec![0u8; want];
file.read_exact(&mut buffer).with_context(|| {
format!(
"reading {want} bytes at 0x{offset:x} from {}",
path.display()
)
})?;
Ok(buffer)
}
fn read_whole(path: &Path) -> Result<Vec<u8>> {
std::fs::read(path).with_context(|| format!("reading {}", path.display()))
}
fn hex(args: &HexArgs) -> Result<()> {
if args.json {
bail!(
"`inspect hex` has no JSON form; JSON lives on `inspect FILE --json` \
(the container summary), `inspect container --json`, and `inspect \
find --json`"
);
}
if args.width == 0 {
bail!("--width must be at least 1");
}
let len = args.len.unwrap_or(DEFAULT_HEX_LEN);
let bytes = read_window(args.file.path(), args.offset, len)?;
if bytes.is_empty() {
println!("(no bytes at 0x{:x})", args.offset);
return Ok(());
}
print!("{}", hexdump::render(args.offset, &bytes, args.width));
Ok(())
}
fn read(args: &ReadArgs) -> Result<()> {
if args.json {
bail!(
"`inspect read` has no JSON form; JSON lives on `inspect FILE --json` \
(the container summary), `inspect container --json`, and `inspect \
find --json`"
);
}
let width = args.ty.width() as u64;
let stride = args.stride.unwrap_or(width);
if args.count == 0 {
return Ok(());
}
if stride == 0 {
bail!("--stride 0 would read the same bytes forever");
}
let endian = args.endian.endian();
let file_path = args.file.path();
let size = file_len(file_path)?;
let name = args.ty.display_name(endian);
let mut file =
File::open(file_path).with_context(|| format!("opening {}", file_path.display()))?;
for index in 0..args.count {
let offset = index
.checked_mul(stride)
.and_then(|step| args.offset.checked_add(step))
.context("the strided offset overflows 64 bits")?;
let end = offset
.checked_add(width)
.context("the read overflows 64 bits")?;
if end > size {
bail!(
"value {index} needs bytes 0x{offset:x}..0x{end:x}, past the end of {} at 0x{size:x}",
file_path.display()
);
}
file.seek(SeekFrom::Start(offset))?;
let mut buffer = [0u8; 8];
let slot = &mut buffer[..width as usize];
file.read_exact(slot)?;
let value = args.ty.read(slot, endian);
println!(
"0x{offset:08x} {name:<6} {:<24} {}",
value.decimal(),
value.hex(args.ty)
);
}
Ok(())
}
fn find(args: &FindArgs) -> Result<()> {
let (file, misplaced) = match (&args.input_flag, &args.file) {
(Some(input), Some(stray)) => (input, Some(stray.display().to_string())),
(Some(input), None) => (input, args.misplaced_pattern.clone()),
(None, Some(file)) => (file, args.misplaced_pattern.clone()),
(None, None) => unreachable!("clap requires one file spelling"),
};
if let Some(stray) = &misplaced {
bail!(
"`{stray}` is an extra positional argument; the search pattern is named by a flag \
because a bare word does not say how to encode it: pass `--hex {stray}` for a byte \
pattern, `--ascii {stray}` for text, or `--utf16le {stray}` for UTF-16LE text"
);
}
if let Some(guessed) = &args.misplaced_type {
let flag = match guessed.to_ascii_lowercase().as_str() {
"hex" | "bytes" => "--hex PATTERN",
text if text.starts_with("utf16") || text.starts_with("utf-16") => "--utf16le TEXT",
_ => "--ascii TEXT",
};
bail!(
"`--type {guessed}` does not select an encoding here; `find` names the pattern \
encoding by flag: pass `{flag}` (the choices are --hex, --ascii, and --utf16le)"
);
}
let (pattern, described) = if let Some(text) = &args.hex {
(search::parse_pattern(text), format!("hex {text}"))
} else if let Some(text) = &args.ascii {
(search::ascii_pattern(text), format!("ascii {text:?}"))
} else if let Some(text) = &args.utf16le {
(search::utf16le_pattern(text), format!("utf16le {text:?}"))
} else {
bail!("pass one of --hex, --ascii, or --utf16le")
};
let pattern = pattern.map_err(|message| anyhow::anyhow!(message))?;
let bytes = read_whole(file)?;
let limit = (args.max > 0).then_some(args.max);
let hits = search::find_all(&bytes, &pattern, limit);
let truncated = limit.is_some_and(|max| hits.len() >= max);
if args.json {
let envelope = serde_json::json!({
"schema_version": crate::commands::CLI_SCHEMA_VERSION,
"command": "inspect find",
"status": "ok",
"refusal": null,
"pattern": described,
"pattern_bytes": pattern.len(),
"truncated": truncated,
"hits": hits,
});
println!(
"{}",
serde_json::to_string_pretty(&envelope).expect("envelope serializes")
);
return Ok(());
}
println!(
"pattern: {described} ({} bytes) hits: {}{}",
pattern.len(),
hits.len(),
if truncated {
" (truncated by --max)"
} else {
""
}
);
for offset in &hits {
println!("0x{offset:08x} {offset}");
if args.context > 0 {
let start = offset.saturating_sub(args.context);
let len = args
.context
.saturating_mul(2)
.saturating_add(pattern.len() as u64);
print!("{}", window(&bytes, start, len));
}
}
if truncated {
println!(
"note: output truncated at {} matches; pass --max 0 for all",
args.max
);
}
Ok(())
}
fn strings(args: &StringsArgs) -> Result<()> {
if args.json {
bail!(
"`inspect strings` has no JSON form; JSON lives on `inspect FILE --json` \
(the container summary), `inspect container --json`, and `inspect \
find --json`"
);
}
if args.min == 0 {
bail!("--min must be at least 1");
}
let bytes = read_whole(args.file.path())?;
for found in search::extract_strings(&bytes, args.min, args.encoding) {
println!(
"0x{:08x} {:<8} \"{}\"",
found.offset,
found.encoding.label(),
search::escape(&found.text)
);
}
Ok(())
}
fn structure(args: &StructArgs) -> Result<()> {
if args.json {
bail!(
"`inspect struct` has no JSON form; JSON lives on `inspect FILE --json` \
(the container summary), `inspect container --json`, and `inspect \
find --json`"
);
}
let layout = layout::Layout::parse(&args.layout)?;
if args.count == 0 {
return Ok(());
}
let file_path = args.file.path();
let size = file_len(file_path)?;
let record_size = layout.size as u64;
let span = record_size
.checked_mul(args.count)
.and_then(|total| args.offset.checked_add(total))
.context("the requested records overflow a 64-bit offset")?;
if span > size {
bail!(
"{} records of {record_size} bytes at 0x{:x} need 0x{span:x} bytes, \
but {} is 0x{size:x} bytes",
args.count,
args.offset,
file_path.display()
);
}
let bytes = read_window(file_path, args.offset, span - args.offset)?;
let name_width = layout
.fields
.iter()
.map(|field| field.name.len())
.max()
.unwrap_or(1);
for index in 0..args.count {
let start = (index * record_size) as usize;
let record = &bytes[start..start + layout.size];
let base = args.offset + index * record_size;
println!("record {index} @ 0x{base:08x} ({record_size} bytes)");
for field in layout.decode(record) {
let at = base + field.offset as u64;
println!(
" 0x{at:08x} {:<name_width$} {:<8} {:<24} {}",
field.name, field.type_name, field.decimal, field.hex
);
}
}
Ok(())
}
fn container_list(args: &ContainerArgs) -> Result<()> {
let file_path = args.file.path();
let bytes = read_whole(file_path)?;
let entries = container::list(&bytes, args.limits.limits()).with_context(|| {
format!(
"cannot list {} as a ZIP container; `cadmpeg inspect {}` reads \
the other container families through their codec",
file_path.display(),
file_path.display()
)
})?;
if args.json {
print!("{}", container::render_json(&entries));
} else {
print!("{}", container::render(&entries));
}
Ok(())
}
fn extract_entry(args: &ExtractArgs) -> Result<()> {
if args.json {
bail!(
"`inspect extract` has no JSON form; JSON lives on `inspect FILE --json` \
(the container summary), `inspect container --json`, and `inspect \
find --json`"
);
}
let bytes = read_whole(&args.file)?;
let payload = container::extract(&bytes, args.limits.limits(), &args.member)
.with_context(|| format!("extracting from {}", args.file.display()))?;
match &args.output {
None => write_payload_to_stdout(&payload),
Some(path) if path == Path::new("-") => write_payload_to_stdout(&payload),
Some(path) => {
if path.exists() && !args.force {
bail!("{} exists; pass --force to replace it", path.display());
}
std::fs::write(path, &payload)
.with_context(|| format!("writing {}", path.display()))?;
Ok(())
}
}
}
fn write_payload_to_stdout(payload: &[u8]) -> Result<()> {
use std::io::Write as _;
std::io::stdout()
.lock()
.write_all(payload)
.context("writing the entry to standard output")
}
fn diff_files(args: &DiffArgs) -> Result<()> {
if args.json {
bail!(
"`inspect diff` has no JSON form; JSON lives on `inspect FILE --json` \
(the container summary), `inspect container --json`, and `inspect \
find --json`"
);
}
let a = read_whole(&args.a)?;
let b = read_whole(&args.b)?;
let summary = diff::compare(&a, &b, args.gap);
println!(
"a: {} ({} bytes)\nb: {} ({} bytes)",
args.a.display(),
summary.len_a,
args.b.display(),
summary.len_b
);
if summary.identical() {
println!("identical");
return Ok(());
}
if summary.len_a != summary.len_b {
println!(
"length differs by {} bytes; only the first {} bytes are compared",
summary.len_a.abs_diff(summary.len_b),
summary.compared
);
}
let Some(first) = summary.first else {
println!("the common prefix is identical");
return Ok(());
};
println!(
"first difference: 0x{first:08x} ({first})\ndiffering bytes: {} of {}\nruns (gap {}): {}",
summary.differing,
summary.compared,
args.gap,
summary.runs.len()
);
let shown = if args.max_runs == 0 {
summary.runs.len()
} else {
args.max_runs.min(summary.runs.len())
};
for run in &summary.runs[..shown] {
println!(
" 0x{:08x}..0x{:08x} {} bytes",
run.start,
run.end(),
run.len
);
}
if shown < summary.runs.len() {
println!(
" … {} more runs (raise --max-runs)",
summary.runs.len() - shown
);
}
if args.context > 0 {
let window_start = first.saturating_sub(args.context / 2);
println!("\na @ 0x{window_start:x}:");
print!("{}", window(&a, window_start, args.context));
println!("b @ 0x{window_start:x}:");
print!("{}", window(&b, window_start, args.context));
}
Ok(())
}
fn window(bytes: &[u8], start: u64, len: u64) -> String {
let begin = usize::try_from(start)
.unwrap_or(usize::MAX)
.min(bytes.len());
let end = usize::try_from(start.saturating_add(len))
.unwrap_or(usize::MAX)
.min(bytes.len());
hexdump::render(begin as u64, &bytes[begin..end], 16)
}