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#[cfg(unix)]
18const RESERVED_CHARS: &[char] = &['.', '/'];
19#[cfg(windows)]
20const RESERVED_CHARS: &[char] = &['.', '/', '<', '>', ':', '"', '\\', '|', '?', '*'];
21
22const MAX_FILENAME_LEN: usize = 64;
24
25#[derive(Error, Debug)]
27#[non_exhaustive]
28pub enum HaruspexError {
29 #[error(transparent)]
31 DecompileFailed(#[from] IDAError),
32 #[error(transparent)]
34 FileWriteFailed(#[from] std::io::Error),
35}
36
37pub fn run(filepath: &Path) -> anyhow::Result<usize> {
43 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 println!("[-] Processor: {}", idb.processor().long_name());
52 println!("[-] Compiler: {:?}", idb.meta().cc_id());
53 println!("[-] File type: {:?}", idb.meta().filetype());
54 println!();
55
56 anyhow::ensure!(idb.decompiler_available(), "Decompiler is not available");
58
59 let dirpath = filepath.with_extension("dec");
61 prepare_output_dir(&dirpath)?;
62
63 let mut decompiled_count = 0;
64
65 println!();
67 println!("[*] Extracting pseudocode of functions...");
68 println!();
69 for (_id, f) in idb.functions() {
70 if f.flags().contains(FunctionFlags::THUNK) {
72 continue;
73 }
74
75 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 Ok(()) => {
82 println!("{func_name} -> `{}`", output_path.display());
83 decompiled_count += 1;
84 }
85
86 Err(HaruspexError::DecompileFailed(IDAError::HexRays(e)))
88 if e.code() == HexRaysErrorCode::License =>
89 {
90 return Err(e.into());
91 }
92
93 Err(HaruspexError::DecompileFailed(_)) => (),
95
96 Err(e) => return Err(e.into()),
98 }
99 }
100
101 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
117pub fn decompile_to_file(
145 idb: &IDB,
146 func: &Function,
147 filepath: impl AsRef<Path>,
148) -> Result<(), HaruspexError> {
149 let decomp = idb.decompile(func)?;
151 let source = decomp.pseudocode();
152
153 let mut writer = BufWriter::new(File::create(&filepath)?);
156 writer.write_all(source.as_bytes())?;
157 writer.flush()?;
158
159 Ok(())
160}
161
162pub 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#[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#[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 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}