Experimental objdiff-cli (WIP)

This commit is contained in:
Luke Street
2024-02-27 18:47:51 -07:00
parent 4eba5f71b0
commit 9a7d2bcebf
23 changed files with 1541 additions and 501 deletions
+29
View File
@@ -0,0 +1,29 @@
[package]
name = "objdiff-cli"
version = "0.1.0"
edition = "2021"
rust-version = "1.70"
authors = ["Luke Street <luke@street.dev>"]
license = "MIT OR Apache-2.0"
repository = "https://github.com/encounter/objdiff"
readme = "../README.md"
description = """
A local diffing tool for decompilation projects.
"""
publish = false
build = "build.rs"
[dependencies]
crossterm = "0.27.0"
anyhow = "1.0.80"
argp = "0.3.0"
enable-ansi-support = "0.2.1"
log = "0.4.20"
objdiff-core = { path = "../objdiff-core", features = ["all"] }
supports-color = "3.0.0"
tracing = "0.1.40"
tracing-attributes = "0.1.27"
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1.0.111"
rayon = "1.8.1"
+9
View File
@@ -0,0 +1,9 @@
fn main() {
let output = std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.output()
.expect("Failed to execute git");
let rev = String::from_utf8(output.stdout).expect("Failed to parse git output");
println!("cargo:rustc-env=GIT_COMMIT_SHA={rev}");
println!("cargo:rustc-rerun-if-changed=.git/HEAD");
}
+64
View File
@@ -0,0 +1,64 @@
// Originally from https://gist.github.com/suluke/e0c672492126be0a4f3b4f0e1115d77c
//! Extend `argp` to be better integrated with the `cargo` ecosystem
//!
//! For now, this only adds a --version/-V option which causes early-exit.
use std::ffi::OsStr;
use argp::{parser::ParseGlobalOptions, EarlyExit, FromArgs, TopLevelCommand};
struct ArgsOrVersion<T>(T)
where T: FromArgs;
impl<T> TopLevelCommand for ArgsOrVersion<T> where T: FromArgs {}
impl<T> FromArgs for ArgsOrVersion<T>
where T: FromArgs
{
fn _from_args(
command_name: &[&str],
args: &[&OsStr],
parent: Option<&mut dyn ParseGlobalOptions>,
) -> Result<Self, EarlyExit> {
/// Also use argp for catching `--version`-only invocations
#[derive(FromArgs)]
struct Version {
/// Print version information and exit.
#[argp(switch, short = 'V')]
pub version: bool,
}
match Version::from_args(command_name, args) {
Ok(v) => {
if v.version {
println!(
"{} {} {}",
command_name.first().unwrap_or(&""),
env!("CARGO_PKG_VERSION"),
env!("GIT_COMMIT_SHA"),
);
std::process::exit(0);
} else {
// Pass through empty arguments
T::_from_args(command_name, args, parent).map(Self)
}
}
Err(exit) => match exit {
EarlyExit::Help(_help) => {
// TODO: Chain help info from Version
// For now, we just put the switch on T as well
T::from_args(command_name, &["--help"]).map(Self)
}
EarlyExit::Err(_) => T::_from_args(command_name, args, parent).map(Self),
},
}
}
}
/// Create a `FromArgs` type from the current processs `env::args`.
///
/// This function will exit early from the current process if argument parsing was unsuccessful or if information like `--help` was requested.
/// Error messages will be printed to stderr, and `--help` output to stdout.
pub fn from_env<T>() -> T
where T: TopLevelCommand {
argp::parse_args_or_exit::<ArgsOrVersion<T>>(argp::DEFAULT).0
}
+322
View File
@@ -0,0 +1,322 @@
use std::{
io::{stdout, Write},
path::PathBuf,
};
use anyhow::{bail, Context, Result};
use argp::FromArgs;
use crossterm::{
cursor::{Hide, MoveRight, MoveTo, Show},
event,
event::{
DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, MouseEventKind,
},
style::{Color, PrintStyledContent, Stylize},
terminal::{
disable_raw_mode, enable_raw_mode, size as terminal_size, Clear, ClearType,
EnterAlternateScreen, LeaveAlternateScreen, SetTitle,
},
};
use event::KeyModifiers;
use objdiff_core::{
diff,
diff::display::{display_diff, DiffText},
obj,
obj::{ObjInfo, ObjInsDiffKind, ObjSection, ObjSectionKind, ObjSymbol},
};
use crate::util::term::crossterm_panic_handler;
#[derive(FromArgs, PartialEq, Debug)]
/// Diff two object files.
#[argp(subcommand, name = "diff")]
pub struct Args {
#[argp(positional)]
/// Target object file
target: PathBuf,
#[argp(positional)]
/// Base object file
base: PathBuf,
#[argp(option, short = 's')]
/// Function symbol to diff
symbol: String,
}
pub fn run(args: Args) -> Result<()> {
let mut target = obj::elf::read(&args.target)
.with_context(|| format!("Loading {}", args.target.display()))?;
let mut base =
obj::elf::read(&args.base).with_context(|| format!("Loading {}", args.base.display()))?;
let config = diff::DiffObjConfig::default();
diff::diff_objs(&config, Some(&mut target), Some(&mut base))?;
let left_sym = find_function(&target, &args.symbol);
let right_sym = find_function(&base, &args.symbol);
let max_len = match (left_sym, right_sym) {
(Some((_, l)), Some((_, r))) => l.instructions.len().max(r.instructions.len()),
(Some((_, l)), None) => l.instructions.len(),
(None, Some((_, r))) => r.instructions.len(),
(None, None) => bail!("Symbol not found: {}", args.symbol),
};
crossterm_panic_handler();
enable_raw_mode()?;
crossterm::queue!(
stdout(),
EnterAlternateScreen,
SetTitle(format!("{} - objdiff", args.symbol)),
Hide,
EnableMouseCapture,
)?;
let mut redraw = true;
let mut skip = 0;
loop {
let y_offset = 2;
let (sx, sy) = terminal_size()?;
let per_page = sy as usize - y_offset;
if redraw {
let mut w = stdout().lock();
crossterm::queue!(
w,
Clear(ClearType::All),
MoveTo(0, 0),
PrintStyledContent(args.symbol.clone().with(Color::White)),
MoveTo(0, 1),
PrintStyledContent(" ".repeat(sx as usize).underlined()),
MoveTo(0, 1),
PrintStyledContent("TARGET ".underlined()),
MoveTo(sx / 2, 0),
PrintStyledContent("Last built: 18:24:20".with(Color::White)),
MoveTo(sx / 2, 1),
PrintStyledContent("BASE ".underlined()),
)?;
if let Some(percent) = right_sym.and_then(|(_, s)| s.match_percent) {
crossterm::queue!(
w,
PrintStyledContent(
format!("{:.2}%", percent).with(match_percent_color(percent)).underlined()
)
)?;
}
if skip > max_len - per_page {
skip = max_len - per_page;
}
if let Some((_, symbol)) = left_sym {
print_sym(&mut w, symbol, 0, y_offset as u16, sx / 2 - 1, sy, skip)?;
}
if let Some((_, symbol)) = right_sym {
print_sym(&mut w, symbol, sx / 2, y_offset as u16, sx, sy, skip)?;
}
w.flush()?;
redraw = false;
}
match event::read()? {
Event::Key(event)
if matches!(event.kind, KeyEventKind::Press | KeyEventKind::Repeat) =>
{
match event.code {
// Quit
KeyCode::Esc | KeyCode::Char('q') => break,
// Page up
KeyCode::PageUp => {
skip = skip.saturating_sub(per_page);
redraw = true;
}
// Page up (shift + space)
KeyCode::Char(' ') if event.modifiers.contains(KeyModifiers::SHIFT) => {
skip = skip.saturating_sub(per_page);
redraw = true;
}
// Page down
KeyCode::Char(' ') | KeyCode::PageDown => {
skip += per_page;
redraw = true;
}
// Scroll down
KeyCode::Down | KeyCode::Char('j') => {
skip += 1;
redraw = true;
}
// Scroll up
KeyCode::Up | KeyCode::Char('k') => {
skip = skip.saturating_sub(1);
redraw = true;
}
// Scroll to start
KeyCode::Char('g') => {
skip = 0;
redraw = true;
}
// Scroll to end
KeyCode::Char('G') => {
skip = max_len;
redraw = true;
}
_ => {}
}
}
Event::Mouse(event) => match event.kind {
MouseEventKind::ScrollDown => {
skip += 3;
redraw = true;
}
MouseEventKind::ScrollUp => {
skip = skip.saturating_sub(3);
redraw = true;
}
_ => {}
},
Event::Resize(_, _) => redraw = true,
_ => {}
}
}
// Reset terminal
crossterm::execute!(stdout(), LeaveAlternateScreen, Show, DisableMouseCapture)?;
disable_raw_mode()?;
Ok(())
}
fn find_function<'a>(obj: &'a ObjInfo, name: &str) -> Option<(&'a ObjSection, &'a ObjSymbol)> {
for section in &obj.sections {
if section.kind != ObjSectionKind::Code {
continue;
}
for symbol in &section.symbols {
if symbol.name == name {
return Some((section, symbol));
}
}
}
None
}
fn print_sym<W>(
w: &mut W,
symbol: &ObjSymbol,
sx: u16,
mut sy: u16,
max_sx: u16,
max_sy: u16,
skip: usize,
) -> Result<()>
where
W: Write,
{
let base_addr = symbol.address as u32;
for ins_diff in symbol.instructions.iter().skip(skip) {
let mut sx = sx;
if ins_diff.kind != ObjInsDiffKind::None && sx > 2 {
crossterm::queue!(w, MoveTo(sx - 2, sy))?;
let s = match ins_diff.kind {
ObjInsDiffKind::Delete => "< ",
ObjInsDiffKind::Insert => "> ",
_ => "| ",
};
crossterm::queue!(w, PrintStyledContent(s.with(Color::DarkGrey)))?;
} else {
crossterm::queue!(w, MoveTo(sx, sy))?;
}
display_diff(ins_diff, base_addr, |text| {
let mut label_text;
let mut base_color = match ins_diff.kind {
ObjInsDiffKind::None | ObjInsDiffKind::OpMismatch | ObjInsDiffKind::ArgMismatch => {
Color::Grey
}
ObjInsDiffKind::Replace => Color::DarkCyan,
ObjInsDiffKind::Delete => Color::DarkRed,
ObjInsDiffKind::Insert => Color::DarkGreen,
};
let mut pad_to = 0;
match text {
DiffText::Basic(text) => {
label_text = text.to_string();
}
DiffText::BasicColor(s, idx) => {
label_text = s.to_string();
base_color = COLOR_ROTATION[idx % COLOR_ROTATION.len()];
}
DiffText::Line(num) => {
label_text = format!("{num} ");
base_color = Color::DarkGrey;
pad_to = 5;
}
DiffText::Address(addr) => {
label_text = format!("{:x}:", addr);
pad_to = 5;
}
DiffText::Opcode(mnemonic, _op) => {
label_text = mnemonic.to_string();
if ins_diff.kind == ObjInsDiffKind::OpMismatch {
base_color = Color::Blue;
}
pad_to = 8;
}
DiffText::Argument(arg, diff) => {
label_text = arg.to_string();
if let Some(diff) = diff {
base_color = COLOR_ROTATION[diff.idx % COLOR_ROTATION.len()]
}
}
DiffText::BranchTarget(addr) => {
label_text = format!("{addr:x}");
}
DiffText::Symbol(sym) => {
let name = sym.demangled_name.as_ref().unwrap_or(&sym.name);
label_text = name.clone();
base_color = Color::White;
}
DiffText::Spacing(n) => {
crossterm::queue!(w, MoveRight(n as u16))?;
sx += n as u16;
return Ok(());
}
DiffText::Eol => {
sy += 1;
return Ok(());
}
}
let len = label_text.len();
if sx >= max_sx {
return Ok(());
}
label_text.truncate(max_sx as usize - sx as usize);
crossterm::queue!(w, PrintStyledContent(label_text.with(base_color)))?;
sx += len as u16;
if pad_to > len {
let pad = (pad_to - len) as u16;
crossterm::queue!(w, MoveRight(pad))?;
sx += pad;
}
Ok(())
})?;
if sy >= max_sy {
break;
}
}
Ok(())
}
pub const COLOR_ROTATION: [Color; 8] = [
Color::Magenta,
Color::Cyan,
Color::Green,
Color::Red,
Color::Yellow,
Color::DarkMagenta,
Color::Blue,
Color::Green,
];
pub fn match_percent_color(match_percent: f32) -> Color {
if match_percent == 100.0 {
Color::Green
} else if match_percent >= 50.0 {
Color::Blue
} else {
Color::Red
}
}
+2
View File
@@ -0,0 +1,2 @@
pub mod diff;
pub mod report;
+229
View File
@@ -0,0 +1,229 @@
use std::{
collections::HashSet,
fs::File,
io::{BufWriter, Write},
path::{Path, PathBuf},
time::Instant,
};
use anyhow::{bail, Context, Result};
use argp::FromArgs;
use objdiff_core::{
config::ProjectObject,
diff, obj,
obj::{ObjSectionKind, ObjSymbolFlags},
};
use rayon::iter::{IntoParallelRefMutIterator, ParallelIterator};
#[derive(FromArgs, PartialEq, Debug)]
/// Generate a report from a project.
#[argp(subcommand, name = "report")]
pub struct Args {
#[argp(option, short = 'p')]
/// Project directory
project: Option<PathBuf>,
#[argp(option, short = 'o')]
/// Output JSON file
output: Option<PathBuf>,
#[argp(switch, short = 'd')]
/// Deduplicate global and weak symbols
deduplicate: bool,
}
#[derive(Debug, Clone, Default, serde::Serialize)]
struct Report {
fuzzy_match_percent: f32,
total_size: u64,
matched_size: u64,
matched_size_percent: f32,
total_functions: u32,
matched_functions: u32,
matched_functions_percent: f32,
units: Vec<ReportUnit>,
}
#[derive(Debug, Clone, Default, serde::Serialize)]
struct ReportUnit {
name: String,
match_percent: f32,
total_size: u64,
matched_size: u64,
total_functions: u32,
matched_functions: u32,
#[serde(skip_serializing_if = "Option::is_none")]
complete: Option<bool>,
functions: Vec<ReportFunction>,
}
#[derive(Debug, Clone, Default, serde::Serialize)]
struct ReportFunction {
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
demangled_name: Option<String>,
size: u64,
match_percent: f32,
}
pub fn run(args: Args) -> Result<()> {
let project_dir = args.project.as_deref().unwrap_or_else(|| Path::new("."));
log::info!("Loading project {}", project_dir.display());
let config = objdiff_core::config::try_project_config(project_dir);
let Some((Ok(mut project), _)) = config else {
bail!("No project configuration found");
};
log::info!(
"Generating report for {} units (using {} threads)",
project.objects.len(),
if args.deduplicate { 1 } else { rayon::current_num_threads() }
);
let start = Instant::now();
let mut report = Report::default();
let mut existing_functions: HashSet<String> = HashSet::new();
if args.deduplicate {
// If deduplicating, we need to run single-threaded
for object in &mut project.objects {
if let Some(unit) = report_object(
object,
project_dir,
project.target_dir.as_deref(),
project.base_dir.as_deref(),
Some(&mut existing_functions),
)? {
report.units.push(unit);
}
}
} else {
let units = project
.objects
.par_iter_mut()
.map(|object| {
report_object(
object,
project_dir,
project.target_dir.as_deref(),
project.base_dir.as_deref(),
None,
)
})
.collect::<Result<Vec<Option<ReportUnit>>>>()?;
report.units = units.into_iter().flatten().collect::<Vec<ReportUnit>>();
}
for unit in &report.units {
report.fuzzy_match_percent += unit.match_percent * unit.total_size as f32;
report.total_size += unit.total_size;
report.matched_size += unit.matched_size;
report.total_functions += unit.total_functions;
report.matched_functions += unit.matched_functions;
}
if report.total_size == 0 {
report.fuzzy_match_percent = 100.0;
} else {
report.fuzzy_match_percent /= report.total_size as f32;
}
report.matched_size_percent = if report.total_size == 0 {
100.0
} else {
report.matched_size as f32 / report.total_size as f32 * 100.0
};
report.matched_functions_percent = if report.total_functions == 0 {
100.0
} else {
report.matched_functions as f32 / report.total_functions as f32 * 100.0
};
let duration = start.elapsed();
log::info!("Report generated in {}.{:03}s", duration.as_secs(), duration.subsec_millis());
if let Some(output) = &args.output {
log::info!("Writing to {}", output.display());
let mut output = BufWriter::new(
File::create(output)
.with_context(|| format!("Failed to create file {}", output.display()))?,
);
serde_json::to_writer_pretty(&mut output, &report)?;
output.flush()?;
} else {
serde_json::to_writer_pretty(std::io::stdout(), &report)?;
}
Ok(())
}
fn report_object(
object: &mut ProjectObject,
project_dir: &Path,
target_dir: Option<&Path>,
base_dir: Option<&Path>,
mut existing_functions: Option<&mut HashSet<String>>,
) -> Result<Option<ReportUnit>> {
object.resolve_paths(project_dir, target_dir, base_dir);
match (&object.target_path, &object.base_path) {
(None, Some(_)) if object.complete != Some(true) => {
log::warn!("Skipping object without target: {}", object.name());
return Ok(None);
}
(None, None) => {
log::warn!("Skipping object without target or base: {}", object.name());
return Ok(None);
}
_ => {}
}
// println!("Checking {}", object.name());
let mut target = object
.target_path
.as_ref()
.map(|p| obj::elf::read(p).with_context(|| format!("Failed to open {}", p.display())))
.transpose()?;
let mut base = object
.base_path
.as_ref()
.map(|p| obj::elf::read(p).with_context(|| format!("Failed to open {}", p.display())))
.transpose()?;
let config = diff::DiffObjConfig { relax_reloc_diffs: true, ..Default::default() };
diff::diff_objs(&config, target.as_mut(), base.as_mut())?;
let mut unit = ReportUnit { name: object.name().to_string(), ..Default::default() };
let obj = target.as_ref().or(base.as_ref()).unwrap();
for section in &obj.sections {
if section.kind != ObjSectionKind::Code {
continue;
}
for symbol in &section.symbols {
if symbol.size == 0 {
continue;
}
if let Some(existing_functions) = &mut existing_functions {
if (symbol.flags.0.contains(ObjSymbolFlags::Global)
|| symbol.flags.0.contains(ObjSymbolFlags::Weak))
&& !existing_functions.insert(symbol.name.clone())
{
continue;
}
}
let match_percent = symbol.match_percent.unwrap_or(if object.complete == Some(true) {
100.0
} else {
0.0
});
unit.match_percent += match_percent * symbol.size as f32;
unit.total_size += symbol.size;
if match_percent == 100.0 {
unit.matched_size += symbol.size;
}
unit.functions.push(ReportFunction {
name: symbol.name.clone(),
demangled_name: symbol.demangled_name.clone(),
size: symbol.size,
match_percent,
});
if match_percent == 100.0 {
unit.matched_functions += 1;
}
unit.total_functions += 1;
}
}
if unit.total_size == 0 {
unit.match_percent = 100.0;
} else {
unit.match_percent /= unit.total_size as f32;
}
Ok(Some(unit))
}
+142
View File
@@ -0,0 +1,142 @@
mod argp_version;
mod cmd;
mod util;
use std::{env, ffi::OsStr, path::PathBuf, str::FromStr};
use anyhow::{Error, Result};
use argp::{FromArgValue, FromArgs};
use enable_ansi_support::enable_ansi_support;
use supports_color::Stream;
use tracing_subscriber::{filter::LevelFilter, EnvFilter};
#[derive(Debug, Eq, PartialEq, Copy, Clone)]
enum LogLevel {
Error,
Warn,
Info,
Debug,
Trace,
}
impl FromStr for LogLevel {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(match s {
"error" => Self::Error,
"warn" => Self::Warn,
"info" => Self::Info,
"debug" => Self::Debug,
"trace" => Self::Trace,
_ => return Err(()),
})
}
}
impl ToString for LogLevel {
fn to_string(&self) -> String {
match self {
LogLevel::Error => "error",
LogLevel::Warn => "warn",
LogLevel::Info => "info",
LogLevel::Debug => "debug",
LogLevel::Trace => "trace",
}
.to_string()
}
}
impl FromArgValue for LogLevel {
fn from_arg_value(value: &OsStr) -> Result<Self, String> {
String::from_arg_value(value)
.and_then(|s| Self::from_str(&s).map_err(|_| "Invalid log level".to_string()))
}
}
#[derive(FromArgs, PartialEq, Debug)]
/// Yet another GameCube/Wii decompilation toolkit.
struct TopLevel {
#[argp(subcommand)]
command: SubCommand,
#[argp(option, short = 'C')]
/// Change working directory.
chdir: Option<PathBuf>,
#[argp(option, short = 'L')]
/// Minimum logging level. (Default: info)
/// Possible values: error, warn, info, debug, trace
log_level: Option<LogLevel>,
/// Print version information and exit.
#[argp(switch, short = 'V')]
version: bool,
/// Disable color output. (env: NO_COLOR)
#[argp(switch)]
no_color: bool,
}
#[derive(FromArgs, PartialEq, Debug)]
#[argp(subcommand)]
enum SubCommand {
Diff(cmd::diff::Args),
Report(cmd::report::Args),
}
// Duplicated from supports-color so we can check early.
fn env_no_color() -> bool {
match env::var("NO_COLOR").as_deref() {
Ok("") | Ok("0") | Err(_) => false,
Ok(_) => true,
}
}
fn main() {
let args: TopLevel = argp_version::from_env();
let use_colors = if args.no_color || env_no_color() {
false
} else {
// Try to enable ANSI support on Windows.
let _ = enable_ansi_support();
// Disable isatty check for supports-color. (e.g. when used with ninja)
env::set_var("IGNORE_IS_TERMINAL", "1");
supports_color::on(Stream::Stdout).is_some_and(|c| c.has_basic)
};
let format =
tracing_subscriber::fmt::format().with_ansi(use_colors).with_target(false).without_time();
let builder = tracing_subscriber::fmt().event_format(format);
if let Some(level) = args.log_level {
builder
.with_max_level(match level {
LogLevel::Error => LevelFilter::ERROR,
LogLevel::Warn => LevelFilter::WARN,
LogLevel::Info => LevelFilter::INFO,
LogLevel::Debug => LevelFilter::DEBUG,
LogLevel::Trace => LevelFilter::TRACE,
})
.init();
} else {
builder
.with_env_filter(
EnvFilter::builder()
.with_default_directive(LevelFilter::INFO.into())
.from_env_lossy(),
)
.init();
}
let mut result = Ok(());
if let Some(dir) = &args.chdir {
result = env::set_current_dir(dir).map_err(|e| {
Error::new(e)
.context(format!("Failed to change working directory to '{}'", dir.display()))
});
}
result = result.and_then(|_| match args.command {
SubCommand::Diff(c_args) => cmd::diff::run(c_args),
SubCommand::Report(c_args) => cmd::report::run(c_args),
});
if let Err(e) = result {
eprintln!("Failed: {e:?}");
std::process::exit(1);
}
}
+1
View File
@@ -0,0 +1 @@
pub mod term;
+15
View File
@@ -0,0 +1,15 @@
use std::panic;
pub fn crossterm_panic_handler() {
let original_hook = panic::take_hook();
panic::set_hook(Box::new(move |panic_info| {
crossterm::execute!(
std::io::stderr(),
crossterm::terminal::LeaveAlternateScreen,
crossterm::event::DisableMouseCapture
)
.unwrap();
crossterm::terminal::disable_raw_mode().unwrap();
original_hook(panic_info);
}));
}