mod container;
mod diff;
mod hexdump;
mod layout;
mod numeric;
mod search;
use std::fs::File;
use std::io::{Read, Seek, SeekFrom};
use std::num::{NonZeroU64, NonZeroUsize};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use crate::application::artifact_store::OptionalFileDestination;
use anyhow::{anyhow, bail, Context, Result};
use cadmpeg_core::decode::{DecodeArena, DecodeContext, DecodePolicy};
use clap::{Args, Subcommand, ValueEnum};
use crate::LimitProfile;
use numeric::{parse_offset, Endian, ScalarType};
const DEFAULT_HEX_LEN: u64 = 256;
#[derive(Debug)]
pub(crate) enum InspectArgs {
Summary(SummaryArgs),
Bytes(ByteCommand),
}
#[derive(Debug, Args)]
#[command(mut_arg("output", |arg| arg.long("report").visible_alias("output").help("Write a JSON report to this file")))]
pub(crate) struct SummaryArgs {
#[command(flatten)]
pub(crate) file: FileArg,
#[arg(long)]
pub(crate) json: bool,
#[command(flatten)]
pub(crate) report: OptionalFileDestination,
#[arg(long, value_enum, default_value_t = LimitProfile::Desktop)]
pub(crate) limits: LimitProfile,
#[arg(long)]
pub(crate) max_input_bytes: Option<u64>,
#[arg(long, visible_alias = "from", value_parser = crate::input_format::native_input_parser())]
pub(crate) input_format: Option<&'static cadmpeg_registry::NativeDescriptor>,
}
impl clap::Args for InspectArgs {
fn augment_args(command: clap::Command) -> clap::Command {
ByteCommand::augment_subcommands(SummaryArgs::augment_args(command))
}
fn augment_args_for_update(command: clap::Command) -> clap::Command {
Self::augment_args(command)
}
}
impl clap::FromArgMatches for InspectArgs {
fn from_arg_matches(matches: &clap::ArgMatches) -> Result<Self, clap::Error> {
if matches.subcommand_name().is_some() {
ByteCommand::from_arg_matches(matches).map(Self::Bytes)
} else {
SummaryArgs::from_arg_matches(matches).map(Self::Summary)
}
}
fn update_from_arg_matches(&mut self, matches: &clap::ArgMatches) -> Result<(), clap::Error> {
*self = Self::from_arg_matches(matches)?;
Ok(())
}
}
#[derive(Debug, Subcommand)]
pub(crate) enum ByteCommand {
#[command(flatten)]
Tool(ByteTool),
#[command(hide = true)]
Bytes {
#[command(subcommand)]
tool: ByteTool,
},
}
#[derive(Debug, Subcommand)]
pub(crate) enum ByteTool {
Hex(HexArgs),
Read(ReadArgs),
Find(FindArgs),
Strings(StringsArgs),
Struct(StructArgs),
Container(ContainerArgs),
Extract(ExtractArgs),
Cmp(CmpArgs),
}
#[derive(Debug)]
pub(crate) struct FileArg {
path: PathBuf,
}
impl FileArg {
pub(crate) fn path(&self) -> &Path {
&self.path
}
}
impl clap::Args for FileArg {
fn augment_args(command: clap::Command) -> clap::Command {
let input = clap::Arg::new("input_flag")
.long("input")
.value_name("FILE")
.help("Tolerated spelling of the positional file")
.hide(true)
.value_parser(clap::value_parser!(PathBuf));
let input = input.conflicts_with("file");
let value_name = if command.get_name() == "inspect" {
"INPUT"
} else {
"FILE"
};
command
.arg(
clap::Arg::new("file")
.value_name(value_name)
.help("File to read")
.required_unless_present("input_flag")
.value_parser(clap::value_parser!(PathBuf)),
)
.arg(input)
}
fn augment_args_for_update(command: clap::Command) -> clap::Command {
Self::augment_args(command)
}
}
impl clap::FromArgMatches for FileArg {
fn from_arg_matches(matches: &clap::ArgMatches) -> Result<Self, clap::Error> {
matches
.get_one::<PathBuf>("file")
.or_else(|| matches.get_one::<PathBuf>("input_flag"))
.cloned()
.map(|path| Self { path })
.ok_or_else(|| {
clap::Error::raw(
clap::error::ErrorKind::MissingRequiredArgument,
"file is required",
)
})
}
fn update_from_arg_matches(&mut self, matches: &clap::ArgMatches) -> Result<(), clap::Error> {
*self = Self::from_arg_matches(matches)?;
Ok(())
}
}
#[derive(Debug, Args)]
pub(crate) struct HexArgs {
#[command(flatten)]
file: FileArg,
#[arg(long, alias = "start", default_value = "0", value_parser = parse_offset)]
offset: u64,
#[arg(long, visible_alias = "length", value_parser = parse_offset)]
len: Option<u64>,
#[arg(long, default_value = "16")]
width: NonZeroUsize,
#[command(flatten)]
_reject_json: crate::reject_json::RejectJson,
}
fn parse_stride(text: &str) -> Result<NonZeroU64, String> {
NonZeroU64::new(parse_offset(text)?).ok_or_else(|| "stride must be at least 1".to_owned())
}
type CountLimit = Option<NonZeroUsize>;
fn parse_limit(text: &str) -> Result<CountLimit, std::num::ParseIntError> {
text.parse::<usize>().map(NonZeroUsize::new)
}
#[derive(Debug, Args)]
pub(crate) struct ReadArgs {
#[command(flatten)]
file: FileArg,
#[arg(long = "type", value_parser = numeric::ScalarTypeParser)]
ty: ScalarType,
#[arg(long, alias = "start", default_value = "0", value_parser = parse_offset)]
offset: u64,
#[arg(short = 'n', long, default_value_t = 1)]
count: u64,
#[arg(long, alias = "step", value_parser = parse_stride)]
stride: Option<NonZeroU64>,
#[arg(long, value_enum, default_value_t = Endian::Le)]
endian: Endian,
#[command(flatten)]
_reject_json: crate::reject_json::RejectJson,
}
#[derive(Debug, Args)]
pub(crate) struct FindArgs {
#[command(flatten)]
input: FindInput,
#[arg(long, value_enum)]
encoding: FindEncoding,
#[arg(long, default_value = "100", value_parser = parse_limit)]
max: CountLimit,
#[arg(long, default_value = "0", value_parser = parse_offset)]
context: u64,
#[arg(long, conflicts_with = "context")]
json: bool,
}
#[derive(Debug)]
struct FindInput {
file: PathBuf,
needle: String,
}
impl clap::Args for FindInput {
fn augment_args(command: clap::Command) -> clap::Command {
command
.arg(clap::Arg::new("search_operands")
.value_name("FILE NEEDLE")
.help("File and pattern, or just the pattern with --input; hex accepts ?? wildcards")
.num_args(1..=2)
.action(clap::ArgAction::Append)
.required(true)
.value_parser(clap::value_parser!(std::ffi::OsString)))
.arg(clap::Arg::new("input_flag")
.long("input")
.value_name("FILE")
.hide(true)
.value_parser(clap::value_parser!(PathBuf)))
}
fn augment_args_for_update(command: clap::Command) -> clap::Command {
Self::augment_args(command)
}
}
impl clap::FromArgMatches for FindInput {
fn from_arg_matches(matches: &clap::ArgMatches) -> Result<Self, clap::Error> {
let operands: Vec<_> = matches
.get_many::<std::ffi::OsString>("search_operands")
.into_iter()
.flatten()
.collect();
let (file, needle) = match (
matches.get_one::<PathBuf>("input_flag"),
operands.as_slice(),
) {
(None, [file, needle]) => (PathBuf::from(file), *needle),
(Some(file), [needle]) => (file.clone(), *needle),
(Some(_), [_, _, ..]) => {
return Err(clap::Error::raw(
clap::error::ErrorKind::ArgumentConflict,
"--input cannot be used with a positional file",
));
}
(None, [_, _, _, ..]) => {
return Err(clap::Error::raw(
clap::error::ErrorKind::TooManyValues,
"expected only FILE and NEEDLE",
));
}
_ => {
return Err(clap::Error::raw(
clap::error::ErrorKind::MissingRequiredArgument,
"required arguments: FILE and NEEDLE",
));
}
};
let needle = needle
.to_str()
.ok_or_else(|| {
clap::Error::raw(
clap::error::ErrorKind::InvalidUtf8,
"NEEDLE must be valid UTF-8",
)
})?
.to_owned();
Ok(Self { file, needle })
}
fn update_from_arg_matches(&mut self, matches: &clap::ArgMatches) -> Result<(), clap::Error> {
*self = Self::from_arg_matches(matches)?;
Ok(())
}
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum FindEncoding {
Hex,
Ascii,
Utf16le,
}
#[derive(Debug, Args)]
pub(crate) struct StringsArgs {
#[command(flatten)]
file: FileArg,
#[arg(
long,
visible_alias = "min-len",
alias = "min-length",
default_value = "4"
)]
min: NonZeroUsize,
#[arg(long, value_enum, default_value_t = search::StringScan::Ascii)]
encoding: search::StringScan,
#[command(flatten)]
_reject_json: crate::reject_json::RejectJson,
}
#[derive(Debug, Args)]
pub(crate) struct StructArgs {
#[command(flatten)]
file: FileArg,
#[arg(long)]
layout: String,
#[arg(long, alias = "start", default_value = "0", value_parser = parse_offset)]
offset: u64,
#[arg(short = 'n', long, default_value_t = 1)]
count: u64,
#[command(flatten)]
_reject_json: crate::reject_json::RejectJson,
}
#[derive(Debug, Args)]
pub(crate) struct ContainerArgs {
#[command(flatten)]
file: FileArg,
#[arg(long)]
json: bool,
#[arg(long, value_enum, default_value_t = LimitProfile::Desktop)]
limits: LimitProfile,
#[arg(long)]
max_input_bytes: Option<u64>,
}
#[derive(Debug, Args)]
pub(crate) struct ExtractArgs {
file: PathBuf,
member: String,
#[command(flatten)]
output: ExtractDestination,
#[arg(long, value_enum, default_value_t = LimitProfile::Desktop)]
limits: LimitProfile,
#[command(flatten)]
_reject_json: crate::reject_json::RejectJson,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum ExtractDestination {
Stdout,
File(crate::application::artifact_store::FileDestination),
}
impl clap::Args for ExtractDestination {
fn augment_args(command: clap::Command) -> clap::Command {
OptionalFileDestination::augment_args(command).mut_arg("output", |arg| {
arg.help("Output file; omit it or pass - for standard output")
})
}
fn augment_args_for_update(command: clap::Command) -> clap::Command {
Self::augment_args(command)
}
}
impl clap::FromArgMatches for ExtractDestination {
fn from_arg_matches(matches: &clap::ArgMatches) -> Result<Self, clap::Error> {
Ok(
match OptionalFileDestination::from_arg_matches(matches)?.0 {
Some(file) if file.path != Path::new("-") => Self::File(file),
Some(_) | None => Self::Stdout,
},
)
}
fn update_from_arg_matches(&mut self, matches: &clap::ArgMatches) -> Result<(), clap::Error> {
*self = Self::from_arg_matches(matches)?;
Ok(())
}
}
#[derive(Debug, Args)]
pub(crate) struct CmpArgs {
a: PathBuf,
b: PathBuf,
#[arg(long, default_value_t = 8)]
gap: u64,
#[arg(long, default_value = "32", value_parser = parse_limit)]
max_runs: CountLimit,
#[arg(long, default_value = "32", value_parser = parse_offset)]
context: u64,
#[command(flatten)]
_reject_json: crate::reject_json::RejectJson,
}
pub(crate) fn run(command: ByteCommand) -> Result<ExitCode> {
let tool = match command {
ByteCommand::Tool(tool) | ByteCommand::Bytes { tool } => tool,
};
let json_subcommand = match &tool {
ByteTool::Find(args) if args.json => Some("find"),
ByteTool::Container(args) if args.json => Some("container"),
_ => None,
};
let result = match &tool {
ByteTool::Hex(args) => hex(args).map(|()| ExitCode::SUCCESS),
ByteTool::Read(args) => read(args).map(|()| ExitCode::SUCCESS),
ByteTool::Find(args) => find(args).map(|()| ExitCode::SUCCESS),
ByteTool::Strings(args) => strings(args).map(|()| ExitCode::SUCCESS),
ByteTool::Struct(args) => structure(args).map(|()| ExitCode::SUCCESS),
ByteTool::Container(args) => container_list(args).map(|()| ExitCode::SUCCESS),
ByteTool::Extract(args) => extract_entry(args).map(|()| ExitCode::SUCCESS),
ByteTool::Cmp(args) => cmp_files(args),
};
result.inspect_err(|error| {
let Some(subcommand) = json_subcommand else {
return;
};
let Some(cadmpeg_core::CodecError::ResourceLimit(limit)) =
error.downcast_ref::<cadmpeg_core::CodecError>()
else {
return;
};
let refusal = crate::application::refusal::ConversionRefusal::ResourceLimit {
limit: *limit,
stage: crate::application::refusal::RefusalStage::Decode,
};
let payload = crate::commands::reporting::Payload::Refused(
serde_json::json!({ "subcommand": subcommand }), &refusal,
);
match crate::commands::reporting::payload_report_json("inspect", payload) {
Ok(report) => println!("{report}"),
Err(error) => eprintln!("warning: could not serialize inspect refusal report: {error:#}; preserving the original refusal"),
}
})
}
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 arena = DecodeArena::new();
let (ctx, _) = DecodeContext::from_root_bytes(&[], &arena, &DecodePolicy::default())?;
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 = ctx.alloc_filled(want, 0_u8, "cli inspect read window")?;
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<()> {
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<()> {
let endian = args.endian;
let width_usize = args.ty.width();
let width = cadmpeg_core::decode::u64_from_index(width_usize);
let stride = args.stride.map_or(width, NonZeroU64::get);
if args.count == 0 {
return Ok(());
}
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; ScalarType::MAX_WIDTH];
file.read_exact(&mut buffer[..width_usize])?;
let value = args.ty.window_of(&buffer).read(endian);
println!(
"0x{offset:08x} {name:<6} {:<24} {}",
value.decimal(),
value.hex()
);
}
Ok(())
}
fn find(args: &FindArgs) -> Result<()> {
let file = &args.input.file;
let text = &args.input.needle;
let (pattern, described) = match args.encoding {
FindEncoding::Hex => (search::parse_pattern(text), format!("hex {text}")),
FindEncoding::Ascii => (search::ascii_pattern(text), format!("ascii {text:?}")),
FindEncoding::Utf16le => (search::utf16le_pattern(text), format!("utf16le {text:?}")),
};
let pattern = pattern.map_err(|message| anyhow::anyhow!(message))?;
let bytes = read_whole(file)?;
let limit = args.max;
let hits = search::find_all(&bytes, &pattern, limit);
let truncated = limit.is_some_and(|max| hits.len() >= max.get());
if args.json {
let payload = serde_json::json!({
"subcommand": "find",
"pattern": described,
"pattern_bytes": pattern.len(),
"truncated": truncated,
"hits": hits,
});
println!(
"{}",
crate::commands::reporting::command_report_json("inspect", &payload)?
);
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 len = args
.context
.checked_mul(2)
.and_then(|len| {
len.checked_add(cadmpeg_core::decode::u64_from_index(pattern.len()))
})
.ok_or_else(|| anyhow!("inspection context length exceeds u64"))?;
if let Some(start) = offset.checked_sub(args.context) {
print!("{}", window(&bytes, start, len)?);
} else {
print!("{}", window(&bytes, 0, len)?);
}
}
}
if truncated {
println!(
"note: output truncated at {} matches; pass --max 0 for all",
hits.len()
);
}
Ok(())
}
fn strings(args: &StringsArgs) -> Result<()> {
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<()> {
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 = cadmpeg_core::decode::u64_from_index(layout.size().get());
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.names().map(str::len).fold(0, usize::max);
for (index, record) in layout.split(&bytes).enumerate() {
let base = args.offset + cadmpeg_core::decode::u64_from_index(index) * record_size;
println!("record {index} @ 0x{base:08x} ({record_size} bytes)");
for field in record.fields() {
let at = base + cadmpeg_core::decode::u64_from_index(field.offset());
let decimal = match field.value() {
layout::DecodedValue::Scalar { value, .. } => value.decimal(),
layout::DecodedValue::Bytes(_) => String::new(),
};
println!(
" 0x{at:08x} {:<name_width$} {:<8} {:<24} {}",
field.name(),
field.type_name(),
decimal,
field.hex()
);
}
}
Ok(())
}
fn container_list(args: &ContainerArgs) -> Result<()> {
let file_path = args.file.path();
let limits = args.limits.limits(args.max_input_bytes);
let arena = DecodeArena::new();
let policy = DecodePolicy {
limits,
..DecodePolicy::default()
};
let ctx = DecodeContext::new(&arena, &policy, false);
let mut file =
File::open(file_path).with_context(|| format!("reading {}", file_path.display()))?;
let mut bytes = Vec::new();
ctx.complete_input(&mut file, &mut bytes)?;
ctx.finish_session()?;
let listing = container::list(&bytes, limits).with_context(|| {
format!(
"cannot list {} as a ZIP or CFB container; `cadmpeg inspect {}` reads \
the other container families through their codec",
file_path.display(),
file_path.display()
)
})?;
if args.json {
print!("{}", container::render_json(&listing)?);
} else {
print!("{}", container::render(&listing));
}
Ok(())
}
fn extract_entry(args: &ExtractArgs) -> Result<()> {
let bytes = read_whole(&args.file)?;
let payload = container::extract(&bytes, args.limits.limits(None), &args.member)
.with_context(|| format!("extracting from {}", args.file.display()))?;
match &args.output {
ExtractDestination::Stdout => write_payload_to_stdout(&payload),
ExtractDestination::File(destination) => destination.write(&args.file, &payload),
}
}
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 cmp_files(args: &CmpArgs) -> Result<ExitCode> {
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(ExitCode::SUCCESS);
}
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(ExitCode::from(1));
};
println!(
"first difference: 0x{first:08x} ({first})\ndiffering bytes: {} of {}\nruns (gap {}): {}",
summary.differing(),
summary.compared(),
args.gap,
summary.runs().len()
);
let shown = args.max_runs.map_or(summary.runs().len(), |max| {
max.get().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 {
if let Some(window_start) = first.checked_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)?);
} else {
println!("\na @ 0x0:");
print!("{}", window(&a, 0, args.context)?);
println!("b @ 0x0:");
print!("{}", window(&b, 0, args.context)?);
}
}
Ok(ExitCode::from(1))
}
fn window(bytes: &[u8], start: u64, len: u64) -> Result<String> {
let begin = match usize::try_from(start) {
Ok(begin) if begin < bytes.len() => begin,
_ => bytes.len(),
};
let end = match start.checked_add(len).map(usize::try_from) {
Some(Ok(end)) if end < bytes.len() => end,
_ => bytes.len(),
};
hexdump::render_default_width(
cadmpeg_core::decode::u64_from_index(begin),
&bytes[begin..end],
)
}
#[cfg(test)]
mod tests {
use super::{find, ExtractArgs, FindArgs, FindEncoding, FindInput};
use clap::{Args, FromArgMatches};
#[test]
fn inspect_rejects_cadir_at_argument_admission() {
use clap::Parser;
let error =
crate::Cli::try_parse_from(["cadmpeg", "inspect", "missing", "--from", "cadir"])
.unwrap_err();
assert_eq!(error.kind(), clap::error::ErrorKind::InvalidValue);
}
#[test]
fn extract_stdout_spellings_have_one_destination() {
let parse = |args: Vec<&str>| {
let matches = ExtractArgs::augment_args(clap::Command::new("extract"))
.try_get_matches_from(args)
.unwrap();
ExtractArgs::from_arg_matches(&matches).unwrap()
};
let omitted = parse(vec!["extract", "input.zip", "entry"]);
let explicit = parse(vec!["extract", "input.zip", "entry", "-o", "-", "--force"]);
assert_eq!(omitted.output, explicit.output);
}
#[test]
fn find_refuses_an_unrepresentable_context_length() {
let file = tempfile::NamedTempFile::new().expect("create test input");
std::fs::write(file.path(), b"a").expect("write test input");
let args = FindArgs {
input: FindInput {
file: file.path().to_path_buf(),
needle: "a".into(),
},
encoding: FindEncoding::Ascii,
max: None,
context: u64::MAX,
json: false,
};
assert!(find(&args).is_err());
}
}