Skip to main content

haruspex/
lib.rs

1#![doc = include_str!("../README.md")]
2#![doc(html_logo_url = "https://raw.githubusercontent.com/0xdea/haruspex/master/.img/logo.png")]
3
4use std::fs;
5use std::fs::File;
6use std::io::{BufWriter, Write as _};
7use std::path::{Path, PathBuf};
8
9use anyhow::Context as _;
10use idalib::IDAError;
11use idalib::decompiler::HexRaysErrorCode;
12use idalib::func::{Function, FunctionFlags};
13use idalib::idb::IDB;
14use thiserror::Error;
15
16/// Reserved characters in filenames
17#[cfg(unix)]
18const RESERVED_CHARS: &[char] = &['.', '/'];
19#[cfg(windows)]
20const RESERVED_CHARS: &[char] = &['.', '/', '<', '>', ':', '"', '\\', '|', '?', '*'];
21
22/// Maximum length of filenames
23const MAX_FILENAME_LEN: usize = 64;
24
25/// Haruspex error type
26#[derive(Error, Debug)]
27#[non_exhaustive]
28pub enum HaruspexError {
29    /// Failure in decompiling the function
30    #[error(transparent)]
31    DecompileFailed(#[from] IDAError),
32    /// Failure in writing to the output file
33    #[error(transparent)]
34    FileWriteFailed(#[from] std::io::Error),
35}
36
37/// Extract pseudocode of functions in the binary file at `filepath` and save it in `filepath.dec`.
38///
39/// ## Errors
40///
41/// Returns how many functions were decompiled, or an error in case something goes wrong.
42pub fn run(filepath: &Path) -> anyhow::Result<usize> {
43    // Open the target binary and run auto-analysis
44    println!("[*] Analyzing binary file `{}`", filepath.display());
45    let idb = IDB::open(filepath)
46        .with_context(|| format!("Failed to analyze binary file `{}`", filepath.display()))?;
47    println!("[+] Successfully analyzed binary file");
48    println!();
49
50    // Print binary file information
51    println!("[-] Processor: {}", idb.processor().long_name());
52    println!("[-] Compiler: {:?}", idb.meta().cc_id());
53    println!("[-] File type: {:?}", idb.meta().filetype());
54    println!();
55
56    // Ensure Hex-Rays decompiler is available
57    anyhow::ensure!(idb.decompiler_available(), "Decompiler is not available");
58
59    // Create a new output directory, returning an error if it already exists, and it's not empty
60    let dirpath = filepath.with_extension("dec");
61    prepare_output_dir(&dirpath)?;
62
63    let mut decompiled_count = 0;
64
65    // Extract pseudocode of functions
66    println!();
67    println!("[*] Extracting pseudocode of functions...");
68    println!();
69    for (_id, f) in idb.functions() {
70        // Skip the function if it has the `thunk` attribute
71        if f.flags().contains(FunctionFlags::THUNK) {
72            continue;
73        }
74
75        // Decompile function and write pseudocode to the output file
76        let func_name = f.name().unwrap_or_else(|| "[no name]".into());
77        let output_path = output_path_for_function(&f, &dirpath);
78
79        match decompile_to_file(&idb, &f, &output_path) {
80            // Print the output path in case of successful function decompilation
81            Ok(()) => {
82                println!("{func_name} -> `{}`", output_path.display());
83                decompiled_count += 1;
84            }
85
86            // Return an error if Hex-Rays decompiler license is not available
87            Err(HaruspexError::DecompileFailed(IDAError::HexRays(e)))
88                if e.code() == HexRaysErrorCode::License =>
89            {
90                return Err(e.into());
91            }
92
93            // Ignore other IDA errors
94            Err(HaruspexError::DecompileFailed(_)) => (),
95
96            // Return any other error
97            Err(e) => return Err(e.into()),
98        }
99    }
100
101    // Remove the output directory and return an error in case no functions were decompiled
102    if decompiled_count == 0 {
103        fs::remove_dir(&dirpath)
104            .with_context(|| format!("Failed to remove directory `{}`", dirpath.display()))?;
105        anyhow::bail!("No functions were decompiled, check your input file");
106    }
107
108    println!();
109    println!(
110        "[+] Decompiled {decompiled_count} functions into `{}`",
111        dirpath.display()
112    );
113    println!("[+] Done processing binary file `{}`", filepath.display());
114    Ok(decompiled_count)
115}
116
117/// Decompile [`Function`] `func` in [`IDB`] `idb` and save its pseudocode to the output file at `filepath`.
118///
119/// ## Errors
120///
121/// Returns the appropriate [`HaruspexError`] in case something goes wrong.
122///
123/// ## Examples
124///
125/// Basic usage:
126/// ```
127/// # fn main() -> anyhow::Result<()> {
128/// # let base_dir = std::path::Path::new("./tests/data");
129/// let input_file = base_dir.join("ls");
130/// let output_file = base_dir.join("ls-main.c");
131///
132/// let idb = idalib::idb::IDB::open(&input_file)?;
133/// let (_, func) = idb
134///     .functions()
135///     .find(|(_, f)| f.name().unwrap() == "main")
136///     .unwrap();
137///
138/// haruspex::decompile_to_file(&idb, &func, &output_file)?;
139/// # std::fs::remove_file(output_file)?;
140/// # Ok(())
141/// # }
142/// ```
143///
144pub fn decompile_to_file(
145    idb: &IDB,
146    func: &Function,
147    filepath: impl AsRef<Path>,
148) -> Result<(), HaruspexError> {
149    // Decompile function
150    let decomp = idb.decompile(func)?;
151    let source = decomp.pseudocode();
152
153    // Write pseudocode to output file
154    // Note: for easier testing, we could use a generic function together with `std::io::Cursor`
155    let mut writer = BufWriter::new(File::create(&filepath)?);
156    writer.write_all(source.as_bytes())?;
157    writer.flush()?;
158
159    Ok(())
160}
161
162/// Create a fresh output directory at `dirpath`, removing it first if it exists and is empty
163///
164/// ## Errors
165///
166/// Returns an error if the directory already exists and is not empty, or if any filesystem operation fails.
167pub fn prepare_output_dir(dirpath: &Path) -> anyhow::Result<()> {
168    println!("[*] Preparing output directory `{}`", dirpath.display());
169    if dirpath.exists() {
170        fs::remove_dir(dirpath)
171            .with_context(|| format!("Output directory `{}` already exists", dirpath.display()))?;
172    }
173    fs::create_dir_all(dirpath)
174        .with_context(|| format!("Failed to create directory `{}`", dirpath.display()))?;
175    println!("[+] Output directory is ready");
176    Ok(())
177}
178
179/// Build the output file path for `func` inside `dirpath`
180#[must_use]
181pub fn output_path_for_function(func: &Function, dirpath: &Path) -> PathBuf {
182    let func_name = func.name().unwrap_or_else(|| "[no name]".into());
183    dirpath
184        .join(format!(
185            "{}@{:X}",
186            sanitize_filename(&func_name),
187            func.start_address()
188        ))
189        .with_extension("c")
190}
191
192/// Replace reserved characters in `filename` with underscores and truncate to `MAX_FILENAME_LEN`
193#[must_use]
194pub fn sanitize_filename(filename: &str) -> String {
195    filename
196        .replace(RESERVED_CHARS, "_")
197        .chars()
198        .take(MAX_FILENAME_LEN)
199        .collect()
200}
201
202#[cfg(test)]
203mod tests {
204    use std::{env, fs};
205
206    use super::*;
207
208    /// Return a unique temporary path scoped to the given label and current process
209    fn test_dir(label: &str) -> std::path::PathBuf {
210        env::temp_dir().join(format!("haruspex_{label}_{}", std::process::id()))
211    }
212
213    #[test]
214    fn prepare_output_dir_creates_missing_dir() -> anyhow::Result<()> {
215        let dir = test_dir("create");
216        if dir.exists() {
217            fs::remove_dir_all(&dir)?;
218        }
219
220        prepare_output_dir(&dir)?;
221        assert!(dir.is_dir(), "output directory should have been created");
222
223        fs::remove_dir(&dir)?;
224        Ok(())
225    }
226
227    #[test]
228    fn prepare_output_dir_removes_and_recreates_empty_dir() -> anyhow::Result<()> {
229        let dir = test_dir("empty");
230        if dir.exists() {
231            fs::remove_dir_all(&dir)?;
232        }
233        fs::create_dir_all(&dir)?;
234
235        prepare_output_dir(&dir)?;
236        assert!(
237            dir.is_dir(),
238            "output directory should still exist after prepare"
239        );
240
241        fs::remove_dir(&dir)?;
242        Ok(())
243    }
244
245    #[test]
246    fn prepare_output_dir_fails_on_nonempty_dir() -> anyhow::Result<()> {
247        let dir = test_dir("nonempty");
248        if dir.exists() {
249            fs::remove_dir_all(&dir)?;
250        }
251        fs::create_dir_all(&dir)?;
252        fs::write(dir.join("sentinel.txt"), b"block")?;
253
254        let result = prepare_output_dir(&dir);
255        assert!(
256            result.is_err(),
257            "prepare_output_dir should fail when directory is not empty"
258        );
259
260        fs::remove_dir_all(&dir)?;
261        Ok(())
262    }
263
264    #[test]
265    fn sanitize_filename_preserves_plain_names() {
266        assert_eq!(
267            sanitize_filename("hello_world"),
268            "hello_world",
269            "plain names should not be modified"
270        );
271    }
272
273    #[test]
274    fn sanitize_filename_replaces_dots() {
275        assert_eq!(
276            sanitize_filename("foo.bar"),
277            "foo_bar",
278            "dots should be replaced with underscores"
279        );
280    }
281
282    #[test]
283    fn sanitize_filename_replaces_slashes() {
284        assert_eq!(
285            sanitize_filename("foo/bar"),
286            "foo_bar",
287            "slashes should be replaced with underscores"
288        );
289    }
290
291    #[test]
292    fn sanitize_filename_empty_string() {
293        assert_eq!(
294            sanitize_filename(""),
295            "",
296            "empty input should produce empty output"
297        );
298    }
299
300    #[test]
301    fn sanitize_filename_truncates_long_names() {
302        let long = "a".repeat(MAX_FILENAME_LEN + 10);
303        assert_eq!(
304            sanitize_filename(&long).len(),
305            MAX_FILENAME_LEN,
306            "names exceeding `MAX_FILENAME_LEN` should be truncated"
307        );
308    }
309
310    #[test]
311    fn sanitize_filename_keeps_exact_max_len() {
312        let exact = "a".repeat(MAX_FILENAME_LEN);
313        assert_eq!(
314            sanitize_filename(&exact).len(),
315            MAX_FILENAME_LEN,
316            "names of exactly `MAX_FILENAME_LEN` chars should not be truncated"
317        );
318    }
319
320    #[test]
321    fn sanitize_filename_short_names_unchanged_length() {
322        let short = "a".repeat(MAX_FILENAME_LEN - 1);
323        assert_eq!(
324            sanitize_filename(&short).len(),
325            MAX_FILENAME_LEN - 1,
326            "names shorter than `MAX_FILENAME_LEN` should retain their length"
327        );
328    }
329}