chore(release): prepare public source release
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
[package]
|
||||
name = "llvmtools"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
readme.workspace = true
|
||||
publish.workspace = true
|
||||
description = "Wrap LLVM object tools with Mercury-friendly PE/COFF diagnostics."
|
||||
keywords.workspace = true
|
||||
categories.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "llvmobjdump"
|
||||
path = "src/main_objdump.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "llvmreadobj"
|
||||
path = "src/main_readobj.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "llvmnm"
|
||||
path = "src/main_nm.rs"
|
||||
|
||||
[dependencies]
|
||||
common = { path = "../common", default-features = false }
|
||||
goblin.workspace = true
|
||||
lexopt.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
assert_cmd.workspace = true
|
||||
predicates.workspace = true
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,316 @@
|
||||
//! LLVM backend discovery and process execution.
|
||||
|
||||
use std::env;
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use common::CliError;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::cli::ToolKind;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
/// Describes the LLVM backend executable selected for a run.
|
||||
pub struct BackendInfo {
|
||||
/// Fully resolved backend executable path.
|
||||
pub tool_path: PathBuf,
|
||||
/// First line of `--version`, when the backend reports one.
|
||||
pub version: Option<String>,
|
||||
/// Arguments passed to the backend, excluding the executable path.
|
||||
pub argv: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
/// Raw LLVM text preserved when a parser does not map a block.
|
||||
pub struct RawBlock {
|
||||
/// Input path associated with this block, when known.
|
||||
pub path: Option<PathBuf>,
|
||||
/// Short parser label for the block.
|
||||
pub label: String,
|
||||
/// Raw text content.
|
||||
pub text: String,
|
||||
/// Whether the raw text was truncated before being exposed.
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
/// Captured output from an LLVM backend invocation.
|
||||
pub struct BackendOutput {
|
||||
/// Selected backend metadata.
|
||||
pub info: BackendInfo,
|
||||
/// Bounded standard output.
|
||||
pub stdout: String,
|
||||
/// Bounded standard error.
|
||||
pub stderr: String,
|
||||
/// Backend process exit code.
|
||||
pub exit_code: i32,
|
||||
/// Whether standard output was truncated.
|
||||
pub stdout_truncated: bool,
|
||||
/// Whether standard error was truncated.
|
||||
pub stderr_truncated: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn run_backend(
|
||||
tool: ToolKind,
|
||||
explicit_bin_dir: Option<&Path>,
|
||||
args: &[String],
|
||||
raw_output_limit: usize,
|
||||
) -> Result<BackendOutput, CliError> {
|
||||
run_llvm_binary(
|
||||
tool.llvm_binary_name(),
|
||||
explicit_bin_dir,
|
||||
args,
|
||||
raw_output_limit,
|
||||
)
|
||||
}
|
||||
|
||||
/// Finds and runs an LLVM backend binary using Mercury's shared discovery order.
|
||||
///
|
||||
/// Discovery checks `--llvm-bin-dir`, `MERCURY_LLVM_BIN`, `PATH`, then the
|
||||
/// current Rust sysroot's `lib\rustlib\<host>\bin` directory.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`CliError::Runtime`] when the backend cannot be found or executed.
|
||||
pub fn run_llvm_binary(
|
||||
binary_name: &str,
|
||||
explicit_bin_dir: Option<&Path>,
|
||||
args: &[String],
|
||||
raw_output_limit: usize,
|
||||
) -> Result<BackendOutput, CliError> {
|
||||
let tool_path = find_tool_by_name(binary_name, explicit_bin_dir)?;
|
||||
let version = read_version(&tool_path);
|
||||
let output = Command::new(&tool_path)
|
||||
.args(args)
|
||||
.output()
|
||||
.map_err(|error| {
|
||||
CliError::runtime(format!(
|
||||
"failed to run {} at {}: {error}",
|
||||
binary_name,
|
||||
tool_path.display()
|
||||
))
|
||||
})?;
|
||||
let (stdout, stdout_truncated) =
|
||||
bounded_string(&String::from_utf8_lossy(&output.stdout), raw_output_limit);
|
||||
let (stderr, stderr_truncated) = bounded_string(
|
||||
&String::from_utf8_lossy(&output.stderr),
|
||||
raw_output_limit.min(16_384),
|
||||
);
|
||||
Ok(BackendOutput {
|
||||
info: BackendInfo {
|
||||
tool_path,
|
||||
version,
|
||||
argv: args.to_vec(),
|
||||
},
|
||||
stdout,
|
||||
stderr,
|
||||
exit_code: output.status.code().unwrap_or(3),
|
||||
stdout_truncated,
|
||||
stderr_truncated,
|
||||
})
|
||||
}
|
||||
|
||||
fn find_tool_by_name(name: &str, explicit_bin_dir: Option<&Path>) -> Result<PathBuf, CliError> {
|
||||
let mut searched = Vec::new();
|
||||
if let Some(dir) = explicit_bin_dir {
|
||||
return candidate_in_dir(dir, name, &mut searched)
|
||||
.ok_or_else(|| missing_backend_error(name, &searched));
|
||||
}
|
||||
if let Some(dir) = env::var_os("MERCURY_LLVM_BIN").map(PathBuf::from) {
|
||||
if let Some(path) = candidate_in_dir(&dir, name, &mut searched) {
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
if let Some(paths) = env::var_os("PATH") {
|
||||
for dir in env::split_paths(&paths) {
|
||||
if let Some(path) = candidate_in_dir(&dir, name, &mut searched) {
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(dir) = rustup_llvm_bin_dir() {
|
||||
if let Some(path) = candidate_in_dir(&dir, name, &mut searched) {
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
Err(missing_backend_error(name, &searched))
|
||||
}
|
||||
|
||||
fn candidate_in_dir(dir: &Path, name: &str, searched: &mut Vec<PathBuf>) -> Option<PathBuf> {
|
||||
let candidate = dir.join(executable_name(name));
|
||||
searched.push(candidate.clone());
|
||||
candidate.is_file().then_some(candidate)
|
||||
}
|
||||
|
||||
fn executable_name(name: &str) -> OsString {
|
||||
if cfg!(windows) {
|
||||
OsString::from(format!("{name}.exe"))
|
||||
} else {
|
||||
OsString::from(name)
|
||||
}
|
||||
}
|
||||
|
||||
fn rustup_llvm_bin_dir() -> Option<PathBuf> {
|
||||
let output = Command::new("rustc")
|
||||
.args(["--print", "sysroot"])
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let sysroot = String::from_utf8(output.stdout).ok()?;
|
||||
Some(
|
||||
Path::new(sysroot.trim())
|
||||
.join("lib")
|
||||
.join("rustlib")
|
||||
.join(rustc_host_triple()?)
|
||||
.join("bin"),
|
||||
)
|
||||
}
|
||||
|
||||
fn rustc_host_triple() -> Option<String> {
|
||||
let output = Command::new("rustc").arg("-vV").output().ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let version = String::from_utf8(output.stdout).ok()?;
|
||||
parse_rustc_host(&version).map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn parse_rustc_host(version: &str) -> Option<&str> {
|
||||
version
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("host:").map(str::trim))
|
||||
.filter(|host| !host.is_empty())
|
||||
}
|
||||
|
||||
fn read_version(tool_path: &Path) -> Option<String> {
|
||||
let output = Command::new(tool_path).arg("--version").output().ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
String::from_utf8(output.stdout)
|
||||
.ok()
|
||||
.and_then(|text| text.lines().next().map(str::trim).map(ToOwned::to_owned))
|
||||
.filter(|line| !line.is_empty())
|
||||
}
|
||||
|
||||
fn bounded_string(text: &str, limit: usize) -> (String, bool) {
|
||||
if text.len() <= limit {
|
||||
return (text.to_owned(), false);
|
||||
}
|
||||
let mut end = limit;
|
||||
while !text.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
(text[..end].to_owned(), true)
|
||||
}
|
||||
|
||||
fn missing_backend_error(binary_name: &str, searched: &[PathBuf]) -> CliError {
|
||||
let searched_text = searched
|
||||
.iter()
|
||||
.take(12)
|
||||
.map(|path| path.display().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ");
|
||||
CliError::runtime(format!(
|
||||
"could not find {binary_name}. Discovery order: --llvm-bin-dir, MERCURY_LLVM_BIN, PATH, then rustc --print sysroot lib\\rustlib\\<host>\\bin. Searched: {searched_text}"
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bounded_string_truncates_on_char_boundary() {
|
||||
let (text, truncated) = bounded_string("abcédef", 5);
|
||||
assert_eq!(text, "abcé");
|
||||
assert!(truncated);
|
||||
let (text, truncated) = bounded_string("short", 99);
|
||||
assert_eq!(text, "short");
|
||||
assert!(!truncated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_missing_dir_reports_discovery_hints() {
|
||||
let dir = Path::new("Z:/definitely/missing");
|
||||
let error = find_tool_by_name(ToolKind::Nm.llvm_binary_name(), Some(dir))
|
||||
.expect_err("missing backend");
|
||||
let message = error.to_string();
|
||||
assert!(message.contains("MERCURY_LLVM_BIN"));
|
||||
assert!(message.contains("rustc --print sysroot"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidate_lookup_and_executable_names_are_platform_aware() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let name = if cfg!(windows) {
|
||||
"fake-llvm.exe"
|
||||
} else {
|
||||
"fake-llvm"
|
||||
};
|
||||
let tool = temp.path().join(name);
|
||||
std::fs::write(&tool, b"").expect("tool placeholder");
|
||||
|
||||
let mut searched = Vec::new();
|
||||
assert_eq!(
|
||||
candidate_in_dir(temp.path(), "fake-llvm", &mut searched),
|
||||
Some(tool)
|
||||
);
|
||||
assert_eq!(searched.len(), 1);
|
||||
assert!(
|
||||
executable_name("fake-llvm")
|
||||
.to_string_lossy()
|
||||
.contains("fake-llvm")
|
||||
);
|
||||
|
||||
let mut searched = Vec::new();
|
||||
assert_eq!(
|
||||
candidate_in_dir(temp.path(), "missing", &mut searched),
|
||||
None
|
||||
);
|
||||
let message = missing_backend_error("missing", &searched).to_string();
|
||||
assert!(message.contains("could not find missing"));
|
||||
assert!(message.contains("Searched:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn llvm_backend_runs_version_command_when_backend_is_available() {
|
||||
let output = run_llvm_binary("llvm-readobj", None, &["--version".to_string()], 256)
|
||||
.expect("llvm-readobj should be available through repo toolchain");
|
||||
|
||||
assert_eq!(output.exit_code, 0);
|
||||
assert!(output.info.tool_path.is_file());
|
||||
assert_eq!(output.info.argv, vec!["--version"]);
|
||||
assert!(!output.stdout.is_empty());
|
||||
assert!(!output.stdout_truncated);
|
||||
assert!(!output.stderr_truncated);
|
||||
|
||||
let via_tool = run_backend(ToolKind::Readobj, None, &["--version".to_string()], 256)
|
||||
.expect("run_backend delegates to llvm-readobj");
|
||||
assert_eq!(via_tool.exit_code, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_rustc_host_from_verbose_version_output() {
|
||||
let version = "rustc 1.86.0\nbinary: rustc\nhost: x86_64-pc-windows-msvc\n";
|
||||
assert_eq!(parse_rustc_host(version), Some("x86_64-pc-windows-msvc"));
|
||||
assert_eq!(parse_rustc_host("rustc 1.86.0\nhost:\n"), None);
|
||||
assert_eq!(
|
||||
rustc_host_triple().as_deref(),
|
||||
parse_rustc_host(
|
||||
&String::from_utf8(
|
||||
Command::new("rustc")
|
||||
.arg("-vV")
|
||||
.output()
|
||||
.expect("rustc")
|
||||
.stdout
|
||||
)
|
||||
.expect("utf8")
|
||||
)
|
||||
);
|
||||
assert!(rustup_llvm_bin_dir().is_some());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,908 @@
|
||||
//! CLI parsing, orchestration, and rendering for the LLVM wrappers.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::fmt::Write as _;
|
||||
use std::io::{self, Read};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use common::{
|
||||
CliError, CommonArgs, ExitCode, RenderMode, parse_color_choice, parse_format_choice,
|
||||
parse_input_format, print_quick_help_error, print_structured, should_read_stdin,
|
||||
};
|
||||
use lexopt::prelude::{Long, Short, Value as ArgValue};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::backend::{BackendInfo, RawBlock, run_backend};
|
||||
use crate::parse_nm::{NmReport, parse_nm_output};
|
||||
use crate::parse_objdump::{ObjdumpReport, parse_objdump_output};
|
||||
use crate::parse_readobj::{ReadobjReport, parse_readobj_output};
|
||||
use crate::pe_summary::{PeSummary, summarize_pe};
|
||||
|
||||
const DEFAULT_RAW_OUTPUT_LIMIT: usize = 262_144;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
/// Selects which LLVM object tool wrapper is being executed.
|
||||
pub enum ToolKind {
|
||||
/// `llvm-objdump` wrapper.
|
||||
Objdump,
|
||||
/// `llvm-readobj` wrapper.
|
||||
Readobj,
|
||||
/// `llvm-nm` wrapper.
|
||||
Nm,
|
||||
}
|
||||
|
||||
impl ToolKind {
|
||||
/// Returns the Mercury command name.
|
||||
#[must_use]
|
||||
pub const fn command_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Objdump => "llvmobjdump",
|
||||
Self::Readobj => "llvmreadobj",
|
||||
Self::Nm => "llvmnm",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the upstream LLVM executable stem.
|
||||
#[must_use]
|
||||
pub const fn llvm_binary_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Objdump => "llvm-objdump",
|
||||
Self::Readobj => "llvm-readobj",
|
||||
Self::Nm => "llvm-nm",
|
||||
}
|
||||
}
|
||||
|
||||
const fn help(self) -> &'static str {
|
||||
match self {
|
||||
Self::Objdump => OBJDUMP_HELP,
|
||||
Self::Readobj => READOBJ_HELP,
|
||||
Self::Nm => NM_HELP,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const OBJDUMP_HELP: &str = "\
|
||||
Inspect object files through LLVM objdump with Mercury-friendly output.
|
||||
|
||||
Usage:
|
||||
llvmobjdump [OPTIONS] [PATH...]
|
||||
|
||||
Options:
|
||||
--format <FORMAT> Structured output format: text, json, toon
|
||||
--json Shortcut for --format json
|
||||
--toon Shortcut for --format toon
|
||||
--input-format <FORMAT> Override stdin parsing mode: auto, lines, jsonl
|
||||
--color <WHEN> Control ANSI color output: auto, never
|
||||
--quiet Suppress non-essential status output
|
||||
--llvm-bin-dir <DIR> Directory containing llvm-objdump
|
||||
--llvm-arg <ARG> Append a raw LLVM argument
|
||||
--raw-output-limit <BYTES> Maximum captured stdout bytes (default: 262144)
|
||||
--section <NAME> Restrict LLVM output to a section
|
||||
--symbol <NAME> Disassemble one symbol
|
||||
--start-address <HEX> Start address for disassembly
|
||||
--stop-address <HEX> Stop address for disassembly
|
||||
--relocs Include relocations
|
||||
--headers, --section-headers Include section headers
|
||||
--all Include all available headers
|
||||
-h, --help Show this help text
|
||||
-V, --version Show the command version
|
||||
|
||||
Examples:
|
||||
llvmobjdump .\\target\\release-fast\\binmeta.exe --json | ConvertFrom-Json
|
||||
@('.\\target\\release-fast\\binmeta.exe') | llvmobjdump --input-format lines --toon
|
||||
fd -e exe .\\target\\release-fast | llvmobjdump --input-format lines --toon
|
||||
llvmobjdump .\\target\\release-fast\\binmeta.exe --section .text --toon
|
||||
";
|
||||
|
||||
const READOBJ_HELP: &str = "\
|
||||
Inspect COFF/PE object metadata through LLVM readobj with Mercury-friendly output.
|
||||
|
||||
Usage:
|
||||
llvmreadobj [OPTIONS] [PATH...]
|
||||
|
||||
Options:
|
||||
--format <FORMAT> Structured output format: text, json, toon
|
||||
--json Shortcut for --format json
|
||||
--toon Shortcut for --format toon
|
||||
--input-format <FORMAT> Override stdin parsing mode: auto, lines, jsonl
|
||||
--color <WHEN> Control ANSI color output: auto, never
|
||||
--quiet Suppress non-essential status output
|
||||
--llvm-bin-dir <DIR> Directory containing llvm-readobj
|
||||
--llvm-arg <ARG> Append a raw LLVM argument
|
||||
--raw-output-limit <BYTES> Maximum captured stdout bytes (default: 262144)
|
||||
--symbols Include symbols
|
||||
--relocations Include relocations
|
||||
--exports Include COFF exports
|
||||
--debug-directory Include COFF debug directory
|
||||
--codeview Include CodeView debug information
|
||||
--all Include all available readobj sections
|
||||
-h, --help Show this help text
|
||||
-V, --version Show the command version
|
||||
|
||||
Examples:
|
||||
llvmreadobj .\\target\\release-fast\\binmeta.exe --json | ConvertFrom-Json
|
||||
@('.\\target\\release-fast\\binmeta.exe') | llvmreadobj --input-format lines --toon
|
||||
fd -e exe .\\target\\release-fast | llvmreadobj --input-format lines --toon
|
||||
llvmreadobj .\\target\\release-fast\\binmeta.exe --debug-directory --toon
|
||||
";
|
||||
|
||||
const NM_HELP: &str = "\
|
||||
Inspect object symbols through LLVM nm with Mercury-friendly output.
|
||||
|
||||
Usage:
|
||||
llvmnm [OPTIONS] [PATH...]
|
||||
|
||||
Options:
|
||||
--format <FORMAT> Structured output format: text, json, toon
|
||||
--json Shortcut for --format json
|
||||
--toon Shortcut for --format toon
|
||||
--input-format <FORMAT> Override stdin parsing mode: auto, lines, jsonl
|
||||
--color <WHEN> Control ANSI color output: auto, never
|
||||
--quiet Suppress non-essential status output
|
||||
--llvm-bin-dir <DIR> Directory containing llvm-nm
|
||||
--llvm-arg <ARG> Append a raw LLVM argument
|
||||
--raw-output-limit <BYTES> Maximum captured stdout bytes (default: 262144)
|
||||
--defined-only Show only defined symbols (default)
|
||||
--undefined-only Show only undefined symbols
|
||||
--extern-only Show only external symbols
|
||||
--demangle Demangle names (default)
|
||||
--no-demangle Do not demangle names
|
||||
--sort <KEY> Sort symbols: address, name, size, none
|
||||
--print-file-name Prefix symbol rows with file names
|
||||
-h, --help Show this help text
|
||||
-V, --version Show the command version
|
||||
|
||||
Examples:
|
||||
llvmnm .\\target\\release-fast\\binmeta.exe --defined-only --json | ConvertFrom-Json
|
||||
@('.\\target\\release-fast\\binmeta.exe') | llvmnm --input-format lines --toon
|
||||
fd -e exe .\\target\\release-fast | llvmnm --input-format lines --toon
|
||||
llvmnm .\\target\\release-fast\\binmeta.exe --undefined-only --toon
|
||||
";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Cli {
|
||||
common: CommonArgs,
|
||||
paths: Vec<PathBuf>,
|
||||
llvm_bin_dir: Option<PathBuf>,
|
||||
llvm_args: Vec<String>,
|
||||
raw_output_limit: usize,
|
||||
flags: ToolFlags,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct ToolFlags {
|
||||
objdump: ObjdumpFlags,
|
||||
readobj: ReadobjFlags,
|
||||
nm: NmFlags,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct ObjdumpFlags {
|
||||
sections: Vec<String>,
|
||||
symbols: Vec<String>,
|
||||
start_address: Option<String>,
|
||||
stop_address: Option<String>,
|
||||
relocs: bool,
|
||||
headers: bool,
|
||||
all: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
#[allow(
|
||||
clippy::struct_excessive_bools,
|
||||
reason = "these booleans map directly to independent llvm-readobj flags"
|
||||
)]
|
||||
struct ReadobjFlags {
|
||||
symbols: bool,
|
||||
relocations: bool,
|
||||
exports: bool,
|
||||
debug_directory: bool,
|
||||
codeview: bool,
|
||||
all: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(
|
||||
clippy::struct_excessive_bools,
|
||||
reason = "these booleans map directly to independent llvm-nm flags"
|
||||
)]
|
||||
struct NmFlags {
|
||||
defined_only: bool,
|
||||
undefined_only: bool,
|
||||
extern_only: bool,
|
||||
demangle: bool,
|
||||
sort: NmSort,
|
||||
print_file_name: bool,
|
||||
}
|
||||
|
||||
impl Default for NmFlags {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
defined_only: true,
|
||||
undefined_only: false,
|
||||
extern_only: false,
|
||||
demangle: true,
|
||||
sort: NmSort::Address,
|
||||
print_file_name: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum NmSort {
|
||||
Address,
|
||||
Name,
|
||||
Size,
|
||||
None,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ParseOutcome {
|
||||
Help,
|
||||
Version,
|
||||
Run,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ToolReport {
|
||||
tool: &'static str,
|
||||
backend: BackendInfo,
|
||||
inputs: Vec<PathBuf>,
|
||||
summary: Summary,
|
||||
files: Vec<FileReport>,
|
||||
parse_warnings: Vec<String>,
|
||||
raw_blocks: Vec<RawBlock>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
#[allow(
|
||||
clippy::struct_field_names,
|
||||
reason = "the JSON contract intentionally names summary counters with a count suffix"
|
||||
)]
|
||||
struct Summary {
|
||||
file_count: usize,
|
||||
success_count: usize,
|
||||
failed_count: usize,
|
||||
section_count: usize,
|
||||
symbol_count: usize,
|
||||
instruction_count: usize,
|
||||
import_library_count: usize,
|
||||
export_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct FileReport {
|
||||
path: PathBuf,
|
||||
format: Option<String>,
|
||||
arch: Option<String>,
|
||||
address_size: Option<String>,
|
||||
exit_code: i32,
|
||||
stderr_tail: Option<String>,
|
||||
pe: Option<PeSummary>,
|
||||
objdump: Option<ObjdumpReport>,
|
||||
readobj: Option<ReadobjReport>,
|
||||
nm: Option<NmReport>,
|
||||
}
|
||||
|
||||
/// Parses CLI arguments, runs the selected LLVM wrapper, and returns a process exit code.
|
||||
#[must_use]
|
||||
pub fn main_entry(tool: ToolKind) -> i32 {
|
||||
match parse_cli_from(std::env::args_os(), tool) {
|
||||
Ok((ParseOutcome::Help, _)) => {
|
||||
print!("{}", tool.help());
|
||||
ExitCode::Success.as_i32()
|
||||
}
|
||||
Ok((ParseOutcome::Version, _)) => {
|
||||
println!("{} {}", tool.command_name(), env!("CARGO_PKG_VERSION"));
|
||||
ExitCode::Success.as_i32()
|
||||
}
|
||||
Ok((ParseOutcome::Run, cli)) => match run(tool, &cli) {
|
||||
Ok(code) => code.as_i32(),
|
||||
Err(error) => {
|
||||
print_quick_help_error(&error, tool.help());
|
||||
error.exit_code().as_i32()
|
||||
}
|
||||
},
|
||||
Err(error) => {
|
||||
print_quick_help_error(&error, tool.help());
|
||||
error.exit_code().as_i32()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(
|
||||
clippy::too_many_lines,
|
||||
reason = "the parser keeps each supported CLI flag visible beside its validation"
|
||||
)]
|
||||
fn parse_cli_from<I, T>(args: I, tool: ToolKind) -> Result<(ParseOutcome, Cli), CliError>
|
||||
where
|
||||
I: IntoIterator<Item = T>,
|
||||
T: Into<OsString>,
|
||||
{
|
||||
let mut parser = lexopt::Parser::from_iter(args);
|
||||
let mut cli = Cli {
|
||||
common: CommonArgs::default(),
|
||||
paths: Vec::new(),
|
||||
llvm_bin_dir: None,
|
||||
llvm_args: Vec::new(),
|
||||
raw_output_limit: DEFAULT_RAW_OUTPUT_LIMIT,
|
||||
flags: ToolFlags::default(),
|
||||
};
|
||||
|
||||
while let Some(argument) = parser
|
||||
.next()
|
||||
.map_err(|error| CliError::usage(error.to_string()))?
|
||||
{
|
||||
match argument {
|
||||
Long("help") | Short('h') => return Ok((ParseOutcome::Help, cli)),
|
||||
Long("version") | Short('V') => return Ok((ParseOutcome::Version, cli)),
|
||||
Long("json") => cli.common.set_render_mode(RenderMode::Json),
|
||||
Long("toon") => cli.common.set_render_mode(RenderMode::Toon),
|
||||
Long("format") => {
|
||||
let value = parser_value_string(&mut parser, "--format")?;
|
||||
cli.common.set_render_mode(parse_format_choice(&value)?);
|
||||
}
|
||||
Long("input-format") => {
|
||||
let value = parser_value_string(&mut parser, "--input-format")?;
|
||||
cli.common.input_format = parse_input_format(&value)?;
|
||||
}
|
||||
Long("color") => {
|
||||
let value = parser_value_string(&mut parser, "--color")?;
|
||||
cli.common.color = parse_color_choice(&value)?;
|
||||
}
|
||||
Long("quiet") => cli.common.quiet = true,
|
||||
Long("llvm-bin-dir") => {
|
||||
cli.llvm_bin_dir = Some(PathBuf::from(parser_value_string(
|
||||
&mut parser,
|
||||
"--llvm-bin-dir",
|
||||
)?));
|
||||
}
|
||||
Long("llvm-arg") => cli
|
||||
.llvm_args
|
||||
.push(parser_value_string(&mut parser, "--llvm-arg")?),
|
||||
Long("raw-output-limit") => {
|
||||
let value = parser_value_string(&mut parser, "--raw-output-limit")?;
|
||||
cli.raw_output_limit = value.parse().map_err(|error| {
|
||||
CliError::usage(format!("--raw-output-limit expects a byte count: {error}"))
|
||||
})?;
|
||||
}
|
||||
Long("section") => {
|
||||
require_tool(tool, ToolKind::Objdump, "--section")?;
|
||||
cli.flags
|
||||
.objdump
|
||||
.sections
|
||||
.push(parser_value_string(&mut parser, "--section")?);
|
||||
}
|
||||
Long("symbol") => {
|
||||
require_tool(tool, ToolKind::Objdump, "--symbol")?;
|
||||
cli.flags
|
||||
.objdump
|
||||
.symbols
|
||||
.push(parser_value_string(&mut parser, "--symbol")?);
|
||||
}
|
||||
Long("start-address") => {
|
||||
require_tool(tool, ToolKind::Objdump, "--start-address")?;
|
||||
cli.flags.objdump.start_address =
|
||||
Some(parser_value_string(&mut parser, "--start-address")?);
|
||||
}
|
||||
Long("stop-address") => {
|
||||
require_tool(tool, ToolKind::Objdump, "--stop-address")?;
|
||||
cli.flags.objdump.stop_address =
|
||||
Some(parser_value_string(&mut parser, "--stop-address")?);
|
||||
}
|
||||
Long("relocs") => {
|
||||
require_tool(tool, ToolKind::Objdump, "--relocs")?;
|
||||
cli.flags.objdump.relocs = true;
|
||||
}
|
||||
Long("headers" | "section-headers") => {
|
||||
require_tool(tool, ToolKind::Objdump, "--headers")?;
|
||||
cli.flags.objdump.headers = true;
|
||||
}
|
||||
Long("all") if tool == ToolKind::Objdump => cli.flags.objdump.all = true,
|
||||
Long("symbols") => {
|
||||
require_tool(tool, ToolKind::Readobj, "--symbols")?;
|
||||
cli.flags.readobj.symbols = true;
|
||||
}
|
||||
Long("relocations") => {
|
||||
require_tool(tool, ToolKind::Readobj, "--relocations")?;
|
||||
cli.flags.readobj.relocations = true;
|
||||
}
|
||||
Long("exports") => {
|
||||
require_tool(tool, ToolKind::Readobj, "--exports")?;
|
||||
cli.flags.readobj.exports = true;
|
||||
}
|
||||
Long("debug-directory") => {
|
||||
require_tool(tool, ToolKind::Readobj, "--debug-directory")?;
|
||||
cli.flags.readobj.debug_directory = true;
|
||||
}
|
||||
Long("codeview") => {
|
||||
require_tool(tool, ToolKind::Readobj, "--codeview")?;
|
||||
cli.flags.readobj.codeview = true;
|
||||
}
|
||||
Long("all") if tool == ToolKind::Readobj => cli.flags.readobj.all = true,
|
||||
Long("defined-only") => {
|
||||
require_tool(tool, ToolKind::Nm, "--defined-only")?;
|
||||
cli.flags.nm.defined_only = true;
|
||||
cli.flags.nm.undefined_only = false;
|
||||
}
|
||||
Long("undefined-only") => {
|
||||
require_tool(tool, ToolKind::Nm, "--undefined-only")?;
|
||||
cli.flags.nm.undefined_only = true;
|
||||
cli.flags.nm.defined_only = false;
|
||||
}
|
||||
Long("extern-only") => {
|
||||
require_tool(tool, ToolKind::Nm, "--extern-only")?;
|
||||
cli.flags.nm.extern_only = true;
|
||||
}
|
||||
Long("demangle") => {
|
||||
require_tool(tool, ToolKind::Nm, "--demangle")?;
|
||||
cli.flags.nm.demangle = true;
|
||||
}
|
||||
Long("no-demangle") => {
|
||||
require_tool(tool, ToolKind::Nm, "--no-demangle")?;
|
||||
cli.flags.nm.demangle = false;
|
||||
}
|
||||
Long("sort") => {
|
||||
require_tool(tool, ToolKind::Nm, "--sort")?;
|
||||
cli.flags.nm.sort = parse_nm_sort(&parser_value_string(&mut parser, "--sort")?)?;
|
||||
}
|
||||
Long("print-file-name") => {
|
||||
require_tool(tool, ToolKind::Nm, "--print-file-name")?;
|
||||
cli.flags.nm.print_file_name = true;
|
||||
}
|
||||
ArgValue(path) => cli.paths.push(PathBuf::from(path)),
|
||||
_ => {
|
||||
return Err(CliError::usage(
|
||||
"unsupported argument; use --help to see available options",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((ParseOutcome::Run, cli))
|
||||
}
|
||||
|
||||
fn require_tool(actual: ToolKind, expected: ToolKind, flag: &str) -> Result<(), CliError> {
|
||||
if actual == expected {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(CliError::usage(format!(
|
||||
"{flag} is not supported by {}",
|
||||
actual.command_name()
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result<String, CliError> {
|
||||
let value = parser
|
||||
.value()
|
||||
.map_err(|error| CliError::usage(error.to_string()))?;
|
||||
value.into_string().map_err(|invalid| {
|
||||
CliError::usage(format!(
|
||||
"{flag} expects UTF-8 text, got '{}'",
|
||||
invalid.to_string_lossy()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_nm_sort(value: &str) -> Result<NmSort, CliError> {
|
||||
match value {
|
||||
"address" => Ok(NmSort::Address),
|
||||
"name" => Ok(NmSort::Name),
|
||||
"size" => Ok(NmSort::Size),
|
||||
"none" => Ok(NmSort::None),
|
||||
other => Err(CliError::usage(format!(
|
||||
"invalid --sort value '{other}'; expected address, name, size, or none"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn run(tool: ToolKind, cli: &Cli) -> Result<ExitCode, CliError> {
|
||||
let paths = collect_paths(cli, tool.command_name())?;
|
||||
if paths.is_empty() {
|
||||
return Err(CliError::usage(
|
||||
"provide at least one path or pipe paths into stdin",
|
||||
));
|
||||
}
|
||||
|
||||
let mut files = Vec::new();
|
||||
let mut parse_warnings = Vec::new();
|
||||
let mut raw_blocks = Vec::new();
|
||||
let mut backend_info = None;
|
||||
for path in &paths {
|
||||
if !path.exists() {
|
||||
return Err(CliError::runtime(format!(
|
||||
"input path does not exist: {}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
let args = build_llvm_args(tool, cli, path);
|
||||
let backend = run_backend(
|
||||
tool,
|
||||
cli.llvm_bin_dir.as_deref(),
|
||||
&args,
|
||||
cli.raw_output_limit,
|
||||
)?;
|
||||
backend_info = Some(backend.info.clone());
|
||||
if backend.exit_code != 0 {
|
||||
return Err(CliError::runtime(format!(
|
||||
"{} failed for {} with exit code {}: {}",
|
||||
tool.llvm_binary_name(),
|
||||
path.display(),
|
||||
backend.exit_code,
|
||||
tail(&backend.stderr, 2000).unwrap_or_default()
|
||||
)));
|
||||
}
|
||||
let (file, warnings, blocks) = parse_file_report(
|
||||
tool,
|
||||
path,
|
||||
&backend.stdout,
|
||||
&backend.stderr,
|
||||
backend.exit_code,
|
||||
backend.stdout_truncated,
|
||||
);
|
||||
parse_warnings.extend(warnings);
|
||||
raw_blocks.extend(blocks);
|
||||
files.push(file);
|
||||
}
|
||||
|
||||
let report = ToolReport {
|
||||
tool: tool.command_name(),
|
||||
backend: backend_info.expect("backend ran for at least one file"),
|
||||
inputs: paths,
|
||||
summary: summarize_files(&files),
|
||||
files,
|
||||
parse_warnings,
|
||||
raw_blocks,
|
||||
};
|
||||
|
||||
match cli.common.render_mode() {
|
||||
RenderMode::Json | RenderMode::Toon => print_structured(&report, cli.common.render_mode())?,
|
||||
RenderMode::Text => print!("{}", render_text_report(&report)),
|
||||
}
|
||||
Ok(ExitCode::Success)
|
||||
}
|
||||
|
||||
fn collect_paths(cli: &Cli, command_name: &str) -> Result<Vec<PathBuf>, CliError> {
|
||||
if should_read_stdin(!cli.paths.is_empty(), cli.common.stdin_is_terminal()) {
|
||||
let mut buffer = String::new();
|
||||
io::stdin()
|
||||
.read_to_string(&mut buffer)
|
||||
.map_err(|error| CliError::runtime(format!("failed to read stdin: {error}")))?;
|
||||
if let Some(paths) = common::read_existing_stdin_path_records(
|
||||
&buffer,
|
||||
cli.common.input_format,
|
||||
command_name,
|
||||
)? {
|
||||
return Ok(paths);
|
||||
}
|
||||
}
|
||||
common::expand_input_patterns(&cli.paths, command_name)
|
||||
}
|
||||
|
||||
fn build_llvm_args(tool: ToolKind, cli: &Cli, path: &std::path::Path) -> Vec<String> {
|
||||
let mut args = match tool {
|
||||
ToolKind::Objdump => objdump_args(&cli.flags.objdump),
|
||||
ToolKind::Readobj => readobj_args(&cli.flags.readobj),
|
||||
ToolKind::Nm => nm_args(&cli.flags.nm),
|
||||
};
|
||||
args.extend(cli.llvm_args.iter().cloned());
|
||||
args.push(path.display().to_string());
|
||||
args
|
||||
}
|
||||
|
||||
fn objdump_args(flags: &ObjdumpFlags) -> Vec<String> {
|
||||
let mut args = Vec::new();
|
||||
if flags.all {
|
||||
args.push("--all-headers".to_owned());
|
||||
} else {
|
||||
args.extend([
|
||||
"--file-headers".to_owned(),
|
||||
"--section-headers".to_owned(),
|
||||
"--disassemble".to_owned(),
|
||||
"--demangle".to_owned(),
|
||||
]);
|
||||
}
|
||||
if flags.headers && !args.iter().any(|arg| arg == "--section-headers") {
|
||||
args.push("--section-headers".to_owned());
|
||||
}
|
||||
if flags.relocs {
|
||||
args.push("--reloc".to_owned());
|
||||
}
|
||||
for section in &flags.sections {
|
||||
args.push(format!("--section={section}"));
|
||||
}
|
||||
for symbol in &flags.symbols {
|
||||
args.push(format!("--disassemble-symbols={symbol}"));
|
||||
}
|
||||
if let Some(start) = &flags.start_address {
|
||||
args.push(format!("--start-address={start}"));
|
||||
}
|
||||
if let Some(stop) = &flags.stop_address {
|
||||
args.push(format!("--stop-address={stop}"));
|
||||
}
|
||||
args
|
||||
}
|
||||
|
||||
fn readobj_args(flags: &ReadobjFlags) -> Vec<String> {
|
||||
let mut args = if flags.all {
|
||||
vec!["--all".to_owned()]
|
||||
} else {
|
||||
vec![
|
||||
"--file-headers".to_owned(),
|
||||
"--sections".to_owned(),
|
||||
"--coff-imports".to_owned(),
|
||||
]
|
||||
};
|
||||
if flags.symbols {
|
||||
args.push("--symbols".to_owned());
|
||||
}
|
||||
if flags.relocations {
|
||||
args.push("--relocations".to_owned());
|
||||
}
|
||||
if flags.exports {
|
||||
args.push("--coff-exports".to_owned());
|
||||
}
|
||||
if flags.debug_directory {
|
||||
args.push("--coff-debug-directory".to_owned());
|
||||
}
|
||||
if flags.codeview {
|
||||
args.push("--codeview".to_owned());
|
||||
}
|
||||
args
|
||||
}
|
||||
|
||||
fn nm_args(flags: &NmFlags) -> Vec<String> {
|
||||
let mut args = vec!["--format=posix".to_owned(), "--print-size".to_owned()];
|
||||
if flags.defined_only {
|
||||
args.push("--defined-only".to_owned());
|
||||
}
|
||||
if flags.undefined_only {
|
||||
args.push("--undefined-only".to_owned());
|
||||
}
|
||||
if flags.extern_only {
|
||||
args.push("--extern-only".to_owned());
|
||||
}
|
||||
if flags.demangle {
|
||||
args.push("--demangle".to_owned());
|
||||
} else {
|
||||
args.push("--no-demangle".to_owned());
|
||||
}
|
||||
if flags.print_file_name {
|
||||
args.push("--print-file-name".to_owned());
|
||||
}
|
||||
match flags.sort {
|
||||
NmSort::Address => args.push("--numeric-sort".to_owned()),
|
||||
NmSort::Name => {}
|
||||
NmSort::Size => args.push("--size-sort".to_owned()),
|
||||
NmSort::None => args.push("--no-sort".to_owned()),
|
||||
}
|
||||
args
|
||||
}
|
||||
|
||||
fn parse_file_report(
|
||||
tool: ToolKind,
|
||||
path: &std::path::Path,
|
||||
stdout: &str,
|
||||
stderr: &str,
|
||||
exit_code: i32,
|
||||
stdout_truncated: bool,
|
||||
) -> (FileReport, Vec<String>, Vec<RawBlock>) {
|
||||
let mut warnings = Vec::new();
|
||||
let mut raw_blocks = Vec::new();
|
||||
let pe = summarize_pe(path);
|
||||
let mut report = FileReport {
|
||||
path: path.to_path_buf(),
|
||||
format: None,
|
||||
arch: pe.as_ref().map(|summary| summary.architecture.clone()),
|
||||
address_size: pe.as_ref().map(|summary| format!("{}bit", summary.bitness)),
|
||||
exit_code,
|
||||
stderr_tail: if tool == ToolKind::Nm && stderr.trim().eq_ignore_ascii_case("no symbols") {
|
||||
None
|
||||
} else {
|
||||
tail(stderr, 4000)
|
||||
},
|
||||
pe,
|
||||
objdump: None,
|
||||
readobj: None,
|
||||
nm: None,
|
||||
};
|
||||
if stdout_truncated {
|
||||
warnings.push(format!(
|
||||
"{} stdout for {} was truncated",
|
||||
tool.llvm_binary_name(),
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
match tool {
|
||||
ToolKind::Objdump => {
|
||||
let parsed = parse_objdump_output(stdout);
|
||||
report.format.clone_from(&parsed.report.format);
|
||||
report.arch.clone_from(&parsed.report.architecture);
|
||||
warnings.extend(parsed.warnings);
|
||||
if !parsed.raw_lines.is_empty() {
|
||||
raw_blocks.push(RawBlock {
|
||||
path: Some(path.to_path_buf()),
|
||||
label: "llvm-objdump-unparsed".to_owned(),
|
||||
text: parsed.raw_lines.join("\n"),
|
||||
truncated: stdout_truncated,
|
||||
});
|
||||
}
|
||||
report.objdump = Some(parsed.report);
|
||||
}
|
||||
ToolKind::Readobj => {
|
||||
let parsed = parse_readobj_output(stdout);
|
||||
report.format.clone_from(&parsed.report.format);
|
||||
report.arch.clone_from(&parsed.report.arch);
|
||||
report.address_size.clone_from(&parsed.report.address_size);
|
||||
warnings.extend(parsed.warnings);
|
||||
if !parsed.raw_lines.is_empty() {
|
||||
raw_blocks.push(RawBlock {
|
||||
path: Some(path.to_path_buf()),
|
||||
label: "llvm-readobj-unparsed".to_owned(),
|
||||
text: parsed.raw_lines.join("\n"),
|
||||
truncated: stdout_truncated,
|
||||
});
|
||||
}
|
||||
report.readobj = Some(parsed.report);
|
||||
}
|
||||
ToolKind::Nm => {
|
||||
let parsed = parse_nm_output(stdout);
|
||||
warnings.extend(parsed.warnings);
|
||||
if !parsed.raw_lines.is_empty() {
|
||||
raw_blocks.push(RawBlock {
|
||||
path: Some(path.to_path_buf()),
|
||||
label: "llvm-nm-raw".to_owned(),
|
||||
text: parsed.raw_lines.join("\n"),
|
||||
truncated: stdout_truncated,
|
||||
});
|
||||
}
|
||||
report.nm = Some(parsed.report);
|
||||
}
|
||||
}
|
||||
(report, warnings, raw_blocks)
|
||||
}
|
||||
|
||||
fn summarize_files(files: &[FileReport]) -> Summary {
|
||||
let mut summary = Summary {
|
||||
file_count: files.len(),
|
||||
success_count: files.iter().filter(|file| file.exit_code == 0).count(),
|
||||
failed_count: files.iter().filter(|file| file.exit_code != 0).count(),
|
||||
..Summary::default()
|
||||
};
|
||||
for file in files {
|
||||
if let Some(objdump) = &file.objdump {
|
||||
summary.section_count += objdump.sections.len();
|
||||
summary.symbol_count += objdump.symbols.len();
|
||||
summary.instruction_count += objdump.instruction_count;
|
||||
}
|
||||
if let Some(readobj) = &file.readobj {
|
||||
summary.section_count += readobj.sections.len();
|
||||
summary.import_library_count += readobj.imports.len();
|
||||
summary.export_count += readobj.export_count;
|
||||
}
|
||||
if let Some(nm) = &file.nm {
|
||||
summary.symbol_count += nm.symbol_count;
|
||||
}
|
||||
}
|
||||
summary
|
||||
}
|
||||
|
||||
fn render_text_report(report: &ToolReport) -> String {
|
||||
let mut output = String::new();
|
||||
let _ = writeln!(
|
||||
output,
|
||||
"{} files={} sections={} symbols={} instructions={} imports={} exports={}",
|
||||
report.tool,
|
||||
report.summary.file_count,
|
||||
report.summary.section_count,
|
||||
report.summary.symbol_count,
|
||||
report.summary.instruction_count,
|
||||
report.summary.import_library_count,
|
||||
report.summary.export_count
|
||||
);
|
||||
let _ = writeln!(
|
||||
output,
|
||||
"backend={} version={}",
|
||||
report.backend.tool_path.display(),
|
||||
report.backend.version.as_deref().unwrap_or("unknown")
|
||||
);
|
||||
for file in &report.files {
|
||||
let _ = writeln!(
|
||||
output,
|
||||
"file={} format={} arch={} exit_code={}",
|
||||
file.path.display(),
|
||||
file.format.as_deref().unwrap_or("unknown"),
|
||||
file.arch.as_deref().unwrap_or("unknown"),
|
||||
file.exit_code
|
||||
);
|
||||
}
|
||||
for warning in &report.parse_warnings {
|
||||
let _ = writeln!(output, "warning={warning}");
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
fn tail(text: &str, max_bytes: usize) -> Option<String> {
|
||||
if text.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
if text.len() <= max_bytes {
|
||||
return Some(text.trim().to_owned());
|
||||
}
|
||||
let mut start = text.len() - max_bytes;
|
||||
while !text.is_char_boundary(start) {
|
||||
start += 1;
|
||||
}
|
||||
Some(text[start..].trim().to_owned())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn objdump_symbol_flags_are_translated_to_llvm_args() {
|
||||
let flags = ObjdumpFlags {
|
||||
symbols: vec!["main".to_owned()],
|
||||
start_address: Some("0x10".to_owned()),
|
||||
stop_address: Some("0x20".to_owned()),
|
||||
..ObjdumpFlags::default()
|
||||
};
|
||||
let args = objdump_args(&flags);
|
||||
assert!(args.contains(&"--disassemble-symbols=main".to_owned()));
|
||||
assert!(args.contains(&"--start-address=0x10".to_owned()));
|
||||
assert!(args.contains(&"--stop-address=0x20".to_owned()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nm_sort_values_are_strict() {
|
||||
assert_eq!(parse_nm_sort("address").expect("sort"), NmSort::Address);
|
||||
assert!(parse_nm_sort("mtime").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readobj_defaults_include_coff_imports() {
|
||||
let args = readobj_args(&ReadobjFlags::default());
|
||||
assert!(args.contains(&"--file-headers".to_owned()));
|
||||
assert!(args.contains(&"--coff-imports".to_owned()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tail_preserves_short_text_and_trims_long_text() {
|
||||
assert_eq!(tail(" hello ", 100).as_deref(), Some("hello"));
|
||||
assert_eq!(tail("abcdef", 3).as_deref(), Some("def"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objdump_truncated_stdout_marks_unparsed_raw_block_as_truncated() {
|
||||
let stdout = "\
|
||||
sample.obj:\tfile format coff-x86-64
|
||||
architecture: x86_64
|
||||
unparsed disassembly row preserved from truncated output
|
||||
";
|
||||
let path = std::path::PathBuf::from("sample.obj");
|
||||
|
||||
let (file, warnings, raw_blocks) =
|
||||
parse_file_report(ToolKind::Objdump, &path, stdout, "", 0, true);
|
||||
|
||||
assert_eq!(file.format.as_deref(), Some("coff-x86-64"));
|
||||
assert!(
|
||||
warnings
|
||||
.iter()
|
||||
.any(|warning| warning.contains("llvm-objdump stdout")
|
||||
&& warning.contains("truncated")),
|
||||
"expected truncation warning, got {warnings:?}"
|
||||
);
|
||||
|
||||
let raw = raw_blocks
|
||||
.iter()
|
||||
.find(|block| block.label == "llvm-objdump-unparsed")
|
||||
.expect("unparsed raw block");
|
||||
assert!(
|
||||
raw.truncated,
|
||||
"raw objdump block derived from truncated stdout must be marked truncated"
|
||||
);
|
||||
assert!(raw.text.contains("unparsed disassembly row"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//! Mercury wrappers for LLVM object-file tools.
|
||||
#![allow(
|
||||
clippy::redundant_pub_crate,
|
||||
reason = "internal sibling modules share parser and backend contracts without exporting them"
|
||||
)]
|
||||
|
||||
pub mod backend;
|
||||
mod cli;
|
||||
pub(crate) mod parse_nm;
|
||||
pub(crate) mod parse_objdump;
|
||||
pub(crate) mod parse_readobj;
|
||||
pub(crate) mod pe_summary;
|
||||
|
||||
pub use cli::{ToolKind, main_entry};
|
||||
|
||||
/// Parses `llvm-objdump` text into a structured JSON value.
|
||||
#[must_use]
|
||||
pub fn parse_objdump_json_value(text: &str) -> serde_json::Value {
|
||||
serde_json::to_value(parse_objdump::parse_objdump_output(text).report)
|
||||
.unwrap_or(serde_json::Value::Null)
|
||||
}
|
||||
|
||||
/// Parses `llvm-readobj` text into a structured JSON value.
|
||||
#[must_use]
|
||||
pub fn parse_readobj_json_value(text: &str) -> serde_json::Value {
|
||||
serde_json::to_value(parse_readobj::parse_readobj_output(text).report)
|
||||
.unwrap_or(serde_json::Value::Null)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//! Binary entry point for `llvmnm`.
|
||||
|
||||
fn main() {
|
||||
std::process::exit(llvmtools::main_entry(llvmtools::ToolKind::Nm));
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//! Binary entry point for `llvmobjdump`.
|
||||
|
||||
fn main() {
|
||||
std::process::exit(llvmtools::main_entry(llvmtools::ToolKind::Objdump));
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//! Binary entry point for `llvmreadobj`.
|
||||
|
||||
fn main() {
|
||||
std::process::exit(llvmtools::main_entry(llvmtools::ToolKind::Readobj));
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
//! Parser for `llvm-nm --format=posix` output.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
/// Summary parsed from POSIX `llvm-nm` output.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub(crate) struct NmReport {
|
||||
/// Structured symbol rows.
|
||||
pub symbols: Vec<NmSymbol>,
|
||||
/// Number of structured symbol rows.
|
||||
pub symbol_count: usize,
|
||||
}
|
||||
|
||||
/// Symbol row parsed from POSIX `llvm-nm` output.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub(crate) struct NmSymbol {
|
||||
/// Optional file prefix emitted by `--print-file-name`.
|
||||
pub file: Option<PathBuf>,
|
||||
/// Symbol name as printed by `llvm-nm`.
|
||||
pub name: String,
|
||||
/// One-character symbol type.
|
||||
pub symbol_type: String,
|
||||
/// Symbol address, when the symbol is defined.
|
||||
pub address: Option<u64>,
|
||||
/// Symbol size, when the symbol is defined.
|
||||
pub size: Option<u64>,
|
||||
/// Optional source file from line-number output.
|
||||
pub source: Option<String>,
|
||||
}
|
||||
|
||||
/// Full parser result, including raw fallback lines and warnings.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct NmParseResult {
|
||||
/// Structured report.
|
||||
pub report: NmReport,
|
||||
/// Non-fatal parser warnings.
|
||||
pub warnings: Vec<String>,
|
||||
/// Raw lines that could not be parsed as symbol rows.
|
||||
pub raw_lines: Vec<String>,
|
||||
}
|
||||
|
||||
/// Parses POSIX `llvm-nm` text.
|
||||
#[must_use]
|
||||
pub(crate) fn parse_nm_output(text: &str) -> NmParseResult {
|
||||
let mut symbols = Vec::new();
|
||||
let mut warnings = Vec::new();
|
||||
let mut raw_lines = Vec::new();
|
||||
|
||||
for (index, line) in text.lines().enumerate() {
|
||||
match parse_nm_line(line) {
|
||||
Ok(symbol) => symbols.push(symbol),
|
||||
Err(warning) => {
|
||||
warnings.push(format!("line {}: {warning}", index + 1));
|
||||
raw_lines.push(line.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NmParseResult {
|
||||
report: NmReport {
|
||||
symbol_count: symbols.len(),
|
||||
symbols,
|
||||
},
|
||||
warnings,
|
||||
raw_lines,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_nm_line(line: &str) -> Result<NmSymbol, String> {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("blank llvm-nm POSIX line".to_string());
|
||||
}
|
||||
if trimmed.contains("no symbols") {
|
||||
return Err("raw llvm-nm informational line".to_string());
|
||||
}
|
||||
|
||||
let (body, source) = split_source_suffix(trimmed);
|
||||
let (file, symbol_text) = split_file_name_prefix(body);
|
||||
let fields = parse_symbol_fields(symbol_text)?;
|
||||
|
||||
Ok(NmSymbol {
|
||||
file,
|
||||
name: fields.name,
|
||||
symbol_type: fields.kind.to_string(),
|
||||
address: fields.address,
|
||||
size: fields.size,
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct SymbolFields {
|
||||
name: String,
|
||||
kind: char,
|
||||
address: Option<u64>,
|
||||
size: Option<u64>,
|
||||
}
|
||||
|
||||
fn parse_symbol_fields(text: &str) -> Result<SymbolFields, String> {
|
||||
let tokens = text.split_whitespace().collect::<Vec<_>>();
|
||||
if tokens.len() < 2 {
|
||||
return Err("malformed llvm-nm POSIX line: expected symbol name and type".to_string());
|
||||
}
|
||||
|
||||
if let Some(fields) = parse_undefined_symbol_without_numbers(&tokens) {
|
||||
return fields;
|
||||
}
|
||||
|
||||
if tokens.len() < 4 {
|
||||
return Err(
|
||||
"malformed llvm-nm POSIX line: expected name, type, address, and size".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let size_text = tokens[tokens.len() - 1];
|
||||
let address_text = tokens[tokens.len() - 2];
|
||||
let kind_text = tokens[tokens.len() - 3];
|
||||
let name_text = tokens[..tokens.len() - 3].join(" ");
|
||||
|
||||
let Some(kind) = parse_kind(kind_text) else {
|
||||
return Err("malformed llvm-nm POSIX line: invalid symbol type".to_string());
|
||||
};
|
||||
if name_text.is_empty() {
|
||||
return Err("malformed llvm-nm POSIX line: missing symbol name".to_string());
|
||||
}
|
||||
|
||||
let address = parse_number(address_text)
|
||||
.ok_or_else(|| format!("invalid address '{address_text}' in llvm-nm POSIX line"))?;
|
||||
let size = parse_number(size_text)
|
||||
.ok_or_else(|| format!("invalid size '{size_text}' in llvm-nm POSIX line"))?;
|
||||
|
||||
Ok(SymbolFields {
|
||||
name: name_text,
|
||||
kind,
|
||||
address: symbol_address(kind, address),
|
||||
size: symbol_size(kind, size),
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_undefined_symbol_without_numbers(tokens: &[&str]) -> Option<Result<SymbolFields, String>> {
|
||||
let kind_text = tokens.last()?;
|
||||
let kind = parse_kind(kind_text)?;
|
||||
if !is_undefined_kind(kind) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let name_text = tokens[..tokens.len() - 1].join(" ");
|
||||
if name_text.is_empty() {
|
||||
return Some(Err(
|
||||
"malformed llvm-nm POSIX line: missing symbol name".to_string()
|
||||
));
|
||||
}
|
||||
|
||||
Some(Ok(SymbolFields {
|
||||
name: name_text,
|
||||
kind,
|
||||
address: None,
|
||||
size: None,
|
||||
}))
|
||||
}
|
||||
|
||||
const fn symbol_address(kind: char, address: u64) -> Option<u64> {
|
||||
if is_undefined_kind(kind) {
|
||||
None
|
||||
} else {
|
||||
Some(address)
|
||||
}
|
||||
}
|
||||
|
||||
const fn symbol_size(kind: char, size: u64) -> Option<u64> {
|
||||
if is_undefined_kind(kind) {
|
||||
None
|
||||
} else {
|
||||
Some(size)
|
||||
}
|
||||
}
|
||||
|
||||
const fn is_undefined_kind(kind: char) -> bool {
|
||||
matches!(kind, 'U' | 'u')
|
||||
}
|
||||
|
||||
fn parse_kind(text: &str) -> Option<char> {
|
||||
let mut chars = text.chars();
|
||||
let kind = chars.next()?;
|
||||
if chars.next().is_some() || !is_nm_kind(kind) {
|
||||
return None;
|
||||
}
|
||||
Some(kind)
|
||||
}
|
||||
|
||||
const fn is_nm_kind(kind: char) -> bool {
|
||||
matches!(
|
||||
kind,
|
||||
'A' | 'a'
|
||||
| 'B'
|
||||
| 'b'
|
||||
| 'C'
|
||||
| 'c'
|
||||
| 'D'
|
||||
| 'd'
|
||||
| 'G'
|
||||
| 'g'
|
||||
| 'I'
|
||||
| 'i'
|
||||
| 'N'
|
||||
| 'n'
|
||||
| 'P'
|
||||
| 'p'
|
||||
| 'R'
|
||||
| 'r'
|
||||
| 'S'
|
||||
| 's'
|
||||
| 'T'
|
||||
| 't'
|
||||
| 'U'
|
||||
| 'u'
|
||||
| 'V'
|
||||
| 'v'
|
||||
| 'W'
|
||||
| 'w'
|
||||
| '-'
|
||||
| '?'
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_number(value: &str) -> Option<u64> {
|
||||
let trimmed = value
|
||||
.strip_prefix("0x")
|
||||
.or_else(|| value.strip_prefix("0X"))
|
||||
.unwrap_or(value);
|
||||
u64::from_str_radix(trimmed, 16).ok()
|
||||
}
|
||||
|
||||
fn split_file_name_prefix(text: &str) -> (Option<PathBuf>, &str) {
|
||||
let mut search_end = text.len();
|
||||
while let Some(colon_index) = text[..search_end].rfind(':') {
|
||||
let Some(after_separator) = text[colon_index + 1..].chars().next() else {
|
||||
search_end = colon_index;
|
||||
continue;
|
||||
};
|
||||
if !after_separator.is_whitespace() {
|
||||
search_end = colon_index;
|
||||
continue;
|
||||
}
|
||||
|
||||
let after_colon = text[colon_index + 1..].trim_start();
|
||||
if parse_symbol_fields(after_colon).is_ok() {
|
||||
let file_name = text[..colon_index].trim();
|
||||
if !file_name.is_empty() {
|
||||
return (Some(PathBuf::from(file_name)), after_colon);
|
||||
}
|
||||
}
|
||||
search_end = colon_index;
|
||||
}
|
||||
|
||||
(None, text)
|
||||
}
|
||||
|
||||
fn split_source_suffix(text: &str) -> (&str, Option<String>) {
|
||||
let Some((body, source)) = text.split_once('\t') else {
|
||||
return (text, None);
|
||||
};
|
||||
let source = source.trim();
|
||||
if source.is_empty() {
|
||||
return (body.trim_end(), None);
|
||||
}
|
||||
|
||||
(body.trim_end(), Some(source_file_without_line(source)))
|
||||
}
|
||||
|
||||
fn source_file_without_line(source: &str) -> String {
|
||||
let Some((file, line)) = source.rsplit_once(':') else {
|
||||
return source.to_string();
|
||||
};
|
||||
if file.is_empty()
|
||||
|| line.is_empty()
|
||||
|| !line.chars().all(|character| character.is_ascii_digit())
|
||||
{
|
||||
return source.to_string();
|
||||
}
|
||||
file.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn symbol_at(result: &NmParseResult, index: usize) -> &NmSymbol {
|
||||
&result.report.symbols[index]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_posix_defined_symbols_with_size() {
|
||||
let result = parse_nm_output("main T 140001000 20\n");
|
||||
|
||||
assert_eq!(result.report.symbol_count, 1);
|
||||
let symbol = symbol_at(&result, 0);
|
||||
assert_eq!(symbol.name, "main");
|
||||
assert_eq!(symbol.symbol_type, "T");
|
||||
assert_eq!(symbol.address, Some(0x0001_4000_1000));
|
||||
assert_eq!(symbol.size, Some(0x20));
|
||||
assert_eq!(symbol.file, None);
|
||||
assert_eq!(symbol.source, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_undefined_symbols_with_zero_address_and_size_as_none() {
|
||||
let result = parse_nm_output("__imp_CreateFileW U 0 0\n");
|
||||
|
||||
let symbol = symbol_at(&result, 0);
|
||||
assert_eq!(symbol.name, "__imp_CreateFileW");
|
||||
assert_eq!(symbol.symbol_type, "U");
|
||||
assert_eq!(symbol.address, None);
|
||||
assert_eq!(symbol.size, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_undefined_symbols_without_address_columns() {
|
||||
let result = parse_nm_output("demo.obj: __imp_CloseHandle U\n");
|
||||
|
||||
let symbol = symbol_at(&result, 0);
|
||||
assert_eq!(symbol.file, Some(PathBuf::from("demo.obj")));
|
||||
assert_eq!(symbol.name, "__imp_CloseHandle");
|
||||
assert_eq!(symbol.symbol_type, "U");
|
||||
assert_eq!(symbol.address, None);
|
||||
assert_eq!(symbol.size, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_print_file_name_prefix_before_symbol_fields() {
|
||||
let result = parse_nm_output("obj/foo.obj: ?helper@@YAXXZ T 140001000 30\n");
|
||||
|
||||
let symbol = symbol_at(&result, 0);
|
||||
assert_eq!(symbol.file, Some(PathBuf::from("obj/foo.obj")));
|
||||
assert_eq!(symbol.name, "?helper@@YAXXZ");
|
||||
assert_eq!(symbol.symbol_type, "T");
|
||||
assert_eq!(symbol.address, Some(0x0001_4000_1000));
|
||||
assert_eq!(symbol.size, Some(0x30));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_archive_print_file_name_prefix() {
|
||||
let result = parse_nm_output("libstuff.a[file.o]: exported D 2000 8\n");
|
||||
|
||||
let symbol = symbol_at(&result, 0);
|
||||
assert_eq!(symbol.file, Some(PathBuf::from("libstuff.a[file.o]")));
|
||||
assert_eq!(symbol.name, "exported");
|
||||
assert_eq!(symbol.address, Some(0x2000));
|
||||
assert_eq!(symbol.size, Some(8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_source_file_suffix_from_line_numbers_output() {
|
||||
let result = parse_nm_output("global_var D 4040 4\tC:/src/main.c:27\n");
|
||||
|
||||
let symbol = symbol_at(&result, 0);
|
||||
assert_eq!(symbol.name, "global_var");
|
||||
assert_eq!(symbol.source.as_deref(), Some("C:/src/main.c"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_source_file_without_numeric_line_suffix() {
|
||||
let result = parse_nm_output("global_var D 4040 4\tC:/src/generated file.c\n");
|
||||
|
||||
let symbol = symbol_at(&result, 0);
|
||||
assert_eq!(symbol.source.as_deref(), Some("C:/src/generated file.c"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_demangled_names_that_contain_spaces() {
|
||||
let result = parse_nm_output("void ns::demo(int, char const*) T 10 20\n");
|
||||
|
||||
let symbol = symbol_at(&result, 0);
|
||||
assert_eq!(symbol.name, "void ns::demo(int, char const*)");
|
||||
assert_eq!(symbol.symbol_type, "T");
|
||||
assert_eq!(symbol.address, Some(0x10));
|
||||
assert_eq!(symbol.size, Some(0x20));
|
||||
assert_eq!(symbol.file, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_lines_become_warnings_and_raw_lines() {
|
||||
let result = parse_nm_output("this is not nm output\n\nname T xyz 4\n");
|
||||
|
||||
assert!(result.report.symbols.is_empty());
|
||||
assert_eq!(
|
||||
result.raw_lines,
|
||||
vec!["this is not nm output", "", "name T xyz 4"]
|
||||
);
|
||||
assert_eq!(result.warnings.len(), 3);
|
||||
assert!(result.warnings[0].contains("malformed llvm-nm POSIX line"));
|
||||
assert!(result.warnings[1].contains("blank llvm-nm POSIX line"));
|
||||
assert!(result.warnings[2].contains("invalid address"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,631 @@
|
||||
//! Parser for high-value `llvm-objdump` text blocks.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
|
||||
pub(crate) struct ObjdumpReport {
|
||||
pub file: Option<String>,
|
||||
pub format: Option<String>,
|
||||
pub architecture: Option<String>,
|
||||
pub start_address: Option<String>,
|
||||
pub start_address_value: Option<u64>,
|
||||
pub sections: Vec<ObjdumpSection>,
|
||||
pub symbols: Vec<DisassemblySymbol>,
|
||||
pub instructions: Vec<Instruction>,
|
||||
pub address_range: Option<AddressRange>,
|
||||
pub raw_blocks: Vec<ObjdumpRawBlock>,
|
||||
pub instruction_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
|
||||
pub(crate) struct ObjdumpSection {
|
||||
pub index: usize,
|
||||
pub name: String,
|
||||
pub size: String,
|
||||
pub size_value: Option<u64>,
|
||||
pub vma: String,
|
||||
pub vma_value: Option<u64>,
|
||||
pub section_type: Option<String>,
|
||||
pub raw: String,
|
||||
pub line_number: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
|
||||
pub(crate) struct DisassemblySymbol {
|
||||
pub address: String,
|
||||
pub address_value: Option<u64>,
|
||||
pub name: String,
|
||||
pub section: Option<String>,
|
||||
pub instructions: Vec<Instruction>,
|
||||
pub raw: String,
|
||||
pub line_number: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
|
||||
pub(crate) struct Instruction {
|
||||
pub address: String,
|
||||
pub address_value: Option<u64>,
|
||||
pub bytes: Vec<String>,
|
||||
pub byte_values: Vec<u8>,
|
||||
pub text: String,
|
||||
pub section: Option<String>,
|
||||
pub symbol: Option<String>,
|
||||
pub raw: String,
|
||||
pub line_number: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub(crate) struct AddressRange {
|
||||
pub start: u64,
|
||||
pub end: u64,
|
||||
pub instruction_count: usize,
|
||||
pub byte_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub(crate) struct ObjdumpRawBlock {
|
||||
pub label: String,
|
||||
pub text: String,
|
||||
pub line_number: usize,
|
||||
pub warning: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub(crate) struct ObjdumpParseResult {
|
||||
pub report: ObjdumpReport,
|
||||
pub warnings: Vec<String>,
|
||||
pub raw_lines: Vec<String>,
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
#[cfg(test)]
|
||||
pub(crate) fn parse_objdump(text: &str) -> ObjdumpParseResult {
|
||||
parse_objdump_output(text)
|
||||
}
|
||||
|
||||
#[allow(
|
||||
clippy::too_many_lines,
|
||||
reason = "the parser keeps the single-pass state machine readable for each objdump block"
|
||||
)]
|
||||
#[must_use]
|
||||
pub(crate) fn parse_objdump_output(text: &str) -> ObjdumpParseResult {
|
||||
let mut result = ObjdumpParseResult::default();
|
||||
let mut in_sections = false;
|
||||
let mut current_section: Option<String> = None;
|
||||
let mut current_symbol: Option<DisassemblySymbol> = None;
|
||||
|
||||
for (index, line) in text.lines().enumerate() {
|
||||
let line_number = index + 1;
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some((file, format)) = parse_file_format_line(trimmed) {
|
||||
flush_symbol(&mut result.report, &mut current_symbol);
|
||||
result.report.file = Some(file);
|
||||
result.report.format = Some(format);
|
||||
in_sections = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(value) = parse_prefixed_value(trimmed, "architecture:") {
|
||||
result.report.architecture = Some(value.trim_end_matches(',').to_owned());
|
||||
in_sections = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(value) = parse_prefixed_value(trimmed, "start address:") {
|
||||
result.report.start_address = Some(value.to_owned());
|
||||
match parse_hex_u64(value) {
|
||||
Ok(address) => result.report.start_address_value = Some(address),
|
||||
Err(()) => warn(
|
||||
&mut result,
|
||||
"prelude",
|
||||
line,
|
||||
line_number,
|
||||
format!("line {line_number}: invalid start address '{value}'"),
|
||||
),
|
||||
}
|
||||
in_sections = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if trimmed == "Sections:" {
|
||||
flush_symbol(&mut result.report, &mut current_symbol);
|
||||
in_sections = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if is_section_table_header(trimmed) {
|
||||
flush_symbol(&mut result.report, &mut current_symbol);
|
||||
in_sections = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(section) = parse_disassembly_section(trimmed) {
|
||||
flush_symbol(&mut result.report, &mut current_symbol);
|
||||
current_section = Some(section);
|
||||
in_sections = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if in_sections {
|
||||
if let Some(section) = parse_section_row(line, line_number, &mut result.warnings) {
|
||||
result.report.sections.push(section);
|
||||
continue;
|
||||
}
|
||||
in_sections = false;
|
||||
}
|
||||
|
||||
if let Some(symbol) =
|
||||
parse_symbol_label(trimmed, line, line_number, current_section.clone())
|
||||
{
|
||||
flush_symbol(&mut result.report, &mut current_symbol);
|
||||
current_symbol = Some(symbol);
|
||||
continue;
|
||||
}
|
||||
|
||||
if looks_like_address_row(trimmed) {
|
||||
match parse_instruction(
|
||||
trimmed,
|
||||
line,
|
||||
line_number,
|
||||
current_section.clone(),
|
||||
current_symbol.as_ref().map(|symbol| symbol.name.clone()),
|
||||
) {
|
||||
Some(instruction) if !instruction.bytes.is_empty() => {
|
||||
result.report.instruction_count += 1;
|
||||
update_address_range(&mut result.report, &instruction);
|
||||
if let Some(symbol) = &mut current_symbol {
|
||||
symbol.instructions.push(instruction.clone());
|
||||
}
|
||||
result.report.instructions.push(instruction);
|
||||
}
|
||||
_ => warn(
|
||||
&mut result,
|
||||
"instruction",
|
||||
line,
|
||||
line_number,
|
||||
format!("line {line_number}: invalid instruction bytes in llvm-objdump row"),
|
||||
),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if is_source_or_line_hint(trimmed) {
|
||||
warn(
|
||||
&mut result,
|
||||
"source",
|
||||
line,
|
||||
line_number,
|
||||
format!("line {line_number}: preserved source/line block"),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
warn(
|
||||
&mut result,
|
||||
"unparsed",
|
||||
line,
|
||||
line_number,
|
||||
format!("line {line_number}: unparsed llvm-objdump line"),
|
||||
);
|
||||
}
|
||||
|
||||
flush_symbol(&mut result.report, &mut current_symbol);
|
||||
result
|
||||
}
|
||||
|
||||
fn flush_symbol(report: &mut ObjdumpReport, current_symbol: &mut Option<DisassemblySymbol>) {
|
||||
if let Some(symbol) = current_symbol.take() {
|
||||
report.symbols.push(symbol);
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_file_format_line(trimmed: &str) -> Option<(String, String)> {
|
||||
let (file, rest) = trimmed.split_once(':')?;
|
||||
let format = rest.trim().strip_prefix("file format")?.trim();
|
||||
if file.trim().is_empty() || format.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some((file.trim().to_owned(), format.to_owned()))
|
||||
}
|
||||
|
||||
fn parse_prefixed_value<'a>(trimmed: &'a str, prefix: &str) -> Option<&'a str> {
|
||||
trimmed
|
||||
.strip_prefix(prefix)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn is_section_table_header(trimmed: &str) -> bool {
|
||||
trimmed.starts_with("Idx ") && trimmed.contains("Name") && trimmed.contains("Size")
|
||||
}
|
||||
|
||||
fn parse_disassembly_section(trimmed: &str) -> Option<String> {
|
||||
trimmed
|
||||
.strip_prefix("Disassembly of section ")?
|
||||
.strip_suffix(':')
|
||||
.map(str::trim)
|
||||
.filter(|section| !section.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn parse_section_row(
|
||||
line: &str,
|
||||
line_number: usize,
|
||||
warnings: &mut Vec<String>,
|
||||
) -> Option<ObjdumpSection> {
|
||||
let parts = line.split_whitespace().collect::<Vec<_>>();
|
||||
if parts.len() < 4 || !parts[0].chars().all(|ch| ch.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let index = match parts[0].parse() {
|
||||
Ok(index) => index,
|
||||
Err(error) => {
|
||||
warnings.push(format!(
|
||||
"line {line_number}: invalid section index '{}': {error}",
|
||||
parts[0]
|
||||
));
|
||||
0
|
||||
}
|
||||
};
|
||||
let size_value = parse_hex_u64(parts[2]).map_or_else(
|
||||
|()| {
|
||||
warnings.push(format!(
|
||||
"line {line_number}: invalid section size '{}'",
|
||||
parts[2]
|
||||
));
|
||||
None
|
||||
},
|
||||
Some,
|
||||
);
|
||||
let vma_value = parse_hex_u64(parts[3]).map_or_else(
|
||||
|()| {
|
||||
warnings.push(format!(
|
||||
"line {line_number}: invalid section VMA '{}'",
|
||||
parts[3]
|
||||
));
|
||||
None
|
||||
},
|
||||
Some,
|
||||
);
|
||||
|
||||
Some(ObjdumpSection {
|
||||
index,
|
||||
name: parts[1].to_owned(),
|
||||
size: parts[2].to_owned(),
|
||||
size_value,
|
||||
vma: parts[3].to_owned(),
|
||||
vma_value,
|
||||
section_type: (parts.len() > 4).then(|| parts[4..].join(" ")),
|
||||
raw: line.to_owned(),
|
||||
line_number,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_symbol_label(
|
||||
trimmed: &str,
|
||||
raw: &str,
|
||||
line_number: usize,
|
||||
section: Option<String>,
|
||||
) -> Option<DisassemblySymbol> {
|
||||
if !(trimmed.ends_with(">:") && trimmed.contains('<')) {
|
||||
return None;
|
||||
}
|
||||
let (address, rest) = trimmed.split_once('<')?;
|
||||
let address = address.trim();
|
||||
let name = rest.trim_end_matches(">:").trim();
|
||||
if name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(DisassemblySymbol {
|
||||
address: address.to_owned(),
|
||||
address_value: parse_hex_u64(address).ok(),
|
||||
name: name.to_owned(),
|
||||
section,
|
||||
instructions: Vec::new(),
|
||||
raw: raw.to_owned(),
|
||||
line_number,
|
||||
})
|
||||
}
|
||||
|
||||
fn looks_like_address_row(trimmed: &str) -> bool {
|
||||
let Some((address, _)) = trimmed.split_once(':') else {
|
||||
return false;
|
||||
};
|
||||
!address.is_empty() && address.chars().all(|ch| ch.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
fn parse_instruction(
|
||||
trimmed: &str,
|
||||
raw: &str,
|
||||
line_number: usize,
|
||||
section: Option<String>,
|
||||
symbol: Option<String>,
|
||||
) -> Option<Instruction> {
|
||||
let (address, rest) = trimmed.split_once(':')?;
|
||||
if !address.chars().all(|ch| ch.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
let (bytes, byte_values, text) = parse_instruction_body(rest);
|
||||
Some(Instruction {
|
||||
address: address.to_owned(),
|
||||
address_value: parse_hex_u64(address).ok(),
|
||||
bytes,
|
||||
byte_values,
|
||||
text,
|
||||
section,
|
||||
symbol,
|
||||
raw: raw.to_owned(),
|
||||
line_number,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_instruction_body(rest: &str) -> (Vec<String>, Vec<u8>, String) {
|
||||
let mut bytes = Vec::new();
|
||||
let mut byte_values = Vec::new();
|
||||
let mut cursor = 0;
|
||||
|
||||
while cursor < rest.len() {
|
||||
cursor += rest[cursor..]
|
||||
.bytes()
|
||||
.take_while(u8::is_ascii_whitespace)
|
||||
.count();
|
||||
if cursor >= rest.len() {
|
||||
break;
|
||||
}
|
||||
|
||||
let token_start = cursor;
|
||||
cursor += rest[cursor..]
|
||||
.bytes()
|
||||
.take_while(|byte| !byte.is_ascii_whitespace())
|
||||
.count();
|
||||
let token = &rest[token_start..cursor];
|
||||
if token.len() != 2 || !token.chars().all(|ch| ch.is_ascii_hexdigit()) {
|
||||
cursor = token_start;
|
||||
break;
|
||||
}
|
||||
|
||||
bytes.push(token.to_owned());
|
||||
if let Ok(value) = u8::from_str_radix(token, 16) {
|
||||
byte_values.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
(bytes, byte_values, rest[cursor..].trim().to_owned())
|
||||
}
|
||||
|
||||
fn update_address_range(report: &mut ObjdumpReport, instruction: &Instruction) {
|
||||
let Some(address) = instruction.address_value else {
|
||||
return;
|
||||
};
|
||||
let row_end = address.saturating_add(instruction.byte_values.len() as u64);
|
||||
match report.address_range.as_mut() {
|
||||
Some(range) => {
|
||||
range.start = range.start.min(address);
|
||||
range.end = range.end.max(row_end);
|
||||
range.instruction_count += 1;
|
||||
range.byte_count += instruction.byte_values.len();
|
||||
}
|
||||
None => {
|
||||
report.address_range = Some(AddressRange {
|
||||
start: address,
|
||||
end: row_end,
|
||||
instruction_count: 1,
|
||||
byte_count: instruction.byte_values.len(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_source_or_line_hint(trimmed: &str) -> bool {
|
||||
trimmed.starts_with(';')
|
||||
|| trimmed.starts_with("//")
|
||||
|| trimmed.starts_with('#')
|
||||
|| (looks_like_source_path(trimmed)
|
||||
&& trimmed
|
||||
.rsplit_once(':')
|
||||
.is_some_and(|(_, line)| line.chars().all(|ch| ch.is_ascii_digit())))
|
||||
}
|
||||
|
||||
fn looks_like_source_path(trimmed: &str) -> bool {
|
||||
trimmed.contains('/')
|
||||
|| trimmed.contains('\\')
|
||||
|| std::path::Path::new(trimmed)
|
||||
.extension()
|
||||
.is_some_and(|ext| {
|
||||
["c", "cc", "cpp", "h", "hpp", "rs"]
|
||||
.iter()
|
||||
.any(|expected| ext.eq_ignore_ascii_case(expected))
|
||||
})
|
||||
}
|
||||
|
||||
fn warn(
|
||||
result: &mut ObjdumpParseResult,
|
||||
label: &str,
|
||||
line: &str,
|
||||
line_number: usize,
|
||||
warning: String,
|
||||
) {
|
||||
result.warnings.push(warning.clone());
|
||||
result.raw_lines.push(line.to_owned());
|
||||
result.report.raw_blocks.push(ObjdumpRawBlock {
|
||||
label: label.to_owned(),
|
||||
text: line.to_owned(),
|
||||
line_number,
|
||||
warning,
|
||||
});
|
||||
}
|
||||
|
||||
fn parse_hex_u64(text: &str) -> Result<u64, ()> {
|
||||
let digits = text
|
||||
.strip_prefix("0x")
|
||||
.or_else(|| text.strip_prefix("0X"))
|
||||
.unwrap_or(text);
|
||||
if digits.is_empty() {
|
||||
return Err(());
|
||||
}
|
||||
u64::from_str_radix(digits, 16).map_err(|_| ())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_objdump() -> &'static str {
|
||||
r"
|
||||
sample.obj: file format coff-x86-64
|
||||
architecture: x86_64
|
||||
start address: 0x0000000140001000
|
||||
|
||||
Sections:
|
||||
Idx Name Size VMA Type
|
||||
0 .text 00000007 0000000140001000 TEXT
|
||||
1 .rdata 00000010 0000000140002000 DATA
|
||||
|
||||
Disassembly of section .text:
|
||||
|
||||
0000000140001000 <main>:
|
||||
140001000: 55 pushq %rbp
|
||||
140001001: 48 89 e5 movq %rsp, %rbp
|
||||
; C:\src\main.cpp:42
|
||||
140001004: e8 00 00 00 00 callq 0x140001009 <helper>
|
||||
|
||||
0000000140001009 <helper>:
|
||||
140001009: c3 retq
|
||||
"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_headers_sections_symbols_and_instructions() {
|
||||
let result = parse_objdump_output(sample_objdump());
|
||||
|
||||
assert_eq!(result.report.file.as_deref(), Some("sample.obj"));
|
||||
assert_eq!(result.report.format.as_deref(), Some("coff-x86-64"));
|
||||
assert_eq!(result.report.architecture.as_deref(), Some("x86_64"));
|
||||
assert_eq!(
|
||||
result.report.start_address.as_deref(),
|
||||
Some("0x0000000140001000")
|
||||
);
|
||||
assert_eq!(result.report.start_address_value, Some(0x0001_4000_1000));
|
||||
|
||||
assert_eq!(result.report.sections.len(), 2);
|
||||
assert_eq!(result.report.sections[0].index, 0);
|
||||
assert_eq!(result.report.sections[0].name, ".text");
|
||||
assert_eq!(result.report.sections[0].size, "00000007");
|
||||
assert_eq!(result.report.sections[0].size_value, Some(7));
|
||||
assert_eq!(result.report.sections[0].vma_value, Some(0x0001_4000_1000));
|
||||
assert_eq!(
|
||||
result.report.sections[0].section_type.as_deref(),
|
||||
Some("TEXT")
|
||||
);
|
||||
|
||||
assert_eq!(result.report.symbols.len(), 2);
|
||||
assert_eq!(result.report.symbols[0].name, "main");
|
||||
assert_eq!(
|
||||
result.report.symbols[0].address_value,
|
||||
Some(0x0001_4000_1000)
|
||||
);
|
||||
assert_eq!(result.report.symbols[0].section.as_deref(), Some(".text"));
|
||||
assert_eq!(result.report.symbols[0].instructions.len(), 3);
|
||||
assert_eq!(result.report.symbols[1].name, "helper");
|
||||
assert_eq!(result.report.symbols[1].instructions.len(), 1);
|
||||
|
||||
assert_eq!(result.report.instruction_count, 4);
|
||||
assert_eq!(result.report.instructions[0].address, "140001000");
|
||||
assert_eq!(result.report.instructions[0].byte_values, vec![0x55]);
|
||||
assert_eq!(result.report.instructions[0].text, "pushq %rbp");
|
||||
assert_eq!(
|
||||
result.report.instructions[0].symbol.as_deref(),
|
||||
Some("main")
|
||||
);
|
||||
assert_eq!(
|
||||
result.report.instructions[2].byte_values,
|
||||
vec![0xe8, 0x00, 0x00, 0x00, 0x00]
|
||||
);
|
||||
assert_eq!(
|
||||
result.report.instructions[2].text,
|
||||
"callq 0x140001009 <helper>"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarizes_instruction_address_range_and_preserves_source_lines() {
|
||||
let result = parse_objdump(sample_objdump());
|
||||
|
||||
assert_eq!(
|
||||
result.report.address_range,
|
||||
Some(AddressRange {
|
||||
start: 0x0001_4000_1000,
|
||||
end: 0x0001_4000_100a,
|
||||
instruction_count: 4,
|
||||
byte_count: 10,
|
||||
})
|
||||
);
|
||||
assert_eq!(result.report.raw_blocks.len(), 1);
|
||||
assert_eq!(result.report.raw_blocks[0].label, "source");
|
||||
assert_eq!(result.report.raw_blocks[0].text, r"; C:\src\main.cpp:42");
|
||||
assert_eq!(result.report.raw_blocks[0].line_number, 16);
|
||||
assert!(
|
||||
result
|
||||
.warnings
|
||||
.iter()
|
||||
.any(|warning| warning.contains("preserved source/line block"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_malformed_rows_with_line_warnings() {
|
||||
let input = r"
|
||||
bad.exe: file format coff-i386
|
||||
architecture: i386
|
||||
start address: not_hex
|
||||
Sections:
|
||||
Idx Name Size VMA Type
|
||||
0 .text badsize 00401000 TEXT
|
||||
random trailer that is not recognized
|
||||
00401000: zz db 0
|
||||
";
|
||||
|
||||
let result = parse_objdump_output(input);
|
||||
|
||||
assert_eq!(result.report.file.as_deref(), Some("bad.exe"));
|
||||
assert_eq!(result.report.format.as_deref(), Some("coff-i386"));
|
||||
assert_eq!(result.report.architecture.as_deref(), Some("i386"));
|
||||
assert_eq!(result.report.start_address.as_deref(), Some("not_hex"));
|
||||
assert_eq!(result.report.start_address_value, None);
|
||||
assert_eq!(result.report.sections.len(), 1);
|
||||
assert_eq!(result.report.sections[0].name, ".text");
|
||||
assert_eq!(result.report.sections[0].size_value, None);
|
||||
assert_eq!(result.report.sections[0].vma_value, Some(0x0040_1000));
|
||||
assert!(result.report.instructions.is_empty());
|
||||
assert!(
|
||||
result
|
||||
.warnings
|
||||
.iter()
|
||||
.any(|warning| warning.contains("invalid start address"))
|
||||
);
|
||||
assert!(
|
||||
result
|
||||
.warnings
|
||||
.iter()
|
||||
.any(|warning| warning.contains("invalid section size"))
|
||||
);
|
||||
assert!(
|
||||
result
|
||||
.warnings
|
||||
.iter()
|
||||
.any(|warning| warning.contains("invalid instruction bytes"))
|
||||
);
|
||||
assert!(
|
||||
result
|
||||
.raw_lines
|
||||
.iter()
|
||||
.any(|line| line == "random trailer that is not recognized")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,874 @@
|
||||
//! Parser for high-value `llvm-readobj` text blocks.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
/// Structured data extracted from `llvm-readobj` output.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
|
||||
pub(crate) struct ReadobjReport {
|
||||
/// Value from the top-level `File:` line.
|
||||
pub file: Option<String>,
|
||||
/// Value from the top-level `Format:` line.
|
||||
pub format: Option<String>,
|
||||
/// Value from the top-level `Arch:` line.
|
||||
pub arch: Option<String>,
|
||||
/// Value from the top-level `AddressSize:` line.
|
||||
pub address_size: Option<String>,
|
||||
/// Convenience copy of `ImageFileHeader.Machine`.
|
||||
pub machine: Option<String>,
|
||||
/// Convenience copy of `ImageFileHeader.SectionCount`.
|
||||
pub section_count: Option<usize>,
|
||||
/// High-value COFF image header fields.
|
||||
pub image_file_header: Option<ImageFileHeader>,
|
||||
/// Section summaries from the `Sections [` block.
|
||||
pub sections: Vec<ReadobjSection>,
|
||||
/// Import libraries and symbols.
|
||||
pub imports: Vec<ImportLibrary>,
|
||||
/// Export records parsed from export blocks.
|
||||
pub exports: Vec<ExportSymbol>,
|
||||
/// Convenience count for CLI summaries.
|
||||
pub export_count: usize,
|
||||
/// Flat debug hints retained for compact text/JSON consumers.
|
||||
pub debug_hints: Vec<String>,
|
||||
/// Structured debug directory and `CodeView` hints.
|
||||
pub debug: Vec<DebugHint>,
|
||||
/// Raw top-level blocks not yet mapped to a structured model.
|
||||
pub raw_blocks: Vec<ReadobjRawBlock>,
|
||||
}
|
||||
|
||||
/// High-value fields from an `ImageFileHeader` block.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
|
||||
pub(crate) struct ImageFileHeader {
|
||||
/// `Machine` value.
|
||||
pub machine: Option<String>,
|
||||
/// `SectionCount` value.
|
||||
pub section_count: Option<usize>,
|
||||
/// `TimeDateStamp` value.
|
||||
pub time_date_stamp: Option<String>,
|
||||
/// `PointerToSymbolTable` value.
|
||||
pub pointer_to_symbol_table: Option<String>,
|
||||
/// `SymbolCount` value.
|
||||
pub symbol_count: Option<u64>,
|
||||
/// `StringTableSize` value.
|
||||
pub string_table_size: Option<u64>,
|
||||
/// `OptionalHeaderSize` value.
|
||||
pub optional_header_size: Option<u64>,
|
||||
/// COFF characteristics listed by `llvm-readobj`.
|
||||
pub characteristics: Vec<String>,
|
||||
}
|
||||
|
||||
/// Summary for one COFF section.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
|
||||
pub(crate) struct ReadobjSection {
|
||||
/// Section number.
|
||||
pub number: usize,
|
||||
/// Section name.
|
||||
pub name: String,
|
||||
/// Section size, preferring `Size` then common COFF size aliases.
|
||||
pub size: Option<u64>,
|
||||
/// Section address, preferring `Address` then common RVA aliases.
|
||||
pub address: Option<u64>,
|
||||
/// Backward-compatible copy of `VirtualSize`.
|
||||
pub virtual_size: Option<u64>,
|
||||
/// Backward-compatible copy of `VirtualAddress`.
|
||||
pub virtual_address: Option<u64>,
|
||||
/// Backward-compatible copy of `RawDataSize`.
|
||||
pub raw_data_size: Option<u64>,
|
||||
}
|
||||
|
||||
/// Imported library and imported symbols.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
|
||||
pub(crate) struct ImportLibrary {
|
||||
/// Library name, kept for the initial JSON contract.
|
||||
pub name: String,
|
||||
/// Library name using the explicit readobj parser vocabulary.
|
||||
pub library: String,
|
||||
/// Imported symbol names or ordinal hints.
|
||||
pub symbols: Vec<String>,
|
||||
}
|
||||
|
||||
/// Export symbol or export table hint.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
|
||||
pub(crate) struct ExportSymbol {
|
||||
/// Exported symbol name.
|
||||
pub name: Option<String>,
|
||||
/// Export ordinal.
|
||||
pub ordinal: Option<u64>,
|
||||
/// Export RVA/address hint.
|
||||
pub rva: Option<u64>,
|
||||
/// Additional export fields preserved as text.
|
||||
pub hints: Vec<String>,
|
||||
}
|
||||
|
||||
/// Debug directory or `CodeView` hint.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
|
||||
pub(crate) struct DebugHint {
|
||||
/// Debug record kind, for example `CodeView`.
|
||||
pub kind: Option<String>,
|
||||
/// PDB path or file name when present.
|
||||
pub pdb: Option<String>,
|
||||
/// GUID/signature value when present.
|
||||
pub guid: Option<String>,
|
||||
/// Age value when present.
|
||||
pub age: Option<u64>,
|
||||
/// Other debug fields preserved as text.
|
||||
pub hints: Vec<String>,
|
||||
}
|
||||
|
||||
/// Raw fallback block retained for future parser coverage.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub(crate) struct ReadobjRawBlock {
|
||||
/// Block or line label.
|
||||
pub label: String,
|
||||
/// Raw text content.
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// Result of parsing `llvm-readobj` text.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct ReadobjParseResult {
|
||||
/// Structured report.
|
||||
pub report: ReadobjReport,
|
||||
/// Non-fatal parser warnings.
|
||||
pub warnings: Vec<String>,
|
||||
/// Raw lines or blocks preserved for callers that expose fallback text separately.
|
||||
pub raw_lines: Vec<String>,
|
||||
}
|
||||
|
||||
/// Parses `llvm-readobj` text output.
|
||||
#[must_use]
|
||||
pub(crate) fn parse_readobj_output(text: &str) -> ReadobjParseResult {
|
||||
let lines = text.lines().map(str::to_string).collect::<Vec<_>>();
|
||||
let mut report = ReadobjReport::default();
|
||||
let mut warnings = Vec::new();
|
||||
let mut index = 0;
|
||||
|
||||
while index < lines.len() {
|
||||
let trimmed = lines[index].trim();
|
||||
if trimmed.is_empty() {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some((key, value)) = split_key_value(trimmed) {
|
||||
match key {
|
||||
"File" => report.file = Some(value.to_owned()),
|
||||
"Format" => report.format = Some(value.to_owned()),
|
||||
"Arch" => report.arch = Some(value.to_owned()),
|
||||
"AddressSize" => report.address_size = Some(value.to_owned()),
|
||||
_ => push_raw_line(&mut report, &mut warnings, key, trimmed),
|
||||
}
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(label) = block_label(trimmed) {
|
||||
let (block, next_index) = collect_block(&lines, index, &mut warnings);
|
||||
match label {
|
||||
"ImageFileHeader" => {
|
||||
let header = parse_image_file_header(&block.inner, &mut warnings);
|
||||
report.machine.clone_from(&header.machine);
|
||||
report.section_count = header.section_count;
|
||||
report.image_file_header = Some(header);
|
||||
}
|
||||
"Sections" => parse_sections(&block.inner, &mut report.sections, &mut warnings),
|
||||
"ImportTable" | "Imports" | "DelayImportTable" | "DelayImports" => {
|
||||
parse_imports(&block.inner, &mut report.imports, &mut warnings);
|
||||
}
|
||||
"ExportTable" | "Exports" => {
|
||||
parse_exports(&block.inner, &mut report.exports, &mut warnings);
|
||||
}
|
||||
"DebugDirectory" | "DebugInfo" | "CodeView" | "CodeViewDebugInfo" => {
|
||||
parse_debug(&block.inner, &mut report.debug, &mut warnings);
|
||||
}
|
||||
_ => push_raw_block(&mut report, &mut warnings, label, block.text),
|
||||
}
|
||||
index = next_index;
|
||||
continue;
|
||||
}
|
||||
|
||||
push_raw_line(&mut report, &mut warnings, &line_label(trimmed), trimmed);
|
||||
index += 1;
|
||||
}
|
||||
|
||||
report.export_count = report.exports.len();
|
||||
report.debug_hints = flatten_debug_hints(&report.debug);
|
||||
let raw_lines = report
|
||||
.raw_blocks
|
||||
.iter()
|
||||
.map(|block| block.text.clone())
|
||||
.collect();
|
||||
|
||||
ReadobjParseResult {
|
||||
report,
|
||||
warnings,
|
||||
raw_lines,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct TextBlock {
|
||||
text: String,
|
||||
inner: Vec<String>,
|
||||
}
|
||||
|
||||
fn split_key_value(line: &str) -> Option<(&str, &str)> {
|
||||
let (key, value) = line.split_once(':')?;
|
||||
let key = key.trim();
|
||||
if key.is_empty() || key.contains(['{', '[', '}', ']']) {
|
||||
return None;
|
||||
}
|
||||
Some((key, value.trim().trim_matches('"')))
|
||||
}
|
||||
|
||||
fn block_label(line: &str) -> Option<&str> {
|
||||
let delimiter = line.find(['{', '['])?;
|
||||
let label = line[..delimiter].trim();
|
||||
if label.is_empty() || line[..delimiter].contains(':') {
|
||||
return None;
|
||||
}
|
||||
Some(label)
|
||||
}
|
||||
|
||||
fn line_label(line: &str) -> String {
|
||||
split_key_value(line)
|
||||
.map_or(line, |(key, _value)| key)
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
fn collect_block(lines: &[String], start: usize, warnings: &mut Vec<String>) -> (TextBlock, usize) {
|
||||
let mut balance = 0i64;
|
||||
let mut end = start;
|
||||
|
||||
for (offset, line) in lines[start..].iter().enumerate() {
|
||||
balance += delimiter_delta(line);
|
||||
end = start + offset;
|
||||
if balance <= 0 && offset > 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if balance > 0 {
|
||||
warnings.push(format!(
|
||||
"unterminated block `{}`",
|
||||
block_label(lines[start].trim()).unwrap_or("unknown")
|
||||
));
|
||||
}
|
||||
|
||||
let text = lines[start..=end].join("\n");
|
||||
let inner = if end > start {
|
||||
lines[(start + 1)..end].to_vec()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
(TextBlock { text, inner }, end + 1)
|
||||
}
|
||||
|
||||
fn delimiter_delta(line: &str) -> i64 {
|
||||
line.chars().fold(0, |delta, character| match character {
|
||||
'{' | '[' => delta + 1,
|
||||
'}' | ']' => delta - 1,
|
||||
_ => delta,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_image_file_header(lines: &[String], warnings: &mut Vec<String>) -> ImageFileHeader {
|
||||
let mut header = ImageFileHeader::default();
|
||||
let mut in_characteristics = false;
|
||||
|
||||
for line in lines {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if in_characteristics {
|
||||
if trimmed.starts_with(']') {
|
||||
in_characteristics = false;
|
||||
} else {
|
||||
header.characteristics.push(trimmed.to_owned());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if trimmed.starts_with("Characteristics") && trimmed.contains('[') {
|
||||
in_characteristics = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some((key, value)) = split_key_value(trimmed) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
match key {
|
||||
"Machine" => header.machine = Some(value.to_owned()),
|
||||
"SectionCount" => header.section_count = parse_usize_field(key, value, warnings),
|
||||
"TimeDateStamp" => header.time_date_stamp = Some(value.to_owned()),
|
||||
"PointerToSymbolTable" => header.pointer_to_symbol_table = Some(value.to_owned()),
|
||||
"SymbolCount" => header.symbol_count = parse_u64_field(key, value, warnings),
|
||||
"StringTableSize" => header.string_table_size = parse_u64_field(key, value, warnings),
|
||||
"OptionalHeaderSize" => {
|
||||
header.optional_header_size = parse_u64_field(key, value, warnings);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
header
|
||||
}
|
||||
|
||||
fn parse_sections(
|
||||
lines: &[String],
|
||||
sections: &mut Vec<ReadobjSection>,
|
||||
warnings: &mut Vec<String>,
|
||||
) {
|
||||
for block in collect_named_inner_blocks(lines, "Section") {
|
||||
let fallback_number = sections.len() + 1;
|
||||
let mut section = ReadobjSection {
|
||||
number: fallback_number,
|
||||
..ReadobjSection::default()
|
||||
};
|
||||
|
||||
for line in block {
|
||||
let trimmed = line.trim();
|
||||
let Some((key, value)) = split_key_value(trimmed) else {
|
||||
continue;
|
||||
};
|
||||
match key {
|
||||
"Name" => value.clone_into(&mut section.name),
|
||||
"Number" => {
|
||||
if let Some(number) = parse_usize_field(key, value, warnings) {
|
||||
section.number = number;
|
||||
}
|
||||
}
|
||||
"Size" => section.size = parse_u64_field(key, value, warnings),
|
||||
"VirtualSize" => {
|
||||
section.virtual_size = parse_u64_field(key, value, warnings);
|
||||
if section.size.is_none() {
|
||||
section.size = section.virtual_size;
|
||||
}
|
||||
}
|
||||
"RawDataSize" | "RawSize" => {
|
||||
section.raw_data_size = parse_u64_field(key, value, warnings);
|
||||
if section.size.is_none() {
|
||||
section.size = section.raw_data_size;
|
||||
}
|
||||
}
|
||||
"Address" | "RVA" => section.address = parse_u64_field(key, value, warnings),
|
||||
"VirtualAddress" => {
|
||||
section.virtual_address = parse_u64_field(key, value, warnings);
|
||||
if section.address.is_none() {
|
||||
section.address = section.virtual_address;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
sections.push(section);
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_imports(lines: &[String], imports: &mut Vec<ImportLibrary>, warnings: &mut Vec<String>) {
|
||||
let mut current_library = None::<String>;
|
||||
let mut index = 0;
|
||||
|
||||
while index < lines.len() {
|
||||
let trimmed = lines[index].trim();
|
||||
if block_label(trimmed) == Some("Import") {
|
||||
let (block, next_index) = collect_inner_block(lines, index);
|
||||
let mut block_library = None::<String>;
|
||||
let mut symbol = None::<String>;
|
||||
|
||||
for line in block {
|
||||
let trimmed = line.trim();
|
||||
let Some((key, value)) = split_key_value(trimmed) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
match key {
|
||||
"DLLName" | "Library" | "ImportName" | "Module" => {
|
||||
block_library = Some(value.to_owned());
|
||||
}
|
||||
"Name" | "Symbol" | "Function" | "HintName" => symbol = Some(value.to_owned()),
|
||||
"Ordinal" => symbol = Some(format!("Ordinal: {value}")),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(library) = block_library {
|
||||
current_library = Some(library.clone());
|
||||
ensure_import_library(imports, &library);
|
||||
}
|
||||
|
||||
if let Some(symbol) = symbol {
|
||||
add_import_symbol(imports, current_library.as_deref(), &symbol, warnings);
|
||||
}
|
||||
|
||||
index = next_index;
|
||||
continue;
|
||||
}
|
||||
|
||||
if block_label(trimmed).is_some() || trimmed.starts_with('}') || trimmed.starts_with(']') {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some((key, value)) = split_key_value(trimmed) else {
|
||||
index += 1;
|
||||
continue;
|
||||
};
|
||||
|
||||
match key {
|
||||
"Name" | "DLLName" | "Library" | "DLL"
|
||||
if current_library.is_none() || is_library_name(value) =>
|
||||
{
|
||||
current_library = Some(value.to_owned());
|
||||
ensure_import_library(imports, value);
|
||||
}
|
||||
"Symbol" | "Function" | "HintName" => {
|
||||
add_import_symbol(imports, current_library.as_deref(), value, warnings);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_exports(lines: &[String], exports: &mut Vec<ExportSymbol>, warnings: &mut Vec<String>) {
|
||||
for block in collect_named_inner_blocks(lines, "Export") {
|
||||
let mut export = ExportSymbol::default();
|
||||
for line in block {
|
||||
let trimmed = line.trim();
|
||||
let Some((key, value)) = split_key_value(trimmed) else {
|
||||
continue;
|
||||
};
|
||||
match key {
|
||||
"Name" | "ExportName" => export.name = Some(value.to_owned()),
|
||||
"Ordinal" => export.ordinal = parse_u64_field(key, value, warnings),
|
||||
"RVA" | "Address" | "Value" => {
|
||||
export.rva = parse_u64_field(key, value, warnings);
|
||||
}
|
||||
_ => export.hints.push(format!("{key}: {value}")),
|
||||
}
|
||||
}
|
||||
exports.push(export);
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_debug(lines: &[String], debug: &mut Vec<DebugHint>, warnings: &mut Vec<String>) {
|
||||
let mut blocks = collect_named_inner_blocks(lines, "DebugEntry");
|
||||
blocks.extend(collect_named_inner_blocks(lines, "CodeView"));
|
||||
|
||||
if blocks.is_empty()
|
||||
&& lines
|
||||
.iter()
|
||||
.any(|line| split_key_value(line.trim()).is_some())
|
||||
{
|
||||
blocks.push(lines.iter().map(String::as_str).collect());
|
||||
}
|
||||
|
||||
for block in blocks {
|
||||
let mut hint = DebugHint::default();
|
||||
for line in block {
|
||||
let trimmed = line.trim();
|
||||
let Some((key, value)) = split_key_value(trimmed) else {
|
||||
continue;
|
||||
};
|
||||
match key {
|
||||
"Type" | "Kind" => hint.kind = Some(value.to_owned()),
|
||||
"PDBFileName" | "PdbFileName" | "PDB" | "PDBPath" => {
|
||||
hint.pdb = Some(value.to_owned());
|
||||
}
|
||||
"FileName" if value.to_ascii_lowercase().ends_with(".pdb") => {
|
||||
hint.pdb = Some(value.to_owned());
|
||||
}
|
||||
"GUID" | "Guid" | "Signature" => hint.guid = Some(value.to_owned()),
|
||||
"Age" => hint.age = parse_u64_field(key, value, warnings),
|
||||
_ => hint.hints.push(format!("{key}: {value}")),
|
||||
}
|
||||
}
|
||||
debug.push(hint);
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_named_inner_blocks<'a>(lines: &'a [String], wanted_label: &str) -> Vec<Vec<&'a str>> {
|
||||
let mut blocks = Vec::new();
|
||||
let mut index = 0;
|
||||
|
||||
while index < lines.len() {
|
||||
let trimmed = lines[index].trim();
|
||||
if block_label(trimmed) == Some(wanted_label) {
|
||||
let (inner, next_index) = collect_inner_block(lines, index);
|
||||
blocks.push(inner);
|
||||
index = next_index;
|
||||
} else {
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
blocks
|
||||
}
|
||||
|
||||
fn collect_inner_block(lines: &[String], start: usize) -> (Vec<&str>, usize) {
|
||||
let mut balance = 0i64;
|
||||
let mut end = start;
|
||||
|
||||
for (offset, line) in lines[start..].iter().enumerate() {
|
||||
balance += delimiter_delta(line);
|
||||
end = start + offset;
|
||||
if balance <= 0 && offset > 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let inner = if end > start {
|
||||
lines[(start + 1)..end].iter().map(String::as_str).collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
(inner, end + 1)
|
||||
}
|
||||
|
||||
fn ensure_import_library(imports: &mut Vec<ImportLibrary>, library: &str) {
|
||||
if imports
|
||||
.iter()
|
||||
.any(|entry| entry.name.eq_ignore_ascii_case(library))
|
||||
{
|
||||
return;
|
||||
}
|
||||
imports.push(ImportLibrary {
|
||||
name: library.to_owned(),
|
||||
library: library.to_owned(),
|
||||
symbols: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
fn add_import_symbol(
|
||||
imports: &mut Vec<ImportLibrary>,
|
||||
library: Option<&str>,
|
||||
symbol: &str,
|
||||
warnings: &mut Vec<String>,
|
||||
) {
|
||||
let Some(library) = library else {
|
||||
warnings.push(format!("import symbol `{symbol}` has no library"));
|
||||
return;
|
||||
};
|
||||
|
||||
ensure_import_library(imports, library);
|
||||
if let Some(entry) = imports
|
||||
.iter_mut()
|
||||
.find(|entry| entry.name.eq_ignore_ascii_case(library))
|
||||
{
|
||||
entry.symbols.push(symbol.to_owned());
|
||||
}
|
||||
}
|
||||
|
||||
fn is_library_name(value: &str) -> bool {
|
||||
let token = value.split_whitespace().next().unwrap_or(value);
|
||||
std::path::Path::new(token)
|
||||
.extension()
|
||||
.is_some_and(|extension| {
|
||||
extension.eq_ignore_ascii_case("dll")
|
||||
|| extension.eq_ignore_ascii_case("exe")
|
||||
|| extension.eq_ignore_ascii_case("sys")
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_usize_field(field: &str, value: &str, warnings: &mut Vec<String>) -> Option<usize> {
|
||||
parse_u64_field(field, value, warnings).and_then(|number| match usize::try_from(number) {
|
||||
Ok(value) => Some(value),
|
||||
Err(_error) => {
|
||||
warnings.push(format!("numeric field `{field}` is too large: `{value}`"));
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_u64_field(field: &str, value: &str, warnings: &mut Vec<String>) -> Option<u64> {
|
||||
let parsed = parse_u64(value);
|
||||
if parsed.is_none() {
|
||||
warnings.push(format!(
|
||||
"could not parse numeric field `{field}` from `{value}`"
|
||||
));
|
||||
}
|
||||
parsed
|
||||
}
|
||||
|
||||
fn parse_u64(value: &str) -> Option<u64> {
|
||||
let token = value
|
||||
.trim()
|
||||
.trim_matches(|character| character == '(' || character == ')')
|
||||
.split_whitespace()
|
||||
.next()?;
|
||||
let token = token.trim_end_matches(',');
|
||||
token
|
||||
.strip_prefix("0x")
|
||||
.or_else(|| token.strip_prefix("0X"))
|
||||
.map_or_else(
|
||||
|| token.parse::<u64>().ok(),
|
||||
|hex| u64::from_str_radix(hex, 16).ok(),
|
||||
)
|
||||
}
|
||||
|
||||
fn push_raw_line(report: &mut ReadobjReport, warnings: &mut Vec<String>, label: &str, text: &str) {
|
||||
warnings.push(format!("unparsed llvm-readobj line: {text}"));
|
||||
report.raw_blocks.push(ReadobjRawBlock {
|
||||
label: label.to_owned(),
|
||||
text: text.to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
fn push_raw_block(
|
||||
report: &mut ReadobjReport,
|
||||
warnings: &mut Vec<String>,
|
||||
label: &str,
|
||||
text: String,
|
||||
) {
|
||||
warnings.push(format!("unparsed llvm-readobj block: {label}"));
|
||||
report.raw_blocks.push(ReadobjRawBlock {
|
||||
label: label.to_owned(),
|
||||
text,
|
||||
});
|
||||
}
|
||||
|
||||
fn flatten_debug_hints(debug: &[DebugHint]) -> Vec<String> {
|
||||
let mut hints = Vec::new();
|
||||
for item in debug {
|
||||
if let Some(kind) = &item.kind {
|
||||
hints.push(format!("Type: {kind}"));
|
||||
}
|
||||
if let Some(pdb) = &item.pdb {
|
||||
hints.push(format!("PDB: {pdb}"));
|
||||
}
|
||||
if let Some(guid) = &item.guid {
|
||||
hints.push(format!("GUID: {guid}"));
|
||||
}
|
||||
if let Some(age) = item.age {
|
||||
hints.push(format!("Age: {age}"));
|
||||
}
|
||||
hints.extend(item.hints.iter().cloned());
|
||||
}
|
||||
hints
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_report() -> &'static str {
|
||||
r"
|
||||
File: sample.obj
|
||||
Format: COFF-x86-64
|
||||
Arch: x86_64
|
||||
AddressSize: 64bit
|
||||
ImageFileHeader {
|
||||
Machine: IMAGE_FILE_MACHINE_AMD64 (0x8664)
|
||||
SectionCount: 3
|
||||
TimeDateStamp: 2026-05-11 10:20:30 (0x6821CAFE)
|
||||
PointerToSymbolTable: 0x120
|
||||
SymbolCount: 18
|
||||
StringTableSize: 42
|
||||
OptionalHeaderSize: 0
|
||||
Characteristics [ (0x2022)
|
||||
IMAGE_FILE_EXECUTABLE_IMAGE (0x2)
|
||||
IMAGE_FILE_LARGE_ADDRESS_AWARE (0x20)
|
||||
IMAGE_FILE_DLL (0x2000)
|
||||
]
|
||||
}
|
||||
Sections [
|
||||
Section {
|
||||
Number: 1
|
||||
Name: .text
|
||||
Size: 0x2A
|
||||
Address: 0x140001000
|
||||
}
|
||||
Section {
|
||||
Number: 2
|
||||
Name: .rdata
|
||||
VirtualSize: 96
|
||||
VirtualAddress: 0x140002000
|
||||
}
|
||||
]
|
||||
ImportTable {
|
||||
Name: KERNEL32.dll
|
||||
Import {
|
||||
Name: GetLastError
|
||||
}
|
||||
Import {
|
||||
Ordinal: 5
|
||||
}
|
||||
DLLName: USER32.dll
|
||||
Symbol: MessageBoxW
|
||||
}
|
||||
ExportTable {
|
||||
Name: sample.dll
|
||||
Export {
|
||||
Ordinal: 1
|
||||
Name: Run
|
||||
RVA: 0x1010
|
||||
}
|
||||
}
|
||||
DebugDirectory [
|
||||
DebugEntry {
|
||||
Type: CodeView
|
||||
PDBFileName: C:\build\sample.pdb
|
||||
GUID: 00112233-4455-6677-8899-AABBCCDDEEFF
|
||||
Age: 3
|
||||
}
|
||||
]
|
||||
LoadConfig {
|
||||
GuardFlags: 0x100
|
||||
}
|
||||
"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_top_level_header_and_image_file_header_fields() {
|
||||
let parsed = parse_readobj_output(sample_report());
|
||||
let report = parsed.report;
|
||||
assert_eq!(report.file.as_deref(), Some("sample.obj"));
|
||||
assert_eq!(report.format.as_deref(), Some("COFF-x86-64"));
|
||||
assert_eq!(report.arch.as_deref(), Some("x86_64"));
|
||||
assert_eq!(report.address_size.as_deref(), Some("64bit"));
|
||||
assert_eq!(
|
||||
report.machine.as_deref(),
|
||||
Some("IMAGE_FILE_MACHINE_AMD64 (0x8664)")
|
||||
);
|
||||
assert_eq!(report.section_count, Some(3));
|
||||
|
||||
let Some(header) = report.image_file_header.as_ref() else {
|
||||
panic!("missing image file header");
|
||||
};
|
||||
assert_eq!(
|
||||
header.time_date_stamp.as_deref(),
|
||||
Some("2026-05-11 10:20:30 (0x6821CAFE)")
|
||||
);
|
||||
assert_eq!(header.pointer_to_symbol_table.as_deref(), Some("0x120"));
|
||||
assert_eq!(header.symbol_count, Some(18));
|
||||
assert_eq!(header.string_table_size, Some(42));
|
||||
assert_eq!(header.optional_header_size, Some(0));
|
||||
assert_eq!(
|
||||
header.characteristics,
|
||||
vec![
|
||||
"IMAGE_FILE_EXECUTABLE_IMAGE (0x2)",
|
||||
"IMAGE_FILE_LARGE_ADDRESS_AWARE (0x20)",
|
||||
"IMAGE_FILE_DLL (0x2000)"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_sections_imports_exports_and_debug_hints() {
|
||||
let parsed = parse_readobj_output(sample_report());
|
||||
let report = parsed.report;
|
||||
|
||||
assert_eq!(
|
||||
report.sections,
|
||||
vec![
|
||||
ReadobjSection {
|
||||
number: 1,
|
||||
name: ".text".to_owned(),
|
||||
size: Some(0x2A),
|
||||
address: Some(0x0001_4000_1000),
|
||||
virtual_size: None,
|
||||
virtual_address: None,
|
||||
raw_data_size: None,
|
||||
},
|
||||
ReadobjSection {
|
||||
number: 2,
|
||||
name: ".rdata".to_owned(),
|
||||
size: Some(96),
|
||||
address: Some(0x0001_4000_2000),
|
||||
virtual_size: Some(96),
|
||||
virtual_address: Some(0x0001_4000_2000),
|
||||
raw_data_size: None,
|
||||
}
|
||||
]
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
report.imports,
|
||||
vec![
|
||||
ImportLibrary {
|
||||
name: "KERNEL32.dll".to_owned(),
|
||||
library: "KERNEL32.dll".to_owned(),
|
||||
symbols: vec!["GetLastError".to_owned(), "Ordinal: 5".to_owned()],
|
||||
},
|
||||
ImportLibrary {
|
||||
name: "USER32.dll".to_owned(),
|
||||
library: "USER32.dll".to_owned(),
|
||||
symbols: vec!["MessageBoxW".to_owned()],
|
||||
}
|
||||
]
|
||||
);
|
||||
assert_eq!(report.export_count, 1);
|
||||
assert_eq!(
|
||||
report.exports,
|
||||
vec![ExportSymbol {
|
||||
name: Some("Run".to_owned()),
|
||||
ordinal: Some(1),
|
||||
rva: Some(0x1010),
|
||||
hints: Vec::new(),
|
||||
}]
|
||||
);
|
||||
assert_eq!(
|
||||
report.debug,
|
||||
vec![DebugHint {
|
||||
kind: Some("CodeView".to_owned()),
|
||||
pdb: Some(r"C:\build\sample.pdb".to_owned()),
|
||||
guid: Some("00112233-4455-6677-8899-AABBCCDDEEFF".to_owned()),
|
||||
age: Some(3),
|
||||
hints: Vec::new(),
|
||||
}]
|
||||
);
|
||||
assert!(report.debug_hints.iter().any(|hint| hint.contains("PDB")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_unknown_blocks_and_warns_on_malformed_numbers() {
|
||||
let input = r"
|
||||
File: odd.exe
|
||||
Sections [
|
||||
Section {
|
||||
Number: nope
|
||||
Name: .bad
|
||||
Size: 0xGG
|
||||
}
|
||||
]
|
||||
MysteryBlock {
|
||||
Alpha: Beta
|
||||
}
|
||||
Trailing: value
|
||||
";
|
||||
|
||||
let parsed = parse_readobj_output(input);
|
||||
assert_eq!(parsed.report.sections.len(), 1);
|
||||
assert_eq!(parsed.report.sections[0].name, ".bad");
|
||||
assert_eq!(parsed.report.sections[0].number, 1);
|
||||
assert_eq!(parsed.report.sections[0].size, None);
|
||||
assert!(
|
||||
parsed
|
||||
.warnings
|
||||
.iter()
|
||||
.any(|warning| warning.contains("Number") && warning.contains("nope"))
|
||||
);
|
||||
assert!(
|
||||
parsed
|
||||
.warnings
|
||||
.iter()
|
||||
.any(|warning| warning.contains("Size") && warning.contains("0xGG"))
|
||||
);
|
||||
assert_eq!(parsed.report.raw_blocks.len(), 2);
|
||||
assert_eq!(parsed.report.raw_blocks[0].label, "MysteryBlock");
|
||||
assert!(parsed.report.raw_blocks[0].text.contains("Alpha: Beta"));
|
||||
assert_eq!(parsed.report.raw_blocks[1].label, "Trailing");
|
||||
assert_eq!(parsed.report.raw_blocks[1].text, "Trailing: value");
|
||||
assert_eq!(
|
||||
parsed.raw_lines,
|
||||
vec![
|
||||
"MysteryBlock {\n Alpha: Beta\n}".to_owned(),
|
||||
"Trailing: value".to_owned()
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
//! Stable PE/COFF summary extracted with goblin.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use goblin::pe::{PE, header::machine_to_str, subsystem};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub(crate) struct PeSummary {
|
||||
pub machine: String,
|
||||
pub architecture: String,
|
||||
pub subsystem: String,
|
||||
pub bitness: u8,
|
||||
pub section_count: usize,
|
||||
pub import_library_count: usize,
|
||||
pub export_count: usize,
|
||||
pub has_clr: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn summarize_pe(path: &Path) -> Option<PeSummary> {
|
||||
let bytes = fs::read(path).ok()?;
|
||||
let pe = PE::parse(&bytes).ok()?;
|
||||
let machine = machine_to_str(pe.header.coff_header.machine).to_owned();
|
||||
Some(PeSummary {
|
||||
architecture: architecture_from_machine(&machine).to_owned(),
|
||||
machine,
|
||||
subsystem: subsystem_label(pe.header.optional_header.as_ref()?.windows_fields.subsystem)
|
||||
.to_owned(),
|
||||
bitness: if pe.is_64 { 64 } else { 32 },
|
||||
section_count: pe.sections.len(),
|
||||
import_library_count: pe.libraries.len(),
|
||||
export_count: pe.exports.len(),
|
||||
has_clr: pe.header.optional_header.as_ref().is_some_and(|optional| {
|
||||
optional
|
||||
.data_directories
|
||||
.get_clr_runtime_header()
|
||||
.is_some_and(|directory| directory.size > 0)
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
const fn subsystem_label(value: u16) -> &'static str {
|
||||
match value {
|
||||
subsystem::IMAGE_SUBSYSTEM_UNKNOWN => "IMAGE_SUBSYSTEM_UNKNOWN",
|
||||
subsystem::IMAGE_SUBSYSTEM_NATIVE => "IMAGE_SUBSYSTEM_NATIVE",
|
||||
subsystem::IMAGE_SUBSYSTEM_WINDOWS_GUI => "IMAGE_SUBSYSTEM_WINDOWS_GUI",
|
||||
subsystem::IMAGE_SUBSYSTEM_WINDOWS_CUI => "IMAGE_SUBSYSTEM_WINDOWS_CUI",
|
||||
subsystem::IMAGE_SUBSYSTEM_OS2_CUI => "IMAGE_SUBSYSTEM_OS2_CUI",
|
||||
subsystem::IMAGE_SUBSYSTEM_POSIX_CUI => "IMAGE_SUBSYSTEM_POSIX_CUI",
|
||||
subsystem::IMAGE_SUBSYSTEM_NATIVE_WINDOWS => "IMAGE_SUBSYSTEM_NATIVE_WINDOWS",
|
||||
subsystem::IMAGE_SUBSYSTEM_WINDOWS_CE_GUI => "IMAGE_SUBSYSTEM_WINDOWS_CE_GUI",
|
||||
subsystem::IMAGE_SUBSYSTEM_EFI_APPLICATION => "IMAGE_SUBSYSTEM_EFI_APPLICATION",
|
||||
subsystem::IMAGE_SUBSYSTEM_EFI_BOOT_SERVICE_DRIVER => {
|
||||
"IMAGE_SUBSYSTEM_EFI_BOOT_SERVICE_DRIVER"
|
||||
}
|
||||
subsystem::IMAGE_SUBSYSTEM_EFI_RUNTIME_DRIVER => "IMAGE_SUBSYSTEM_EFI_RUNTIME_DRIVER",
|
||||
subsystem::IMAGE_SUBSYSTEM_EFI_ROM => "IMAGE_SUBSYSTEM_EFI_ROM",
|
||||
subsystem::IMAGE_SUBSYSTEM_XBOX => "IMAGE_SUBSYSTEM_XBOX",
|
||||
subsystem::IMAGE_SUBSYSTEM_WINDOWS_BOOT_APPLICATION => {
|
||||
"IMAGE_SUBSYSTEM_WINDOWS_BOOT_APPLICATION"
|
||||
}
|
||||
_ => "IMAGE_SUBSYSTEM_UNKNOWN_VALUE",
|
||||
}
|
||||
}
|
||||
|
||||
fn architecture_from_machine(machine: &str) -> &'static str {
|
||||
if machine.contains("x86-64")
|
||||
|| machine.contains("X86_64")
|
||||
|| machine.contains("AMD64")
|
||||
|| machine.eq_ignore_ascii_case("x86_64")
|
||||
{
|
||||
"x86_64"
|
||||
} else if machine.contains("ARM64") {
|
||||
"aarch64"
|
||||
} else if machine.contains("i386") || machine.contains("I386") {
|
||||
"x86"
|
||||
} else {
|
||||
"unknown"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{architecture_from_machine, subsystem_label, summarize_pe};
|
||||
use goblin::pe::subsystem;
|
||||
|
||||
fn malformed_pe_missing_section_table() -> Vec<u8> {
|
||||
let pe_offset = 0x80_usize;
|
||||
let optional_size = 0xF0_u16;
|
||||
let mut bytes = vec![0_u8; pe_offset + 4 + 20 + usize::from(optional_size)];
|
||||
bytes[0] = b'M';
|
||||
bytes[1] = b'Z';
|
||||
bytes[0x3c..0x40].copy_from_slice(&0x80_u32.to_le_bytes());
|
||||
|
||||
let coff = pe_offset + 4;
|
||||
bytes[pe_offset..coff].copy_from_slice(b"PE\0\0");
|
||||
bytes[coff..coff + 2].copy_from_slice(&0x8664_u16.to_le_bytes());
|
||||
bytes[coff + 2..coff + 4].copy_from_slice(&1_u16.to_le_bytes());
|
||||
bytes[coff + 16..coff + 18].copy_from_slice(&optional_size.to_le_bytes());
|
||||
bytes[coff + 18..coff + 20].copy_from_slice(&0x0002_u16.to_le_bytes());
|
||||
|
||||
let optional = coff + 20;
|
||||
bytes[optional..optional + 2].copy_from_slice(&0x020B_u16.to_le_bytes());
|
||||
bytes[optional + 68..optional + 70]
|
||||
.copy_from_slice(&subsystem::IMAGE_SUBSYSTEM_WINDOWS_CUI.to_le_bytes());
|
||||
bytes[optional + 108..optional + 112].copy_from_slice(&16_u32.to_le_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
fn malformed_pe_with_data_directory_rva(
|
||||
directory_index: usize,
|
||||
rva: u32,
|
||||
size: u32,
|
||||
) -> Vec<u8> {
|
||||
let (mut bytes, _) = minimal_pe_with_one_section();
|
||||
let optional = 0x80_usize + 4 + 20;
|
||||
let directory = optional + 112 + (directory_index * 8);
|
||||
bytes[directory..directory + 4].copy_from_slice(&rva.to_le_bytes());
|
||||
bytes[directory + 4..directory + 8].copy_from_slice(&size.to_le_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
fn malformed_pe_export_directory_with_bad_tables() -> Vec<u8> {
|
||||
let (mut bytes, export_offset) = minimal_pe_with_one_section();
|
||||
let optional = 0x80_usize + 4 + 20;
|
||||
bytes[optional + 112..optional + 116].copy_from_slice(&0x1000_u32.to_le_bytes());
|
||||
bytes[optional + 116..optional + 120].copy_from_slice(&0x28_u32.to_le_bytes());
|
||||
bytes[export_offset + 12..export_offset + 16].copy_from_slice(&0x9000_u32.to_le_bytes());
|
||||
bytes[export_offset + 16..export_offset + 20].copy_from_slice(&1_u32.to_le_bytes());
|
||||
bytes[export_offset + 20..export_offset + 24].copy_from_slice(&1_u32.to_le_bytes());
|
||||
bytes[export_offset + 24..export_offset + 28].copy_from_slice(&1_u32.to_le_bytes());
|
||||
bytes[export_offset + 28..export_offset + 32].copy_from_slice(&0x9000_u32.to_le_bytes());
|
||||
bytes[export_offset + 32..export_offset + 36].copy_from_slice(&0x9000_u32.to_le_bytes());
|
||||
bytes[export_offset + 36..export_offset + 40].copy_from_slice(&0x9000_u32.to_le_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
fn minimal_pe_with_one_section() -> (Vec<u8>, usize) {
|
||||
let pe_offset = 0x80_usize;
|
||||
let optional_size = 0xF0_u16;
|
||||
let section_size = 40_usize;
|
||||
let section_raw_offset = pe_offset + 4 + 20 + usize::from(optional_size) + section_size;
|
||||
let mut bytes = vec![0_u8; section_raw_offset + 0x200];
|
||||
bytes[0] = b'M';
|
||||
bytes[1] = b'Z';
|
||||
bytes[0x3c..0x40].copy_from_slice(&0x80_u32.to_le_bytes());
|
||||
|
||||
let coff = pe_offset + 4;
|
||||
bytes[pe_offset..coff].copy_from_slice(b"PE\0\0");
|
||||
bytes[coff..coff + 2].copy_from_slice(&0x8664_u16.to_le_bytes());
|
||||
bytes[coff + 2..coff + 4].copy_from_slice(&1_u16.to_le_bytes());
|
||||
bytes[coff + 16..coff + 18].copy_from_slice(&optional_size.to_le_bytes());
|
||||
bytes[coff + 18..coff + 20].copy_from_slice(&0x0002_u16.to_le_bytes());
|
||||
|
||||
let optional = coff + 20;
|
||||
bytes[optional..optional + 2].copy_from_slice(&0x020B_u16.to_le_bytes());
|
||||
bytes[optional + 16..optional + 20].copy_from_slice(&0x1000_u32.to_le_bytes());
|
||||
bytes[optional + 24..optional + 32].copy_from_slice(&0x1400_0000_u64.to_le_bytes());
|
||||
bytes[optional + 32..optional + 36].copy_from_slice(&0x1000_u32.to_le_bytes());
|
||||
bytes[optional + 36..optional + 40].copy_from_slice(&0x200_u32.to_le_bytes());
|
||||
bytes[optional + 68..optional + 70]
|
||||
.copy_from_slice(&subsystem::IMAGE_SUBSYSTEM_WINDOWS_CUI.to_le_bytes());
|
||||
bytes[optional + 80..optional + 84].copy_from_slice(&0x2000_u32.to_le_bytes());
|
||||
bytes[optional + 84..optional + 88].copy_from_slice(&0x200_u32.to_le_bytes());
|
||||
bytes[optional + 108..optional + 112].copy_from_slice(&16_u32.to_le_bytes());
|
||||
|
||||
let section = optional + usize::from(optional_size);
|
||||
bytes[section..section + 8].copy_from_slice(b".rdata\0\0");
|
||||
bytes[section + 8..section + 12].copy_from_slice(&0x200_u32.to_le_bytes());
|
||||
bytes[section + 12..section + 16].copy_from_slice(&0x1000_u32.to_le_bytes());
|
||||
bytes[section + 16..section + 20].copy_from_slice(&0x200_u32.to_le_bytes());
|
||||
let section_raw_offset_u32 =
|
||||
u32::try_from(section_raw_offset).expect("fixture raw offset fits u32");
|
||||
bytes[section + 20..section + 24].copy_from_slice(§ion_raw_offset_u32.to_le_bytes());
|
||||
(bytes, section_raw_offset)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognizes_machine_names_and_subsystems() {
|
||||
assert_eq!(architecture_from_machine("X86_64"), "x86_64");
|
||||
assert_eq!(architecture_from_machine("AMD64"), "x86_64");
|
||||
assert_eq!(architecture_from_machine("ARM64"), "aarch64");
|
||||
assert_eq!(architecture_from_machine("I386"), "x86");
|
||||
assert_eq!(architecture_from_machine("mystery"), "unknown");
|
||||
|
||||
assert_eq!(
|
||||
subsystem_label(subsystem::IMAGE_SUBSYSTEM_UNKNOWN),
|
||||
"IMAGE_SUBSYSTEM_UNKNOWN"
|
||||
);
|
||||
assert_eq!(
|
||||
subsystem_label(subsystem::IMAGE_SUBSYSTEM_NATIVE),
|
||||
"IMAGE_SUBSYSTEM_NATIVE"
|
||||
);
|
||||
assert_eq!(
|
||||
subsystem_label(subsystem::IMAGE_SUBSYSTEM_WINDOWS_GUI),
|
||||
"IMAGE_SUBSYSTEM_WINDOWS_GUI"
|
||||
);
|
||||
assert_eq!(
|
||||
subsystem_label(subsystem::IMAGE_SUBSYSTEM_WINDOWS_CUI),
|
||||
"IMAGE_SUBSYSTEM_WINDOWS_CUI"
|
||||
);
|
||||
assert_eq!(
|
||||
subsystem_label(subsystem::IMAGE_SUBSYSTEM_EFI_RUNTIME_DRIVER),
|
||||
"IMAGE_SUBSYSTEM_EFI_RUNTIME_DRIVER"
|
||||
);
|
||||
assert_eq!(
|
||||
subsystem_label(subsystem::IMAGE_SUBSYSTEM_WINDOWS_BOOT_APPLICATION),
|
||||
"IMAGE_SUBSYSTEM_WINDOWS_BOOT_APPLICATION"
|
||||
);
|
||||
assert_eq!(subsystem_label(0xffff), "IMAGE_SUBSYSTEM_UNKNOWN_VALUE");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn summarizes_current_test_binary_as_pe() {
|
||||
let current = std::env::current_exe().expect("current exe");
|
||||
let summary = summarize_pe(¤t).expect("current test binary is PE");
|
||||
|
||||
assert_eq!(summary.bitness, 64);
|
||||
assert!(!summary.machine.is_empty());
|
||||
assert!(!summary.architecture.is_empty());
|
||||
assert!(!summary.subsystem.is_empty());
|
||||
assert!(summary.section_count > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_pe_or_missing_files_return_none() {
|
||||
let temp = tempfile::NamedTempFile::new().expect("temp file");
|
||||
std::fs::write(temp.path(), b"not a pe").expect("write");
|
||||
assert_eq!(summarize_pe(temp.path()), None);
|
||||
assert_eq!(
|
||||
summarize_pe(std::path::Path::new("Z:/missing/not-pe.exe")),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_pe_headers_return_none_without_panicking() {
|
||||
let temp = tempfile::NamedTempFile::new().expect("temp file");
|
||||
let mut bytes = vec![0_u8; 0x40];
|
||||
bytes[0] = b'M';
|
||||
bytes[1] = b'Z';
|
||||
bytes[0x3c..0x40].copy_from_slice(&0x80_u32.to_le_bytes());
|
||||
std::fs::write(temp.path(), bytes).expect("write");
|
||||
|
||||
assert_eq!(summarize_pe(temp.path()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_section_table_returns_none_without_panicking() {
|
||||
let temp = tempfile::NamedTempFile::new().expect("temp file");
|
||||
std::fs::write(temp.path(), malformed_pe_missing_section_table()).expect("write");
|
||||
|
||||
assert_eq!(summarize_pe(temp.path()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_import_or_resource_directory_returns_none_without_panicking() {
|
||||
for directory_index in [1, 2] {
|
||||
let temp = tempfile::NamedTempFile::new().expect("temp file");
|
||||
std::fs::write(
|
||||
temp.path(),
|
||||
malformed_pe_with_data_directory_rva(directory_index, 0x9000, 0x100),
|
||||
)
|
||||
.expect("write");
|
||||
|
||||
assert_eq!(summarize_pe(temp.path()), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_export_directory_stays_bounded_without_fake_exports() {
|
||||
let temp = tempfile::NamedTempFile::new().expect("temp file");
|
||||
std::fs::write(temp.path(), malformed_pe_export_directory_with_bad_tables())
|
||||
.expect("write");
|
||||
|
||||
let summary = summarize_pe(temp.path()).expect("summary");
|
||||
|
||||
assert_eq!(summary.section_count, 1);
|
||||
assert_eq!(summary.export_count, 0);
|
||||
assert_eq!(summary.import_library_count, 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
//! Integration tests for the LLVM COFF helper commands.
|
||||
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use assert_cmd::Command;
|
||||
use predicates::prelude::*;
|
||||
use serde_json::Value;
|
||||
use tempfile::{TempDir, tempdir};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct ToolCase {
|
||||
binary: &'static str,
|
||||
backend: &'static str,
|
||||
}
|
||||
|
||||
const LLVM_TOOL_CASES: &[ToolCase] = &[
|
||||
ToolCase {
|
||||
binary: "llvmreadobj",
|
||||
backend: "llvm-readobj",
|
||||
},
|
||||
ToolCase {
|
||||
binary: "llvmobjdump",
|
||||
backend: "llvm-objdump",
|
||||
},
|
||||
ToolCase {
|
||||
binary: "llvmnm",
|
||||
backend: "llvm-nm",
|
||||
},
|
||||
];
|
||||
|
||||
fn llvm_command(binary: &str) -> Command {
|
||||
Command::cargo_bin(binary).expect("binary")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_mentions_examples_backend_and_shared_output_flags() {
|
||||
for case in LLVM_TOOL_CASES {
|
||||
let mut command = llvm_command(case.binary);
|
||||
command
|
||||
.arg("--help")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("Examples"))
|
||||
.stdout(predicate::str::contains("--llvm-bin-dir"))
|
||||
.stdout(predicate::str::contains("--llvm-arg"))
|
||||
.stdout(predicate::str::contains("--json"))
|
||||
.stdout(predicate::str::contains("--toon"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_output_exposes_common_top_level_contract() {
|
||||
for case in LLVM_TOOL_CASES {
|
||||
let backend_dir = fake_backend_dir(LLVM_TOOL_CASES);
|
||||
let input_dir = tempdir().expect("input tempdir");
|
||||
let object = write_coff_placeholder(input_dir.path(), case.binary);
|
||||
|
||||
let mut command = llvm_command(case.binary);
|
||||
add_fake_backend(&mut command, backend_dir.path());
|
||||
let output = command
|
||||
.arg("--json")
|
||||
.arg(&object)
|
||||
.assert()
|
||||
.success()
|
||||
.get_output()
|
||||
.stdout
|
||||
.clone();
|
||||
|
||||
let json = serde_json::from_slice::<Value>(&output).expect("json output");
|
||||
assert_common_json_contract(case.binary, &json);
|
||||
assert!(
|
||||
json["files"]
|
||||
.as_array()
|
||||
.is_some_and(|files| !files.is_empty()),
|
||||
"{}: expected at least one file entry in {json}",
|
||||
case.binary
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stdin_lines_feed_multiple_files() {
|
||||
for case in LLVM_TOOL_CASES {
|
||||
let backend_dir = fake_backend_dir(LLVM_TOOL_CASES);
|
||||
let input_dir = tempdir().expect("input tempdir");
|
||||
let first = write_coff_placeholder(input_dir.path(), &format!("{}-first", case.binary));
|
||||
let second = write_coff_placeholder(input_dir.path(), &format!("{}-second", case.binary));
|
||||
let stdin = format!("{}\n{}\n", first.display(), second.display());
|
||||
|
||||
let mut command = llvm_command(case.binary);
|
||||
add_fake_backend(&mut command, backend_dir.path());
|
||||
let output = command
|
||||
.arg("--json")
|
||||
.write_stdin(stdin)
|
||||
.assert()
|
||||
.success()
|
||||
.get_output()
|
||||
.stdout
|
||||
.clone();
|
||||
|
||||
let json = serde_json::from_slice::<Value>(&output).expect("json output");
|
||||
assert_common_json_contract(case.binary, &json);
|
||||
let files = json["files"].as_array().expect("files array");
|
||||
assert_eq!(
|
||||
files.len(),
|
||||
2,
|
||||
"{}: expected stdin line input to produce two file entries in {json}",
|
||||
case.binary
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objdump_symbol_and_address_flags_are_recorded_in_backend_argv() {
|
||||
let backend_dir = fake_backend_dir(LLVM_TOOL_CASES);
|
||||
let input_dir = tempdir().expect("input tempdir");
|
||||
let object = write_coff_placeholder(input_dir.path(), "objdump-argv");
|
||||
|
||||
let mut command = llvm_command("llvmobjdump");
|
||||
add_fake_backend(&mut command, backend_dir.path());
|
||||
let output = command
|
||||
.arg("--symbol")
|
||||
.arg("main")
|
||||
.arg("--start-address")
|
||||
.arg("0x0")
|
||||
.arg("--stop-address")
|
||||
.arg("0x20")
|
||||
.arg("--json")
|
||||
.arg(&object)
|
||||
.assert()
|
||||
.success()
|
||||
.get_output()
|
||||
.stdout
|
||||
.clone();
|
||||
|
||||
let json = serde_json::from_slice::<Value>(&output).expect("json output");
|
||||
let argv = json["backend"]["argv"]
|
||||
.as_array()
|
||||
.expect("backend argv")
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.collect::<Vec<_>>();
|
||||
assert!(argv.contains(&"--disassemble-symbols=main"));
|
||||
assert!(argv.contains(&"--start-address=0x0"));
|
||||
assert!(argv.contains(&"--stop-address=0x20"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_backend_fails_with_actionable_message() {
|
||||
for case in LLVM_TOOL_CASES {
|
||||
let empty_backend_dir = tempdir().expect("backend tempdir");
|
||||
let input_dir = tempdir().expect("input tempdir");
|
||||
let object = write_coff_placeholder(input_dir.path(), case.binary);
|
||||
|
||||
let mut command = llvm_command(case.binary);
|
||||
command
|
||||
.arg("--llvm-bin-dir")
|
||||
.arg(empty_backend_dir.path())
|
||||
.arg(&object)
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains(case.backend))
|
||||
.stderr(predicate::str::contains("--llvm-bin-dir"))
|
||||
.stderr(
|
||||
predicate::str::contains("backend")
|
||||
.or(predicate::str::contains("LLVM"))
|
||||
.or(predicate::str::contains("not found")),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_common_json_contract(binary: &str, json: &Value) {
|
||||
assert!(
|
||||
json.get("backend").is_some(),
|
||||
"{binary}: missing top-level backend in {json}"
|
||||
);
|
||||
assert!(
|
||||
json.get("summary").is_some(),
|
||||
"{binary}: missing top-level summary in {json}"
|
||||
);
|
||||
assert!(
|
||||
json.get("files").and_then(Value::as_array).is_some(),
|
||||
"{binary}: missing top-level files array in {json}"
|
||||
);
|
||||
assert!(
|
||||
json.get("parse_warnings")
|
||||
.and_then(Value::as_array)
|
||||
.is_some(),
|
||||
"{binary}: missing top-level parse_warnings array in {json}"
|
||||
);
|
||||
}
|
||||
|
||||
fn add_fake_backend(command: &mut Command, backend_dir: &Path) {
|
||||
command.arg("--llvm-bin-dir").arg(backend_dir);
|
||||
for arg in fake_backend_args() {
|
||||
command.arg("--llvm-arg").arg(arg);
|
||||
}
|
||||
}
|
||||
|
||||
fn fake_backend_dir(cases: &[ToolCase]) -> TempDir {
|
||||
let dir = tempdir().expect("backend tempdir");
|
||||
let shell = host_shell();
|
||||
for case in cases {
|
||||
for file_name in backend_file_names(case.backend) {
|
||||
let destination = dir.path().join(file_name);
|
||||
fs::copy(&shell, &destination).expect("copy fake backend executable");
|
||||
make_executable(&destination);
|
||||
}
|
||||
}
|
||||
dir
|
||||
}
|
||||
|
||||
fn host_shell() -> PathBuf {
|
||||
if cfg!(windows) {
|
||||
env::var_os("COMSPEC").map_or_else(
|
||||
|| PathBuf::from(r"C:\Windows\System32\cmd.exe"),
|
||||
PathBuf::from,
|
||||
)
|
||||
} else {
|
||||
PathBuf::from("/bin/sh")
|
||||
}
|
||||
}
|
||||
|
||||
fn backend_file_names(backend: &str) -> Vec<String> {
|
||||
if cfg!(windows) {
|
||||
vec![backend.to_owned(), format!("{backend}.exe")]
|
||||
} else {
|
||||
vec![backend.to_owned()]
|
||||
}
|
||||
}
|
||||
|
||||
fn fake_backend_args() -> Vec<&'static str> {
|
||||
if cfg!(windows) {
|
||||
vec!["/D", "/S", "/C", "echo fake llvm coff output"]
|
||||
} else {
|
||||
vec!["-c", "printf 'fake llvm coff output\\n'"]
|
||||
}
|
||||
}
|
||||
|
||||
fn write_coff_placeholder(dir: &Path, stem: &str) -> PathBuf {
|
||||
let path = dir.join(format!("{stem}.obj"));
|
||||
fs::write(&path, b"MZ fake COFF placeholder\n").expect("write object placeholder");
|
||||
path
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn make_executable(path: &Path) {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
|
||||
let mut permissions = fs::metadata(path)
|
||||
.expect("fake backend metadata")
|
||||
.permissions();
|
||||
permissions.set_mode(0o755);
|
||||
fs::set_permissions(path, permissions).expect("fake backend permissions");
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
const fn make_executable(_path: &Path) {}
|
||||
Reference in New Issue
Block a user