mod formats;
mod functions;
mod tui;
#[global_allocator]
static ALLOCATOR: mimalloc::MiMalloc = mimalloc::MiMalloc;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use clap::{Parser, ValueEnum};
use console::Term;
use hereticus::address::Contents;
use hereticus::matching::{
Changed, Confidence, Diff, DiffLine, EditKind, EditKinds, Edits, Match, NearMiss, Options,
PairedLabels, Phase, Progress, UseNames, View, diff_reporting, instruction_diff_in,
};
use hereticus::obj::{Binary, Isa};
use hereticus::raw::{check_base, suggest_base};
use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle};
use tabled::settings::object::{Columns, Rows};
use tabled::settings::style::HorizontalLine;
use tabled::settings::{Alignment, Color, Style};
use tabled::{Table, Tabled};
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
struct Args {
before: PathBuf,
#[arg(required_unless_present = "functions")]
after: Option<PathBuf>,
#[arg(short, long)]
all: bool,
#[arg(long, value_name = "FUNCTION", conflicts_with = "diff_all")]
diff: Option<String>,
#[arg(long)]
diff_all: bool,
#[arg(long, conflicts_with_all = ["diff", "diff_all", "format", "exit_code"])]
tui: bool,
#[arg(
long,
conflicts_with_all = ["after", "diff", "diff_all", "tui", "exit_code"]
)]
functions: bool,
#[arg(long, value_enum, default_value_t = ViewName::Asm)]
view: ViewName,
#[arg(long)]
ignore_names: bool,
#[arg(long)]
show_retouched: bool,
#[arg(long)]
show_data: bool,
#[arg(long, default_value_t = 0.65, value_parser = parse_threshold)]
threshold: f64,
#[arg(long)]
exit_code: bool,
#[arg(long, default_value_t = 0.25, value_parser = parse_threshold)]
near_miss: f64,
#[arg(long)]
raw: bool,
#[arg(long, value_parser = parse_address, requires = "raw")]
base: Option<u64>,
#[arg(long, value_enum, requires = "raw")]
unpack: Option<PartName>,
#[arg(long, value_enum, default_value_t = ArchName::Arm32, requires = "raw")]
arch: ArchName,
#[arg(long, value_enum, default_value_t = Format::Text)]
format: Format,
#[arg(long, value_enum, default_value_t = Sort::Added, verbatim_doc_comment)]
sort: Sort,
#[arg(long, value_enum, default_value_t = When::Auto, verbatim_doc_comment)]
progress: When,
#[arg(long, value_enum, default_value_t = When::Auto, verbatim_doc_comment)]
color: When,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
enum When {
Auto,
Always,
Never,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
enum PartName {
Stub,
Kernel,
}
fn unpack_part(
bytes: Vec<u8>,
part: Option<PartName>,
path: &Path,
) -> Result<(Vec<u8>, Option<u64>), String> {
let Some(part) = part else { return Ok((bytes, None)) };
let unpacked = hereticus::unpack::unpack(&bytes)
.map_err(|error| format!("cannot unpack {}: {error}", path.display()))?;
let found = match part {
PartName::Stub => unpacked
.stub
.ok_or_else(|| format!("{} holds no stub, only a kernel", path.display()))?,
PartName::Kernel => unpacked.kernel,
};
Ok((found.bytes, found.load))
}
#[derive(Clone, Copy, Debug, ValueEnum)]
enum ArchName {
Arm32,
Thumb2,
Aarch64,
Mips32,
Mips32Be,
Mips16,
Mips16Be,
#[value(name = "x86-64")]
X86_64,
X86,
}
impl From<ArchName> for Isa {
fn from(name: ArchName) -> Self {
match name {
ArchName::Arm32 => Self::Arm32,
ArchName::Thumb2 => Self::Thumb2,
ArchName::Aarch64 => Self::Aarch64,
ArchName::Mips32 => Self::Mips32,
ArchName::Mips32Be => Self::Mips32Be,
ArchName::Mips16 => Self::Mips16,
ArchName::Mips16Be => Self::Mips16Be,
ArchName::X86_64 => Self::X86_64,
ArchName::X86 => Self::X86,
}
}
}
fn parse_address(text: &str) -> Result<u64, String> {
let text = text.trim();
let parsed = match text.strip_prefix("0x").or_else(|| text.strip_prefix("0X")) {
Some(hex) => u64::from_str_radix(hex, 16),
None => text.parse::<u64>(),
};
parsed.map_err(|_| format!("`{text}` is not an address"))
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
enum Format {
Text,
Compact,
Markdown,
Json,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
enum ViewName {
Asm,
Il,
}
impl From<ViewName> for View {
fn from(name: ViewName) -> Self {
match name {
ViewName::Asm => Self::Assembly,
ViewName::Il => Self::Il,
}
}
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
enum Sort {
Confidence,
Added,
Removed,
Modified,
Rare,
}
impl Sort {
fn key_of(self, pair: &Match) -> Option<f64> {
match self {
Self::Confidence => None,
Self::Added => Some(pair.edits.added as f64 + early_exit_weight(pair)),
Self::Removed => Some(pair.edits.removed as f64),
Self::Modified => Some(pair.edits.edited as f64),
Self::Rare => Some(pair.rarity),
}
}
fn next(self) -> Self {
match self {
Self::Added => Self::Removed,
Self::Removed => Self::Modified,
Self::Modified => Self::Rare,
Self::Rare => Self::Confidence,
Self::Confidence => Self::Added,
}
}
fn name(self) -> &'static str {
match self {
Self::Confidence => "confidence",
Self::Added => "added",
Self::Removed => "removed",
Self::Modified => "modified",
Self::Rare => "rare",
}
}
}
fn parse_threshold(text: &str) -> Result<f64, String> {
let value: f64 = text.parse().map_err(|_| format!("`{text}` is not a number"))?;
if (0.0..=1.0).contains(&value) {
Ok(value)
} else {
Err(format!("threshold must be between 0.0 and 1.0, but is {value}"))
}
}
struct Bar {
bar: ProgressBar,
}
impl Bar {
fn new(when: When) -> Self {
let target = match when {
When::Auto => ProgressDrawTarget::stderr(),
When::Always => ProgressDrawTarget::term_like(Box::new(Term::stderr())),
When::Never => ProgressDrawTarget::hidden(),
};
Self { bar: ProgressBar::with_draw_target(None, target) }
}
}
impl Progress for Bar {
fn start(&self, phase: Phase, total: usize) {
self.bar.reset();
self.bar.set_length(total as u64);
self.bar.set_message(phase.label());
self.bar.set_style(
ProgressStyle::with_template("{msg:26} [{bar:32}] {pos}/{len} {elapsed_precise}")
.expect("the template is a constant")
.progress_chars("=> "),
);
}
fn advance(&self, steps: usize) {
self.bar.inc(steps as u64);
}
fn finish(&self) {
self.bar.finish_and_clear();
}
}
fn main() -> ExitCode {
end_quietly_on_a_closed_pipe();
let args = Args::parse();
match run(&args) {
Ok(changed) => {
if args.exit_code && changed { ExitCode::from(1) } else { ExitCode::SUCCESS }
}
Err(error) => {
eprintln!("hereticus: {error}");
ExitCode::from(2)
}
}
}
fn end_quietly_on_a_closed_pipe() {
#[cfg(unix)]
unsafe {
libc::signal(libc::SIGPIPE, libc::SIG_DFL);
}
}
fn run(args: &Args) -> Result<bool, String> {
if args.tui && !Term::stdout().is_term() {
return Err("--tui needs a terminal on standard output".to_string());
}
if args.functions {
let binary = read_one(args, &args.before)?;
functions::print_functions(&binary, args.format);
return Ok(false);
}
let after_path = args.after.as_deref().ok_or("needs a later binary to compare with")?;
let (before_bytes, load) = unpack_part(read_file(&args.before)?, args.unpack, &args.before)?;
let (after_bytes, _) = unpack_part(read_file(after_path)?, args.unpack, after_path)?;
let before = read_binary(args, &before_bytes, load, &args.before, BaseCheck::Report)?;
let after_check = if after_path == args.before { BaseCheck::Skip } else { BaseCheck::Report };
let after = read_binary(args, &after_bytes, load, after_path, after_check)?;
warn_if_raw(args);
if before.isa != after.isa {
return Err(format!(
"the two binaries are for different architectures: {:?} and {:?}",
before.isa, after.isa
));
}
let options = Options {
threshold: args.threshold,
use_names: if args.ignore_names { UseNames::No } else { UseNames::Yes },
};
let diff = diff_reporting(&before, &after, options, &Bar::new(args.progress));
if let Some(wanted) = &args.diff {
let paint = Paint::resolve(args.color);
return print_one_diff(&diff, &before, &after, wanted, args.view.into(), paint)
.map(|()| false);
}
if args.tui {
let file_name = |path: &Path| {
path.file_name().map_or_else(
|| path.display().to_string(),
|name| name.to_string_lossy().into_owned(),
)
};
let start = tui::Start {
files: [file_name(&args.before), file_name(after_path)],
sort: args.sort,
view: args.view.into(),
scope: if args.all { tui::Scope::All } else { tui::Scope::Changed },
hidden: Hidden::from_args(args),
near_miss_floor: args.near_miss,
};
return tui::run(&diff, &before, &after, start).map(|()| false);
}
if args.diff_all {
let mut shown: Vec<&Match> = diff
.matched
.iter()
.filter(|pair| args.all || is_interesting(pair, Hidden::from_args(args)))
.collect();
sort_shown(&mut shown, args.sort);
let paint = Paint::resolve(args.color);
let paired = diff.paired_labels(&before, &after);
for (index, pair) in shown.iter().enumerate() {
if index > 0 {
println!();
}
print_pair_diff(pair, &paired, &before, &after, args.view.into(), paint);
}
return Ok(!shown.is_empty());
}
match args.format {
Format::Text => {
let view = TextView {
show_all: args.all,
near_miss_floor: args.near_miss,
hidden: Hidden::from_args(args),
paint: Paint::resolve(args.color),
sort: args.sort,
};
print_text(&diff, &before, &after, view)
}
Format::Compact => {
print_compact(&diff, &before, &after, args.all, Hidden::from_args(args), args.sort)
}
Format::Markdown => formats::print_markdown(
&diff,
&before,
&after,
args.all,
Hidden::from_args(args),
args.sort,
args.near_miss,
),
Format::Json => formats::print_json(
&diff,
&before,
&after,
args.all,
Hidden::from_args(args),
args.sort,
),
}
Ok(diff.changed().count() > 0 || !diff.removed.is_empty() || !diff.added.is_empty())
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
enum BaseCheck {
Report,
Skip,
}
fn warn_if_raw(args: &Args) {
if args.raw {
eprintln!(
"hereticus: a raw image has no exception index, so function boundaries \
are recovered from the code and are approximate"
);
}
}
fn read_one(args: &Args, path: &Path) -> Result<Binary, String> {
let (bytes, load) = unpack_part(read_file(path)?, args.unpack, path)?;
let binary = read_binary(args, &bytes, load, path, BaseCheck::Report)?;
warn_if_raw(args);
Ok(binary)
}
fn read_binary(
args: &Args,
bytes: &[u8],
load: Option<u64>,
path: &Path,
check: BaseCheck,
) -> Result<Binary, String> {
if !args.raw {
return parse(bytes, path);
}
let isa: Isa = args.arch.into();
let base = args.base.or(load).ok_or_else(|| missing_base(bytes, isa))?;
if check == BaseCheck::Report {
check_and_report_base(bytes, base, isa, path);
}
parse_raw(bytes, base, isa, path)
}
fn read_file(path: &Path) -> Result<Vec<u8>, String> {
std::fs::read(path).map_err(|error| format!("cannot read {}: {error}", path.display()))
}
fn parse(data: &[u8], path: &Path) -> Result<Binary, String> {
Binary::parse(data).map_err(|error| format!("cannot read {}: {error}", path.display()))
}
fn parse_raw(data: &[u8], base: u64, isa: Isa, path: &Path) -> Result<Binary, String> {
Binary::parse_raw(data, base, isa)
.map_err(|error| format!("cannot read {}: {error}", path.display()))
}
fn missing_base(data: &[u8], isa: Isa) -> String {
let needed = "--raw needs --base, because a raw image does not say where it loads";
match suggest_base(data, isa.arch()) {
Some(base) => format!("{needed}. Its calls show that it loads at --base {base:#x}"),
None => needed.to_string(),
}
}
fn check_and_report_base(data: &[u8], base: u64, isa: Isa, path: &Path) {
let fit = check_base(data, base, isa.arch());
if fit.is_suspect() {
eprintln!("hereticus: {} may not load at {base:#x}: {fit}", path.display());
}
}
fn name_of(binary: &Binary, index: usize) -> String {
binary.functions[index].label()
}
fn name_of_view(view: View) -> &'static str {
match view {
View::Assembly => "asm",
View::Il => "il",
}
}
fn describe(change: Changed) -> &'static str {
match change {
Changed::No => "same",
Changed::Reordered => "reordered",
Changed::InDetail => "changed values",
Changed::Data => "changed data",
Changed::Unreadable => "changed unread",
Changed::Yes => "changed",
}
}
fn describe_edits(edits: Edits, paint: Paint) -> String {
if edits.total() == 0 {
return "-".to_string();
}
let added = paint.pick(ADDED_COLOR).colorize(edits.added.to_string());
let removed = paint.pick(REMOVED_COLOR).colorize(edits.removed.to_string());
let edited = paint.pick(EDITED_COLOR).colorize(edits.edited.to_string());
format!("{added}/{removed}/{edited}")
}
fn describe_kinds(kinds: EditKinds, change: Changed) -> String {
let named: Vec<(EditKind, usize)> =
kinds.present().filter(|(kind, _)| *kind != EditKind::Other).collect();
if let [(only, _)] = named[..]
&& restates(change, only)
{
return "-".to_string();
}
let listed: Vec<String> =
named.iter().map(|(kind, count)| format!("{count} {}", name_of_kind(*kind))).collect();
if listed.is_empty() { "-".to_string() } else { listed.join(", ") }
}
fn restates(change: Changed, kind: EditKind) -> bool {
matches!(
(change, kind),
(Changed::InDetail, EditKind::Constant) | (Changed::Unreadable, EditKind::Opaque)
)
}
fn name_of_kind(kind: EditKind) -> &'static str {
match kind {
EditKind::Branch => "branch",
EditKind::Call => "call",
EditKind::Callee => "callee",
EditKind::Jump => "jump",
EditKind::Memory => "mem",
EditKind::Constant => "const",
EditKind::Text => "text",
EditKind::Other => "other",
EditKind::Opaque => "unread",
}
}
const ADDED_COLOR: Color = Color::FG_GREEN;
const REMOVED_COLOR: Color = Color::FG_RED;
const EDITED_COLOR: Color = Color::FG_YELLOW;
fn describe_confidence(confidence: Confidence) -> &'static str {
match confidence {
Confidence::IdenticalCode => "identical bytes",
Confidence::IdenticalShape => "identical shape",
Confidence::SameName => "same name",
Confidence::SameRowText => "twin, by the text in its row",
Confidence::TwinInOrder => "twin, in order",
Confidence::SameTable => "same slot of a table",
Confidence::SameImport => "same import",
Confidence::SameReferences => "same references",
Confidence::SameCallers => "same callers",
Confidence::SameLayout => "same place",
Confidence::Similar => "similar",
}
}
fn is_interesting(pair: &Match, hidden: Hidden) -> bool {
match pair.changed {
Changed::No => false,
Changed::Data => hidden.data == DataRegions::Show,
_ => hidden.retouched == Retouched::Show || !is_retouched(pair),
}
}
fn is_shown_unmatched(contents: Contents, hidden: Hidden) -> bool {
contents == Contents::Code || hidden.data == DataRegions::Show
}
fn unmatched_word(word: &'static str, contents: Contents) -> String {
match contents {
Contents::Code => word.to_string(),
Contents::Data => format!("{word} data"),
}
}
fn is_retouched(pair: &Match) -> bool {
pair.edits.total() == 0 && pair.kinds.callees == 0 && pair.kinds.texts == 0 && pair.score >= 1.0
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
struct Hidden {
retouched: Retouched,
data: DataRegions,
}
impl Hidden {
fn from_args(args: &Args) -> Self {
Self {
retouched: Retouched::from(args.show_retouched || args.all),
data: DataRegions::from(args.show_data || args.all),
}
}
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
enum DataRegions {
Show,
Hide,
}
impl From<bool> for DataRegions {
fn from(show: bool) -> Self {
if show { Self::Show } else { Self::Hide }
}
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
enum Retouched {
Show,
Hide,
}
impl From<bool> for Retouched {
fn from(show: bool) -> Self {
if show { Self::Show } else { Self::Hide }
}
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
enum Paint {
On,
Off,
}
impl Paint {
fn resolve(when: When) -> Self {
match when {
When::Always => Self::On,
When::Never => Self::Off,
When::Auto => {
if console::colors_enabled() && Term::stdout().is_term() {
Self::On
} else {
Self::Off
}
}
}
}
fn pick(self, color: Color) -> Color {
match self {
Self::On => color,
Self::Off => Color::empty(),
}
}
}
const CHANGE_COLUMN: usize = 1;
const SCORE_COLUMN: usize = 2;
#[derive(Tabled)]
struct Row {
#[tabled(rename = "FUNCTION")]
function: String,
#[tabled(rename = "CHANGE")]
change: &'static str,
#[tabled(rename = "SCORE")]
score: String,
#[tabled(rename = "+/-/~")]
edits: String,
#[tabled(rename = "TOUCHED")]
kinds: String,
#[tabled(rename = "MATCHED BY")]
matched_by: &'static str,
}
fn color_of(change: Changed) -> Color {
match change {
Changed::Yes => Color::FG_RED,
Changed::Unreadable => Color::FG_MAGENTA,
Changed::InDetail => Color::FG_YELLOW,
Changed::Reordered => Color::FG_CYAN,
Changed::Data => Color::FG_BLUE,
Changed::No => Color::FG_GREEN,
}
}
fn find_function(binary: &Binary, wanted: &str) -> Option<usize> {
let by_address = wanted
.strip_prefix("sub_")
.and_then(|hex| u64::from_str_radix(hex, 16).ok())
.and_then(|address| binary.functions.iter().position(|f| f.address == address));
by_address
.or_else(|| binary.functions.iter().position(|f| !f.name.is_empty() && f.name == wanted))
}
fn print_one_diff(
diff: &Diff,
before: &Binary,
after: &Binary,
wanted: &str,
view: View,
paint: Paint,
) -> Result<(), String> {
let index = find_function(before, wanted)
.ok_or_else(|| format!("no function `{wanted}` in the earlier binary"))?;
let pair = diff.matched.iter().find(|pair| pair.before == index).ok_or_else(|| {
match diff.near_miss(index).and_then(|miss| miss.best) {
Some(best) => format!(
"`{wanted}` was not matched; its closest candidate is {}",
name_of(after, best)
),
None => format!("`{wanted}` was not matched to anything in the later binary"),
}
})?;
print_pair_diff(pair, &diff.paired_labels(before, after), before, after, view, paint);
Ok(())
}
fn print_pair_diff(
pair: &Match,
paired: &PairedLabels,
before: &Binary,
after: &Binary,
view: View,
paint: Paint,
) {
let before_name = name_of(before, pair.before);
let after_name = name_of(after, pair.after);
if before_name == after_name {
println!("{before_name}");
} else {
println!("{before_name} -> {after_name}");
}
let lines = instruction_diff_in(view, Some(paired), before, pair.before, after, pair.after);
for line in &lines {
println!("{}", render_diff_line(line, paint));
}
}
fn render_diff_line(line: &DiffLine, paint: Paint) -> String {
match line {
DiffLine::Same(text) => format!(" {text}"),
DiffLine::Added(text) => paint.pick(ADDED_COLOR).colorize(format!("+ {text}")).to_string(),
DiffLine::Removed(text) => {
paint.pick(REMOVED_COLOR).colorize(format!("- {text}")).to_string()
}
DiffLine::Changed { before, after } => {
let removed = paint.pick(REMOVED_COLOR).colorize(format!("- {before}"));
let added = paint.pick(ADDED_COLOR).colorize(format!("+ {after}"));
format!("{removed}\n{added}")
}
}
}
fn sort_shown(shown: &mut [&Match], sort: Sort) {
if sort == Sort::Confidence {
return;
}
shown.sort_by(|a, b| rank(a, b, sort));
}
fn rank(a: &Match, b: &Match, sort: Sort) -> core::cmp::Ordering {
let a_key = sort.key_of(a).unwrap_or(0.0);
let b_key = sort.key_of(b).unwrap_or(0.0);
is_rescheduled(a)
.cmp(&is_rescheduled(b))
.then_with(|| match sort {
Sort::Added => bounds_a_call(b).cmp(&bounds_a_call(a)),
_ => core::cmp::Ordering::Equal,
})
.then_with(|| b_key.total_cmp(&a_key))
.then_with(|| a.score.partial_cmp(&b.score).unwrap_or(core::cmp::Ordering::Equal))
}
fn early_exit_weight(pair: &Match) -> f64 {
const WEIGHT: f64 = 5.0;
if pair.early_exits > 0 { WEIGHT } else { 0.0 }
}
fn bounds_a_call(pair: &Match) -> bool {
pair.bounded_calls > 0
}
fn is_rescheduled(pair: &Match) -> bool {
pair.changed == Changed::Reordered && !pair.kinds.touches_control_flow()
}
#[derive(Copy, Clone, Debug)]
struct TextView {
show_all: bool,
near_miss_floor: f64,
hidden: Hidden,
paint: Paint,
sort: Sort,
}
fn print_text(diff: &Diff, before: &Binary, after: &Binary, view: TextView) {
let TextView { show_all, near_miss_floor, hidden, paint, sort } = view;
let mut shown: Vec<&Match> =
diff.matched.iter().filter(|pair| show_all || is_interesting(pair, hidden)).collect();
sort_shown(&mut shown, sort);
if !shown.is_empty() {
let rows = shown.iter().map(|pair| {
let name = name_of(before, pair.before);
let renamed = name_of(after, pair.after);
Row {
function: if name == renamed { name } else { format!("{name} -> {renamed}") },
change: describe(pair.changed),
score: format!("{:.2}", pair.score),
edits: describe_edits(pair.edits, paint),
kinds: describe_kinds(pair.kinds, pair.changed),
matched_by: describe_confidence(pair.confidence),
}
});
let mut table = Table::new(rows);
table
.with(
Style::rounded()
.horizontals([(1, HorizontalLine::inherit(Style::modern_rounded()))]),
)
.with(Alignment::left())
.modify(Columns::one(SCORE_COLUMN), Alignment::right())
.modify(Rows::first(), paint.pick(Color::BOLD));
for (offset, pair) in shown.iter().enumerate() {
let cell = (offset + 1, CHANGE_COLUMN);
table.modify(cell, paint.pick(color_of(pair.changed)));
}
println!("{table}");
}
for (index, contents) in diff.removed_with_contents() {
if !is_shown_unmatched(contents, hidden) {
continue;
}
let name = name_of(before, index);
let gone = paint.pick(REMOVED_COLOR).colorize(unmatched_word("gone", contents));
let notes = [
near_miss_note(diff, index, after, near_miss_floor),
place_note(diff.near_miss(index), after),
];
println!("{name}: {gone}{}", joined_notes(notes));
}
for (index, contents) in diff.added_with_contents() {
if !is_shown_unmatched(contents, hidden) {
continue;
}
let name = name_of(after, index);
let new = paint.pick(ADDED_COLOR).colorize(unmatched_word("new", contents));
let notes = [
added_near_miss_note(diff, index, before, near_miss_floor),
place_note(diff.added_near_miss(index), before),
];
println!("{name}: {new}{}", joined_notes(notes));
}
summarize(diff, hidden);
}
fn near_miss_note(diff: &Diff, index: usize, after: &Binary, floor: f64) -> Option<String> {
let miss = diff.near_miss(index)?;
let best = miss.best?;
if miss.score < floor {
return None;
}
Some(format!("closest {} at {:.2}", name_of(after, best), miss.score))
}
fn added_near_miss_note(diff: &Diff, index: usize, before: &Binary, floor: f64) -> Option<String> {
let miss = diff.added_near_miss(index)?;
let best = miss.best?;
if miss.score < floor {
return None;
}
Some(format!("closest {} at {:.2}", name_of(before, best), miss.score))
}
fn place_note(miss: Option<&NearMiss>, other: &Binary) -> Option<String> {
Some(format!("same place as {}", name_of(other, miss?.in_place?)))
}
fn joined_notes(notes: impl IntoIterator<Item = Option<String>>) -> String {
notes.into_iter().flatten().map(|note| format!(", {note}")).collect()
}
fn print_compact(
diff: &Diff,
before: &Binary,
after: &Binary,
show_all: bool,
hidden: Hidden,
sort: Sort,
) {
let mut shown: Vec<&Match> =
diff.matched.iter().filter(|pair| show_all || is_interesting(pair, hidden)).collect();
sort_shown(&mut shown, sort);
for pair in shown {
println!(
"{}\t{}\t{}\t{:.2}\t{}\t{}\t{}",
describe(pair.changed),
name_of(before, pair.before),
name_of(after, pair.after),
pair.score,
pair.edits.added,
pair.edits.removed,
pair.edits.edited,
);
}
for (index, contents) in diff.removed_with_contents() {
if !show_all && !is_shown_unmatched(contents, hidden) {
continue;
}
let miss = diff.near_miss(index);
let closest =
miss.and_then(|miss| miss.best).map(|best| name_of(after, best)).unwrap_or_default();
let score = miss.map_or(0.0, |miss| miss.score);
let gone = unmatched_word("gone", contents);
println!("{gone}\t{}\t{closest}\t{score:.2}\t\t\t", name_of(before, index));
}
for (index, contents) in diff.added_with_contents() {
if !show_all && !is_shown_unmatched(contents, hidden) {
continue;
}
let miss = diff.added_near_miss(index);
let closest =
miss.and_then(|miss| miss.best).map(|best| name_of(before, best)).unwrap_or_default();
let score = miss.map_or(0.0, |miss| miss.score);
let new = unmatched_word("new", contents);
println!("{new}\t{closest}\t{}\t{score:.2}\t\t\t", name_of(after, index));
}
}
fn summarize(diff: &Diff, hidden: Hidden) {
let lines = summary_lines(diff, hidden);
if lines.len() > 1 || !nothing_happened(diff) {
println!();
}
for line in lines {
println!("{line}");
}
}
fn nothing_happened(diff: &Diff) -> bool {
diff.changed().count() == 0 && diff.removed.is_empty() && diff.added.is_empty()
}
fn summary_lines(diff: &Diff, hidden: Hidden) -> Vec<String> {
let changed = diff.changed().count();
let same = diff.matched.len() - changed;
if nothing_happened(diff) {
return vec![format!("No functions changed. {} matched.", diff.matched.len())];
}
let hidden_retouched = if hidden.retouched == Retouched::Hide {
diff.changed().filter(|pair| pair.changed != Changed::Data && is_retouched(pair)).count()
} else {
0
};
let hidden_data = if hidden.data == DataRegions::Hide {
let changed = diff.changed().filter(|pair| pair.changed == Changed::Data).count();
let unmatched = diff.removed_contents.iter().chain(&diff.added_contents);
changed + unmatched.filter(|&&contents| contents == Contents::Data).count()
} else {
0
};
let mut lines = vec![format!(
"{} matched ({same} unchanged, {changed} changed), {} gone, {} new",
diff.matched.len(),
diff.removed.len(),
diff.added.len()
)];
if hidden_retouched > 0 {
lines.push(format!(
"{hidden_retouched} changed only a constant, hidden. Use --show-retouched to list them."
));
}
if hidden_data > 0 {
let regions = if hidden_data == 1 { "region" } else { "regions" };
lines.push(format!(
"{hidden_data} data {regions} changed, hidden. Use --show-data to list them."
));
}
lines
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_a_threshold_outside_its_range() {
assert!(parse_threshold("-0.1").is_err());
assert!(parse_threshold("1.5").is_err());
assert!(parse_threshold("not a number").is_err());
}
#[test]
fn accepts_a_threshold_inside_its_range() {
assert_eq!(parse_threshold("0.0"), Ok(0.0));
assert_eq!(parse_threshold("0.65"), Ok(0.65));
assert_eq!(parse_threshold("1.0"), Ok(1.0));
}
#[test]
fn every_change_has_a_description() {
for change in [
Changed::No,
Changed::Reordered,
Changed::Data,
Changed::InDetail,
Changed::Unreadable,
Changed::Yes,
] {
assert!(!describe(change).is_empty());
}
}
fn pair_with(edits: Edits, score: f64) -> Match {
Match { score, edits, ..edited_pair(Changed::Yes) }
}
#[test]
fn a_rescheduled_pair_goes_last() {
let edits = Edits { added: 40, removed: 40, edited: 0 };
let rescheduled = Match {
kinds: EditKinds { memory: 40, ..EditKinds::default() },
..pair_with(edits, 1.0)
};
let rescheduled = Match { changed: Changed::Reordered, ..rescheduled };
let moved_branch = Match {
changed: Changed::Reordered,
kinds: EditKinds { branches: 1, memory: 39, ..EditKinds::default() },
..pair_with(edits, 1.0)
};
let small = pair_with(Edits { added: 1, removed: 0, edited: 0 }, 0.9);
let mut shown = vec![&rescheduled, &small, &moved_branch];
sort_shown(&mut shown, Sort::Added);
let order: Vec<_> = shown.iter().map(|pair| pair.kinds).collect();
assert_eq!(order, [moved_branch.kinds, small.kinds, rescheduled.kinds]);
}
#[test]
fn sort_by_modified_ranks_by_rewritten_count() {
let small = pair_with(Edits { added: 0, removed: 0, edited: 1 }, 0.9);
let large = pair_with(Edits { added: 0, removed: 0, edited: 9 }, 0.9);
let mut shown = vec![&small, &large];
sort_shown(&mut shown, Sort::Modified);
assert_eq!(shown[0].edits.edited, 9, "the largest edit is first");
}
#[test]
fn each_sort_ranks_by_its_own_column() {
let grew = pair_with(Edits { added: 5, removed: 0, edited: 0 }, 0.9);
let shrank = pair_with(Edits { added: 0, removed: 5, edited: 0 }, 0.9);
let mut shown = vec![&grew, &shrank];
sort_shown(&mut shown, Sort::Added);
assert_eq!(shown[0].edits.added, 5, "added ranks by the + column");
sort_shown(&mut shown, Sort::Removed);
assert_eq!(shown[0].edits.removed, 5, "removed ranks by the - column");
}
#[test]
fn a_bounded_call_goes_first_under_added() {
let large = pair_with(Edits { added: 30, removed: 0, edited: 0 }, 0.9);
let bounded =
Match { bounded_calls: 1, ..pair_with(Edits { added: 1, removed: 0, edited: 0 }, 1.0) };
let mut shown = vec![&large, &bounded];
sort_shown(&mut shown, Sort::Added);
assert_eq!(shown[0].bounded_calls, 1, "the bounded call is first");
sort_shown(&mut shown, Sort::Removed);
sort_shown(&mut shown, Sort::Rare);
assert_eq!(shown[0].edits.added, 30, "another order ignores it");
}
#[test]
fn a_check_before_an_early_exit_weighs_five_instructions() {
let larger = pair_with(Edits { added: 6, removed: 0, edited: 0 }, 0.9);
let checked =
Match { early_exits: 2, ..pair_with(Edits { added: 2, removed: 0, edited: 0 }, 0.9) };
let mut shown = vec![&larger, &checked];
sort_shown(&mut shown, Sort::Added);
assert_eq!(shown[0].edits.added, 2, "2 + 5 is more than 6");
let much_larger = pair_with(Edits { added: 8, removed: 0, edited: 0 }, 0.9);
let mut shown = vec![&checked, &much_larger];
sort_shown(&mut shown, Sort::Added);
assert_eq!(shown[0].edits.added, 8, "two checks weigh as one");
}
#[test]
fn sort_by_rare_ranks_by_rarity() {
let common =
Match { rarity: 6.0, ..pair_with(Edits { added: 6, removed: 0, edited: 0 }, 0.9) };
let rare =
Match { rarity: 9.5, ..pair_with(Edits { added: 4, removed: 0, edited: 0 }, 0.9) };
let mut shown = vec![&common, &rare];
sort_shown(&mut shown, Sort::Rare);
assert_eq!(shown[0].edits.added, 4, "the rarer addition is first");
sort_shown(&mut shown, Sort::Added);
assert_eq!(shown[0].edits.added, 6, "added still ranks by the count");
}
#[test]
fn an_equal_edit_puts_the_least_certain_first() {
let sure = pair_with(Edits { added: 0, removed: 0, edited: 3 }, 0.95);
let unsure = pair_with(Edits { added: 0, removed: 0, edited: 3 }, 0.60);
let mut shown = vec![&sure, &unsure];
sort_shown(&mut shown, Sort::Modified);
assert_eq!(shown[0].score, 0.60, "the least certain of an equal edit is first");
}
#[test]
fn sort_by_confidence_keeps_the_order() {
let first = pair_with(Edits { added: 0, removed: 0, edited: 1 }, 0.9);
let second = pair_with(Edits { added: 0, removed: 0, edited: 9 }, 0.9);
let mut shown = vec![&first, &second];
sort_shown(&mut shown, Sort::Confidence);
assert_eq!(shown[0].edits.edited, 1, "the library's order is untouched");
}
fn edited_pair(changed: Changed) -> Match {
Match {
rarity: 0.0,
bounded_calls: 0,
early_exits: 0,
before: 0,
after: 0,
confidence: Confidence::IdenticalCode,
score: 0.9,
changed,
edits: Edits { added: 0, removed: 0, edited: 1 },
kinds: EditKinds { other: 1, ..EditKinds::default() },
}
}
fn retouched_pair(changed: Changed) -> Match {
Match { score: 1.0, edits: Edits::default(), ..edited_pair(changed) }
}
#[test]
fn a_constant_alone_does_not_repeat_the_change_column() {
let kinds = EditKinds { constants: 1, ..EditKinds::default() };
assert_eq!(describe_kinds(kinds, Changed::InDetail), "-");
}
#[test]
fn unreadable_bytes_alone_do_not_repeat_the_change_column() {
let kinds = EditKinds { opaque: 3, ..EditKinds::default() };
assert_eq!(describe_kinds(kinds, Changed::Unreadable), "-");
}
#[test]
fn a_second_kind_brings_the_list_back() {
let kinds = EditKinds { opaque: 3, branches: 1, ..EditKinds::default() };
assert_eq!(describe_kinds(kinds, Changed::Unreadable), "1 branch, 3 unread");
}
#[test]
fn a_constant_inside_a_rewrite_is_still_named() {
let kinds = EditKinds { constants: 1, branches: 2, ..EditKinds::default() };
assert_eq!(describe_kinds(kinds, Changed::Yes), "2 branch, 1 const");
}
#[test]
fn a_constant_under_a_plain_change_is_not_suppressed() {
let kinds = EditKinds { constants: 1, ..EditKinds::default() };
assert_eq!(describe_kinds(kinds, Changed::Yes), "1 const");
}
#[test]
fn other_instructions_are_left_out() {
let kinds = EditKinds { other: 12, ..EditKinds::default() };
assert_eq!(describe_kinds(kinds, Changed::Yes), "-");
}
const DEFAULT: Hidden = Hidden { retouched: Retouched::Hide, data: DataRegions::Hide };
#[test]
fn unmatched_data_is_hidden_as_changed_data_is() {
assert!(is_shown_unmatched(Contents::Code, DEFAULT));
assert!(!is_shown_unmatched(Contents::Data, DEFAULT));
assert!(is_shown_unmatched(Contents::Data, Hidden { data: DataRegions::Show, ..DEFAULT }));
assert!(
!is_shown_unmatched(Contents::Data, Hidden { retouched: Retouched::Show, ..DEFAULT }),
"--show-retouched does not show data"
);
}
#[test]
fn unmatched_data_says_it_is_data() {
assert_eq!(unmatched_word("gone", Contents::Code), "gone");
assert_eq!(unmatched_word("gone", Contents::Data), "gone data");
assert_eq!(unmatched_word("new", Contents::Data), "new data");
}
#[test]
fn unchanged_functions_are_the_only_ones_hidden() {
let shown = |changed| is_interesting(&edited_pair(changed), DEFAULT);
assert!(!shown(Changed::No));
assert!(!shown(Changed::Data), "data is hidden too, by its own flag");
assert!(shown(Changed::Reordered));
assert!(shown(Changed::InDetail));
assert!(shown(Changed::Unreadable));
assert!(shown(Changed::Yes));
}
#[test]
fn a_retouched_function_is_hidden_unless_asked_for() {
let pair = retouched_pair(Changed::InDetail);
assert!(is_retouched(&pair));
assert!(!is_interesting(&pair, DEFAULT), "hidden by default");
assert!(
is_interesting(&pair, Hidden { retouched: Retouched::Show, ..DEFAULT }),
"--show-retouched lists it"
);
assert!(
!is_interesting(&pair, Hidden { data: DataRegions::Show, ..DEFAULT }),
"--show-data does not"
);
}
#[test]
fn a_data_region_is_hidden_unless_asked_for() {
for pair in [edited_pair(Changed::Data), retouched_pair(Changed::Data)] {
assert!(!is_interesting(&pair, DEFAULT), "hidden by default");
assert!(
!is_interesting(&pair, Hidden { retouched: Retouched::Show, ..DEFAULT }),
"--show-retouched does not list it"
);
assert!(
is_interesting(&pair, Hidden { data: DataRegions::Show, ..DEFAULT }),
"--show-data lists it"
);
}
}
#[test]
fn a_perfect_score_with_an_edit_is_still_shown() {
let pair = Match { score: 1.0, ..edited_pair(Changed::Yes) };
assert!(!is_retouched(&pair), "an edited instruction is not a retouch");
assert!(is_interesting(&pair, DEFAULT));
}
#[test]
fn an_imperfect_score_without_counts_is_still_shown() {
let pair = Match { score: 0.9, edits: Edits::default(), ..edited_pair(Changed::Yes) };
assert!(!is_retouched(&pair));
assert!(is_interesting(&pair, DEFAULT));
}
#[test]
fn the_color_flag_overrides_what_standard_output_is() {
assert_eq!(Paint::resolve(When::Always), Paint::On);
assert_eq!(Paint::resolve(When::Never), Paint::Off);
}
#[test]
fn auto_gives_no_color_when_standard_output_is_not_a_terminal() {
assert_eq!(Paint::resolve(When::Auto), Paint::Off);
}
#[test]
fn no_color_prints_nothing_around_the_text() {
let painted = Paint::On.pick(Color::FG_RED).colorize("do_mount").to_string();
let plain = Paint::Off.pick(Color::FG_RED).colorize("do_mount").to_string();
assert_eq!(plain, "do_mount");
assert_ne!(painted, plain, "a painted cell carries escape codes");
}
#[test]
fn color_does_not_change_what_the_counts_say() {
let edits = Edits { added: 1, removed: 2, edited: 3 };
assert_eq!(describe_edits(edits, Paint::Off), "1/2/3");
assert_eq!(describe_edits(Edits::default(), Paint::Off), "-");
assert_eq!(describe_edits(Edits::default(), Paint::On), "-");
}
#[test]
fn each_count_carries_its_own_color() {
let painted = describe_edits(Edits { added: 1, removed: 2, edited: 3 }, Paint::On);
assert!(painted.contains(&ADDED_COLOR.colorize("1").to_string()));
assert!(painted.contains(&REMOVED_COLOR.colorize("2").to_string()));
assert!(painted.contains(&EDITED_COLOR.colorize("3").to_string()));
assert_eq!(painted.matches('/').count(), 2);
}
#[test]
fn every_change_has_its_own_color() {
let changes = [
Changed::No,
Changed::Reordered,
Changed::Data,
Changed::InDetail,
Changed::Unreadable,
Changed::Yes,
];
for (index, change) in changes.iter().enumerate() {
for other in &changes[index + 1..] {
assert_ne!(
color_of(*change).colorize("x").to_string(),
color_of(*other).colorize("x").to_string(),
"{change:?} and {other:?} must not share a color"
);
}
}
}
}