chore(release): prepare public source release

This commit is contained in:
MercuryToolbox Release
2026-07-18 15:41:59 +08:00
commit e365e5df4d
508 changed files with 163373 additions and 0 deletions
+39
View File
@@ -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
+316
View File
@@ -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());
}
}
+908
View File
@@ -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"));
}
}
+28
View File
@@ -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)
}
+5
View File
@@ -0,0 +1,5 @@
//! Binary entry point for `llvmnm`.
fn main() {
std::process::exit(llvmtools::main_entry(llvmtools::ToolKind::Nm));
}
+5
View File
@@ -0,0 +1,5 @@
//! Binary entry point for `llvmobjdump`.
fn main() {
std::process::exit(llvmtools::main_entry(llvmtools::ToolKind::Objdump));
}
+5
View File
@@ -0,0 +1,5 @@
//! Binary entry point for `llvmreadobj`.
fn main() {
std::process::exit(llvmtools::main_entry(llvmtools::ToolKind::Readobj));
}
+398
View File
@@ -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"));
}
}
+631
View File
@@ -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")
);
}
}
+874
View File
@@ -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()
]
);
}
}
+284
View File
@@ -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(&section_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(&current).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);
}
}
+260
View File
@@ -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) {}