diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8f9ba06 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,46 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + +jobs: + lint: + name: rustfmt + clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + - name: rustfmt + run: cargo fmt --all --check + - name: clippy + run: cargo clippy --all-targets -- -D warnings + + test: + name: test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-14, windows-latest] + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: test + run: cargo test --all-targets + - name: build (release) + run: cargo build --release + - name: smoke + run: cargo run --release --quiet -- --version diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..8743f40 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,49 @@ +name: e2e + +# End-to-end: provision angr via uv and decompile a real binary. Only on PRs, +# since it installs a few hundred MB and takes minutes. +on: + pull_request: + +concurrency: + group: e2e-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + +jobs: + decompile: + name: decompile (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-14, windows-latest] + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Install uv (with cache) + uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + - name: Build + run: cargo build --release + - name: List functions (provisions angr) + shell: bash + run: | + set -euo pipefail + cargo run --release --quiet -- --yes -l tests/fixtures/addsample > list.txt + cat list.txt + grep -E ' main$' list.txt + grep -E ' add$' list.txt + - name: Decompile main + shell: bash + run: | + set -euo pipefail + cargo run --release --quiet -- --yes -f main tests/fixtures/addsample > main.txt + cat main.txt + grep -F '
' main.txt + grep -F 'return' main.txt diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..972fe03 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,32 @@ +name: Publish to crates.io + +# Dormant until a CARGO_REGISTRY_TOKEN secret is configured; then publishes on +# each version tag (in addition to the GitHub Release from release.yml). +on: + push: + tags: ["v*.*.*"] + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: Gate on token + run: | + if [ -z "${CARGO_REGISTRY_TOKEN:-}" ]; then + echo "CARGO_REGISTRY_TOKEN not set; skipping crates.io publish." + echo "SKIP=1" >> "$GITHUB_ENV" + fi + - uses: actions/checkout@v4 + if: env.SKIP != '1' + - uses: dtolnay/rust-toolchain@stable + if: env.SKIP != '1' + - name: cargo publish + if: env.SKIP != '1' + run: cargo publish diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ff828e4 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,52 @@ +name: Release + +on: + push: + tags: ["v*.*.*"] + +permissions: + contents: write + +env: + CARGO_TERM_COLOR: always + +jobs: + # Refuse to release if the tag doesn't match the crate version. + verify-version: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Check tag matches Cargo.toml + run: | + set -euo pipefail + tag="${GITHUB_REF_NAME#v}" + crate="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')" + echo "tag=$tag crate=$crate" + test "$tag" = "$crate" + + upload: + name: ${{ matrix.target }} + needs: verify-version + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-unknown-linux-musl + os: ubuntu-latest + - target: aarch64-unknown-linux-musl + os: ubuntu-latest + - target: aarch64-apple-darwin + os: macos-14 + - target: x86_64-pc-windows-msvc + os: windows-latest + steps: + - uses: actions/checkout@v4 + - name: Upload ${{ matrix.target }} binary + uses: taiki-e/upload-rust-binary-action@v1 + with: + bin: srcdump + target: ${{ matrix.target }} + archive: srcdump-$tag-$target + checksum: sha256 + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index e4fc518..b1787a6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target /x.rec +/core diff --git a/Cargo.toml b/Cargo.toml index cc13d21..9f14007 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,11 @@ edition = "2021" description = "objdump-like tool that displays decompiled code, backed by angr" license = "BSD-2-Clause" readme = "README.md" +repository = "https://github.com/angr/srcdump" +homepage = "https://github.com/angr/srcdump" +keywords = ["decompiler", "angr", "reverse-engineering", "disassembler", "objdump"] +categories = ["command-line-utilities", "development-tools::debugging"] +exclude = ["/.github", "/tests", "/x.rec"] [[bin]] name = "srcdump" diff --git a/src/env.rs b/src/env.rs index 1abe39f..f555173 100644 --- a/src/env.rs +++ b/src/env.rs @@ -35,7 +35,7 @@ pub fn resolve(opts: &EnvOptions) -> Result { return Ok(PathBuf::from(p)); } if let Some(dir) = &opts.venv { - let py = dir.join("bin").join("python"); + let py = venv_python(dir); if !py.is_file() { bail!( "--venv {}: no interpreter at {} (is it a venv with angr installed?)", @@ -65,7 +65,7 @@ fn remove_hint(opts: &EnvOptions, env_dir: &Path) -> String { /// First-run notice shown before uv provisioning, telling the user what srcdump /// is about to download/install and how to undo it. fn print_uv_notice(opts: &EnvOptions, env_dir: &Path, data_dir: &Path) { - let needs_uv = !uv_on_path() && !data_dir.join("bin").join("uv").is_file(); + let needs_uv = !uv_on_path() && !uv_bin(data_dir).is_file(); eprintln!(); eprintln!("srcdump: setting up the angr environment (first run). This will:"); if needs_uv { @@ -145,7 +145,7 @@ fn confirm(question: &str, default_yes: bool) -> Result { fn ensure_managed(opts: &EnvOptions) -> Result { let data_dir = data_dir(); let env_dir = resolve_env_dir(opts); - let python = env_dir.join("bin").join("python"); + let python = venv_python(&env_dir); let archive = resolve_archive(opts)?; let token = match &archive { @@ -222,7 +222,7 @@ fn resolve_archive(opts: &EnvOptions) -> Result> { fn provision_with_uv(opts: &EnvOptions, env_dir: &Path, data_dir: &Path) -> Result<()> { let uv = ensure_uv(data_dir)?; - let python = env_dir.join("bin").join("python"); + let python = venv_python(env_dir); if !python.is_file() { run( @@ -251,7 +251,7 @@ fn ensure_uv(data_dir: &Path) -> Result { if uv_on_path() { return Ok(PathBuf::from("uv")); } - let local = data_dir.join("bin").join("uv"); + let local = uv_bin(data_dir); if local.is_file() { return Ok(local); } @@ -270,19 +270,25 @@ fn uv_on_path() -> bool { fn download_uv(bin_dir: &Path) -> Result { let triple = uv_target_triple()?; - let url = - format!("https://github.com/astral-sh/uv/releases/latest/download/uv-{triple}.tar.gz"); + let ext = if cfg!(windows) { "zip" } else { "tar.gz" }; + let url = format!("https://github.com/astral-sh/uv/releases/latest/download/uv-{triple}.{ext}"); status(&format!("downloading uv ({triple})…")); std::fs::create_dir_all(bin_dir).with_context(|| format!("creating {}", bin_dir.display()))?; let resp = ureq::get(&url) .call() .with_context(|| format!("downloading {url}"))?; - let reader = resp.into_body().into_reader(); + let mut reader = resp.into_body().into_reader(); + let dest = bin_dir.join(uv_exe_name()); + extract_uv(&mut reader, &dest, &url)?; + Ok(dest) +} + +/// Extract the `uv` binary from the release tarball (unix). +#[cfg(not(windows))] +fn extract_uv(reader: &mut impl Read, dest: &Path, url: &str) -> Result<()> { let gz = flate2::read::GzDecoder::new(reader); let mut archive = tar::Archive::new(gz); - - let dest = bin_dir.join("uv"); for entry in archive.entries().context("reading uv archive")? { let mut entry = entry?; let is_uv = entry @@ -291,16 +297,39 @@ fn download_uv(bin_dir: &Path) -> Result { .and_then(|p| p.file_name().map(|n| n == "uv")) .unwrap_or(false); if is_uv { - let mut out = std::fs::File::create(&dest) + let mut out = std::fs::File::create(dest) .with_context(|| format!("writing {}", dest.display()))?; std::io::copy(&mut entry, &mut out)?; - set_executable(&dest)?; - return Ok(dest); + set_executable(dest)?; + return Ok(()); } } bail!("uv binary not found inside {url}"); } +/// Extract `uv.exe` from the release zip (Windows). Zip needs `Seek`, so the +/// (small) archive is buffered. +#[cfg(windows)] +fn extract_uv(reader: &mut impl Read, dest: &Path, url: &str) -> Result<()> { + let mut buf = Vec::new(); + reader.read_to_end(&mut buf)?; + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(buf)).context("reading uv zip")?; + for i in 0..zip.len() { + let mut entry = zip.by_index(i)?; + let is_uv = entry + .enclosed_name() + .and_then(|p| p.file_name().map(|n| n == "uv.exe")) + .unwrap_or(false); + if is_uv { + let mut out = std::fs::File::create(dest) + .with_context(|| format!("writing {}", dest.display()))?; + std::io::copy(&mut entry, &mut out)?; + return Ok(()); + } + } + bail!("uv.exe not found inside {url}"); +} + fn uv_target_triple() -> Result<&'static str> { use std::env::consts::{ARCH, OS}; Ok(match (OS, ARCH) { @@ -308,10 +337,36 @@ fn uv_target_triple() -> Result<&'static str> { ("linux", "aarch64") => "aarch64-unknown-linux-gnu", ("macos", "x86_64") => "x86_64-apple-darwin", ("macos", "aarch64") => "aarch64-apple-darwin", + ("windows", "x86_64") => "x86_64-pc-windows-msvc", + ("windows", "aarch64") => "aarch64-pc-windows-msvc", (os, arch) => bail!("no prebuilt uv for {os}/{arch}; install uv manually and re-run"), }) } +/// Filename of the uv executable for this OS. +fn uv_exe_name() -> &'static str { + if cfg!(windows) { + "uv.exe" + } else { + "uv" + } +} + +/// Interpreter path inside a venv: `bin/python` on unix, `Scripts/python.exe` +/// on Windows. +fn venv_python(dir: &Path) -> PathBuf { + if cfg!(windows) { + dir.join("Scripts").join("python.exe") + } else { + dir.join("bin").join("python") + } +} + +/// Path to the managed `uv` binary under the data dir. +fn uv_bin(data_dir: &Path) -> PathBuf { + data_dir.join("bin").join(uv_exe_name()) +} + // --------------------------------------------------------------------------- // Provisioning from a prebuilt zip archive // --------------------------------------------------------------------------- @@ -342,16 +397,16 @@ fn provision_from_archive(archive: &Path, env_dir: &Path) -> Result<()> { Ok(()) } -/// A venv root is a directory containing `bin/python`, either the extraction +/// A venv root is a directory containing the interpreter, either the extraction /// dir itself or a single top-level subdirectory of it. fn find_venv_root(dir: &Path) -> Option { - if dir.join("bin").join("python").is_file() { + if venv_python(dir).is_file() { return Some(dir.to_path_buf()); } let entries = std::fs::read_dir(dir).ok()?; for entry in entries.flatten() { let p = entry.path(); - if p.is_dir() && p.join("bin").join("python").is_file() { + if p.is_dir() && venv_python(&p).is_file() { return Some(p); } } @@ -524,10 +579,17 @@ impl Flock { #[cfg(not(unix))] impl Flock { - fn acquire(_path: &Path) -> Result { - // Best-effort: no locking on non-unix. - Ok(Self { - _file: std::fs::File::open(std::env::temp_dir()).unwrap(), - }) + fn acquire(path: &Path) -> Result { + // No flock() off-unix; just hold the lock file open (best effort). + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let file = std::fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(false) + .open(path) + .with_context(|| format!("opening lock {}", path.display()))?; + Ok(Self { _file: file }) } } diff --git a/src/highlight.rs b/src/highlight.rs index 0f83474..39af269 100644 --- a/src/highlight.rs +++ b/src/highlight.rs @@ -218,3 +218,52 @@ pub fn ansi_code(kind: TokKind) -> Option<&'static str> { TokKind::Field | TokKind::Var | TokKind::Plain => None, } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::HlSpan; + + fn find(line: &[Tok], text: &str) -> Option { + line.iter().find(|t| t.text == text).map(|t| t.kind) + } + + #[test] + fn lexes_keywords_comments_numbers() { + let lines = highlight_lines("if (x) return 0; // c\n", &[]); + assert_eq!(lines.len(), 1); + let l = &lines[0]; + assert_eq!(find(l, "if"), Some(TokKind::Keyword)); + assert_eq!(find(l, "return"), Some(TokKind::Keyword)); + assert_eq!(find(l, "0"), Some(TokKind::Number)); + assert!(l + .iter() + .any(|t| t.kind == TokKind::Comment && t.text.contains("// c"))); + } + + #[test] + fn string_literal_is_string() { + let lines = highlight_lines("x = \"hi\";", &[]); + assert!(lines[0] + .iter() + .any(|t| t.kind == TokKind::Str && t.text == "\"hi\"")); + } + + #[test] + fn semantic_span_colors_identifier() { + // "Foo" isn't a keyword; the `type` span makes it a type. + let lines = highlight_lines("Foo x;", &[HlSpan(0, 3, "type".into())]); + assert_eq!(find(&lines[0], "Foo"), Some(TokKind::Type)); + } + + #[test] + fn quoted_constant_becomes_string() { + let lines = highlight_lines("\"s\"", &[HlSpan(0, 3, "const".into())]); + assert_eq!(lines[0][0].kind, TokKind::Str); + } + + #[test] + fn splits_into_lines() { + assert_eq!(highlight_lines("a\nb\n", &[]).len(), 2); + } +} diff --git a/src/main.rs b/src/main.rs index 0440eb2..6a67d62 100644 --- a/src/main.rs +++ b/src/main.rs @@ -432,4 +432,36 @@ mod tests { // Unknown available memory -> no cap. assert_eq!(budget_workers(32, None, 3 * gib), 32); } + + fn func(name: &str, addr: u64) -> FunctionInfo { + FunctionInfo { + addr, + name: name.into(), + size: 0, + is_plt: false, + section: None, + text: None, + decompile_ms: None, + spans: Vec::new(), + stale_version: None, + } + } + + #[test] + fn parse_int_hex_and_decimal() { + assert_eq!(parse_int("0x10"), Some(16)); + assert_eq!(parse_int("0X10"), Some(16)); + assert_eq!(parse_int("16"), Some(16)); + assert_eq!(parse_int("main"), None); + } + + #[test] + fn filters_match_name_and_address() { + let f = func("main", 0x401000); + assert!(matches_filters(&f, &[])); + assert!(matches_filters(&f, &["main".into()])); + assert!(matches_filters(&f, &["0x401000".into()])); + assert!(matches_filters(&f, &["4198400".into()])); + assert!(!matches_filters(&f, &["other".into()])); + } } diff --git a/src/model.rs b/src/model.rs index 80852cd..d30b4e8 100644 --- a/src/model.rs +++ b/src/model.rs @@ -152,4 +152,18 @@ mod tests { let d2: DecompCache = serde_json::from_str(r#"{"text":"y"}"#).unwrap(); assert_eq!(d2.angr_version, None); } + + #[test] + fn hlspan_from_array() { + let s: HlSpan = serde_json::from_str("[3, 4, \"type\"]").unwrap(); + assert_eq!((s.0, s.1, s.2.as_str()), (3, 4, "type")); + } + + #[test] + fn decompcache_defaults() { + let d: DecompCache = serde_json::from_str("{\"text\":\"x\"}").unwrap(); + assert_eq!(d.text, "x"); + assert!(d.spans.is_empty()); + assert!(d.decompile_ms.is_none()); + } } diff --git a/src/pool.rs b/src/pool.rs index 22333c3..46df815 100644 --- a/src/pool.rs +++ b/src/pool.rs @@ -59,6 +59,7 @@ impl Cancellation { } } +#[cfg(unix)] fn kill(pid: u32) { // Best effort; a pid that already exited just yields ESRCH. unsafe { @@ -66,6 +67,16 @@ fn kill(pid: u32) { } } +#[cfg(windows)] +fn kill(pid: u32) { + // No fork/signal model on Windows; terminate the process tree via taskkill. + let _ = Command::new("taskkill") + .args(["/F", "/T", "/PID", &pid.to_string()]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); +} + pub struct ProcessPoolBackend { worker_script: PathBuf, } diff --git a/src/sysmem.rs b/src/sysmem.rs index e0247ed..9663532 100644 --- a/src/sysmem.rs +++ b/src/sysmem.rs @@ -23,3 +23,14 @@ pub fn human_bytes(bytes: u64) -> String { format!("{:.0} MiB", bytes as f64 / (1024.0 * 1024.0)) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn human_bytes_units() { + assert_eq!(human_bytes(2 * 1024 * 1024 * 1024), "2.0 GiB"); + assert_eq!(human_bytes(512 * 1024 * 1024), "512 MiB"); + } +} diff --git a/src/tui.rs b/src/tui.rs index ba147e4..97a132c 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -794,7 +794,7 @@ fn render(f: &mut Frame, app: &mut App) { f.render_widget( Paragraph::new( - " j/k line ./, func g goto / search F3/F4 next/prev f panel h help q quit", + " j/k line PgUp/PgDn page ./, function g goto / search F3/F4 next/prev f panel h help q quit", ) .style(Style::default().fg(Color::DarkGray)), footer, diff --git a/src/worker.py b/src/worker.py index 33942b4..abb8d65 100644 --- a/src/worker.py +++ b/src/worker.py @@ -328,7 +328,10 @@ def cfg_progress(percentage, text=None, **kwargs): emit({"kind": "progress", "stage": "save"}) tmp = f"{args.db}.tmp.{os.getpid()}" try: - AngrDB(project=proj).dump(tmp) + # nullpool=True so AngrDB doesn't keep a pooled SQLite connection (and + # thus the file handle) open after dump(); otherwise the rename fails on + # Windows, which can't move a file that's still open. + AngrDB(project=proj, nullpool=True).dump(tmp) os.replace(tmp, args.db) except Exception as exc: try: diff --git a/tests/fixtures/addsample b/tests/fixtures/addsample new file mode 100755 index 0000000..6fe42a9 Binary files /dev/null and b/tests/fixtures/addsample differ