diff --git a/.github/scripts/coverage_comment.py b/.github/scripts/coverage_comment.py new file mode 100644 index 0000000..2bdcf64 --- /dev/null +++ b/.github/scripts/coverage_comment.py @@ -0,0 +1,153 @@ +"""Build a markdown coverage summary from coverage.py Cobertura XML.""" + +from __future__ import annotations + +import argparse +import json +import os +import urllib.error +import urllib.request +import xml.etree.ElementTree as ET +from pathlib import Path + +MARKER = "" +API_VERSION = "2022-11-28" + + +def _pct(rate: str | None) -> float: + try: + return float(rate or 0.0) * 100.0 + except ValueError: + return 0.0 + + +def format_coverage_markdown(xml_path: Path) -> str: + root = ET.parse(xml_path).getroot() + line_pct = _pct(root.get("line-rate")) + branch_pct = _pct(root.get("branch-rate")) + covered = root.get("lines-covered", "?") + valid = root.get("lines-valid", "?") + + files: list[tuple[float, str]] = [] + for cls in root.iter("class"): + name = cls.get("filename") or cls.get("name") or "?" + files.append((_pct(cls.get("line-rate")), name.replace("\\", "/"))) + files.sort(key=lambda item: (item[0], item[1])) + + lines = [ + MARKER, + "## Test coverage", + "", + f"| Metric | Value |", + f"| --- | --- |", + f"| Lines | **{line_pct:.1f}%** ({covered}/{valid}) |", + f"| Branches | **{branch_pct:.1f}%** |", + "", + ] + weak = [row for row in files if row[0] < 80][:15] + if weak: + lines.extend( + [ + "Lowest-coverage files:", + "", + "| File | Lines |", + "| --- | ---: |", + ] + ) + for pct, name in weak: + lines.append(f"| `{name}` | {pct:.1f}% |") + lines.append("") + lines.append("_pytest-cov on ubuntu-latest._") + return "\n".join(lines) + "\n" + + +def _github_json( + token: str, + method: str, + url: str, + payload: dict | None = None, +) -> object: + data = None if payload is None else json.dumps(payload).encode("utf-8") + req = urllib.request.Request( + url, + data=data, + method=method, + headers={ + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "X-GitHub-Api-Version": API_VERSION, + }, + ) + try: + with urllib.request.urlopen(req) as resp: + body = resp.read().decode("utf-8") + except urllib.error.HTTPError as exc: + err = exc.read().decode("utf-8") + raise SystemExit(f"GitHub API {method} {url} failed ({exc.code}): {err}") from exc + return json.loads(body) if body else None + + +def upsert_pr_comment(token: str, repo: str, pr_number: str, body: str) -> None: + """Create or update the sticky coverage comment on a pull request.""" + page = 1 + existing_id = None + while True: + comments = _github_json( + token, + "GET", + f"https://api.github.com/repos/{repo}/issues/{pr_number}/comments?per_page=100&page={page}", + ) + if not isinstance(comments, list) or not comments: + break + for comment in comments: + if MARKER in str(comment.get("body") or ""): + existing_id = comment.get("id") + break + if existing_id or len(comments) < 100: + break + page += 1 + if existing_id: + _github_json( + token, + "PATCH", + f"https://api.github.com/repos/{repo}/issues/comments/{existing_id}", + {"body": body}, + ) + print(f"Updated coverage comment {existing_id}") + return + _github_json( + token, + "POST", + f"https://api.github.com/repos/{repo}/issues/{pr_number}/comments", + {"body": body}, + ) + print("Created coverage comment") + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("xml", type=Path, default=Path("coverage.xml"), nargs="?") + parser.add_argument("-o", "--output", type=Path, default=Path("coverage-comment.md")) + parser.add_argument( + "--post-pr", + action="store_true", + help="Upsert a sticky PR comment (needs GH_TOKEN, GITHUB_REPOSITORY, PR_NUMBER)", + ) + args = parser.parse_args() + if not args.xml.is_file(): + raise SystemExit(f"coverage XML not found: {args.xml}") + text = format_coverage_markdown(args.xml) + args.output.write_text(text, encoding="utf-8") + print(text, end="") + if args.post_pr: + token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN") + repo = os.environ.get("GITHUB_REPOSITORY") + pr_number = os.environ.get("PR_NUMBER") + if not token or not repo or not pr_number: + raise SystemExit("GH_TOKEN, GITHUB_REPOSITORY, and PR_NUMBER are required for --post-pr") + upsert_pr_comment(token, repo, pr_number, text) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 85cd6bd..ac7e43e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,12 +13,16 @@ jobs: tests: name: Tests on ${{ matrix.os }} runs-on: ${{ matrix.os }} + permissions: + contents: read + pull-requests: write strategy: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] steps: + # Org policy: only Codeblin-owned Actions are allowed (no actions/checkout). - name: Check out repository shell: bash run: | @@ -33,7 +37,26 @@ jobs: - name: Run tests shell: bash - run: python -m pytest --cov=droidreaver --cov-report=term-missing --cov-report=xml + run: python -m pytest --cov=droidreaver --cov-report=term-missing --cov-report=xml --cov-report=html + + - name: Write coverage summary + if: always() && matrix.os == 'ubuntu-latest' + shell: bash + run: | + python .github/scripts/coverage_comment.py coverage.xml -o coverage-comment.md + cat coverage-comment.md >> "$GITHUB_STEP_SUMMARY" + + - name: Comment coverage on PR + if: > + always() && + matrix.os == 'ubuntu-latest' && + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + shell: bash + run: python .github/scripts/coverage_comment.py coverage.xml -o coverage-comment.md --post-pr android-tools-smoke: name: Android tools smoke test @@ -241,3 +264,26 @@ jobs: droidreaver --no-banner device list droidreaver --no-banner device ready droidreaver frida-ps -U + + arsenal-smoke: + name: Arsenal install smoke + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Check out repository + shell: bash + run: | + git init . + git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git" + git fetch --depth=1 origin "$GITHUB_SHA" + git checkout --force FETCH_HEAD + + - name: Install package + shell: bash + run: python -m pip install -e ".[dev]" + + - name: Install smoke tools and probe + shell: bash + env: + DROIDREAVER_ARSENAL_INSTALL: gitleaks,adb + run: python -m pytest tests/test_arsenal.py -m arsenal_live -rs --tb=short diff --git a/.gitignore b/.gitignore index 67e3810..890324e 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ pip-wheel-metadata/ .mypy_cache/ .ruff_cache/ .coverage +coverage.xml htmlcov/ .tox/ .nox/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d53001f..865c4ec 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,9 +14,21 @@ python -m pytest Run coverage locally: ```bash -python -m pytest --cov=droidreaver --cov-report=term-missing +python -m pytest --cov=droidreaver --cov-report=term-missing --cov-report=xml --cov-report=html ``` +CI (ubuntu) posts a sticky coverage comment on pull requests and writes the same summary to the Actions job summary. Marketplace actions (`actions/checkout`, `upload-artifact`, …) are not used — org policy allows only Codeblin-owned actions. + +Live arsenal probes use `~/.droidreaver/tools` (gitignored). They skip when a tool is missing. To install a small set first: + +```bash +# Windows CMD +set DROIDREAVER_ARSENAL_INSTALL=gitleaks,adb +.venv\Scripts\python.exe -m pytest tests/test_arsenal.py -m arsenal_live -rs +``` + +`DROIDREAVER_ARSENAL_INSTALL=all` installs github-release and direct-download tools only (not apt/brew/GUI). Do not commit `.droidreaver/`. + ## Development Expectations - Keep changes small and focused. diff --git a/droidreaver/cli.py b/droidreaver/cli.py index 37d4edc..0680b9a 100644 --- a/droidreaver/cli.py +++ b/droidreaver/cli.py @@ -46,6 +46,7 @@ def _harden_stdio() -> None: get_tool_status, get_tool_version, install_tools, + uninstall_tool, warm_version_cache, sync_pin_group, update_pin_group, @@ -328,6 +329,71 @@ def install_cmd(tool: str | None, install_all: bool, ver: str | None, force: boo sys.exit(1) +def _registry_tool_name(name: str) -> str: + try: + return get_tool(name).name + except KeyError: + inv = lookup_invocation(name) + if inv is not None: + return inv.tool.name + raise + + +@main.command("uninstall") +@click.argument("tool") +@click.option("-y", "--yes", "assume_yes", is_flag=True, help="Do not prompt") +@click.option( + "--keep-venv", + is_flag=True, + help="When uninstalling frida, leave the isolated venv on disk", +) +def uninstall_cmd(tool: str, assume_yes: bool, keep_venv: bool) -> None: + """Remove a tool DroidReaver installed (managed dir / Frida venv).""" + print_banner(console, compact=True) + cfg = _load_cfg() + try: + name = _registry_tool_name(tool) + t = get_tool(name) + except KeyError: + console.print(f"[red]Unknown tool:[/] {tool}") + console.print("[dim]See:[/] [cyan]droidreaver arsenal[/]") + sys.exit(1) + + from droidreaver.config import install_dir + + target = install_dir(cfg) / name + console.print( + f"Remove [bold]{t.display_name}[/] ([cyan]{name}[/]) from " + f"[dim]{target}[/]" + ) + if not assume_yes and not click.confirm("Continue?", default=False): + console.print("[dim]Cancelled.[/]") + sys.exit(0) + try: + result = uninstall_tool(name, cfg, remove_venv=not keep_venv) + except InstallError as e: + console.print(f"[red]Error:[/] {e}") + sys.exit(1) + if result.removed: + console.print("[green]Removed:[/]") + for path in result.removed: + console.print(f" • {path}") + for note in result.notes: + console.print(f"[dim]{note}[/]") + if not result.removed: + sys.exit(1) + + +@main.command("remove", hidden=True) +@click.argument("tool") +@click.option("-y", "--yes", "assume_yes", is_flag=True, help="Do not prompt") +@click.option("--keep-venv", is_flag=True) +@click.pass_context +def remove_cmd(ctx: click.Context, tool: str, assume_yes: bool, keep_venv: bool) -> None: + """Alias for uninstall.""" + ctx.invoke(uninstall_cmd, tool=tool, assume_yes=assume_yes, keep_venv=keep_venv) + + @main.command("update") @click.argument("tool", required=False, default=None) @click.option("--all", "update_all", is_flag=True, help="Update all tools") @@ -650,8 +716,8 @@ def arsenal_cmd() -> None: console.print(table) console.print( f"\n[dim]{len(invocations)} dispatchers · " - "invoke directly: [cyan]droidreaver frida --version[/] " - "or: [cyan]droidreaver run objection explore[/]" + "invoke: [cyan]droidreaver frida --version[/] · " + "remove: [cyan]droidreaver uninstall gitleaks -y[/]" ) @@ -1592,7 +1658,7 @@ def _register_tool_runners() -> None: continue seen.add(inv.name) - def make_tool_command(invocation_name: str, display: str): + def make_tool_command(invocation_name: str, help_text: str): @click.command( name=invocation_name, add_help_option=False, @@ -1600,7 +1666,7 @@ def make_tool_command(invocation_name: str, display: str): "ignore_unknown_options": True, "allow_extra_args": True, }, - help=inv.tool.description or f"Run {inv.tool.display_name}", + help=help_text, ) @click.pass_context def _tool_runner(ctx: click.Context) -> None: @@ -1619,7 +1685,10 @@ def _tool_runner(ctx: click.Context) -> None: return _tool_runner - cmd = make_tool_command(inv.name, inv.tool.display_name) + cmd = make_tool_command( + inv.name, + inv.tool.description or f"Run {inv.tool.display_name}", + ) main.add_command(cmd) diff --git a/droidreaver/help_fmt.py b/droidreaver/help_fmt.py index fefd47b..d475e62 100644 --- a/droidreaver/help_fmt.py +++ b/droidreaver/help_fmt.py @@ -2,6 +2,7 @@ from __future__ import annotations +import difflib from collections import defaultdict import click @@ -30,6 +31,7 @@ "doctor", "fix", "install", + "uninstall", "get", "update", "sync", @@ -80,6 +82,25 @@ class DroidReaverGroup(click.Group): """Click group that renders --help in categorized sections.""" + def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None: + cmd = super().get_command(ctx, cmd_name) + if cmd is not None: + return cmd + names = list(self.list_commands(ctx)) + by_lower = {name.lower(): name for name in names} + exact = by_lower.get(cmd_name.lower()) + if exact: + return super().get_command(ctx, exact) + close = difflib.get_close_matches(cmd_name.lower(), list(by_lower), n=1, cutoff=0.85) + if close: + resolved = by_lower[close[0]] + click.echo( + f"No such command '{cmd_name}'. Using '{resolved}'.", + err=True, + ) + return super().get_command(ctx, resolved) + return None + def format_commands(self, ctx: click.Context, formatter: click.HelpFormatter) -> None: by_name: dict[str, click.Command] = {} for name in self.list_commands(ctx): diff --git a/droidreaver/installer.py b/droidreaver/installer.py index 6d55451..5888620 100644 --- a/droidreaver/installer.py +++ b/droidreaver/installer.py @@ -11,6 +11,7 @@ import tarfile import tempfile import zipfile +from dataclasses import dataclass, field from hashlib import sha256 from pathlib import Path from typing import Callable @@ -23,6 +24,7 @@ from droidreaver.registry import Tool, get_pin_group, get_tool, resolve_install_order from droidreaver.utils import ( compare_versions, + detect_cpu_arch, detect_platform, detect_system_package_manager, extract_release_version, @@ -38,6 +40,7 @@ npm_show_version, parse_version, pip_show_version, + PROBE_CMD_TIMEOUT, run_cmd, versions_match, which, @@ -106,6 +109,112 @@ def _tool_has_install_artifacts(tool: Tool, config: dict | None) -> bool: return False +GIT_REF_MARKER = ".droidreaver-ref" + + +def _write_git_ref(tool_dir: Path, ref: str) -> None: + (tool_dir / GIT_REF_MARKER).write_text(ref.strip() + "\n", encoding="utf-8") + + +def _normalize_git_version(ref: str) -> str: + return extract_release_version({"tag_name": ref.strip()}) or ref.strip() + + +def _git_tool_version(tool_dir: Path) -> str | None: + """Version for git/zip clones (Medusa has no --version CLI).""" + if not tool_dir.is_dir(): + return None + recorded = tool_dir / GIT_REF_MARKER + if recorded.is_file(): + label = recorded.read_text(encoding="utf-8").strip() + if label: + return _normalize_git_version(label) + git_exe = find_git() + if git_exe and (tool_dir / ".git").exists(): + described = run_cmd( + [git_exe, "-C", str(tool_dir), "describe", "--tags", "--always"], + timeout=PROBE_CMD_TIMEOUT, + ) + if described.returncode == 0: + label = (described.stdout or "").strip() + if label: + return label + short = run_cmd( + [git_exe, "-C", str(tool_dir), "rev-parse", "--short", "HEAD"], + timeout=PROBE_CMD_TIMEOUT, + ) + if short.returncode == 0: + label = (short.stdout or "").strip() + if label: + return label + marker = tool_dir / ".droidreaver-source" + if marker.is_file(): + kind = marker.read_text(encoding="utf-8").strip() or "clone" + return kind + if (tool_dir / "medusa.py").is_file() or (tool_dir / "bin").is_dir(): + return "installed" + return None + + +def _resolve_git_ref(tool: Tool, version: str | None = None) -> str: + """GitHub release tag shown as Latest, else the default branch.""" + if version: + return version.strip() + if not tool.github_repo: + return "master" + try: + release = get_latest_github_release(tool.github_repo) + tag = str(release.get("tag_name") or "").strip() + if tag: + return tag + except requests.RequestException: + pass + try: + return get_github_default_branch(tool.github_repo) + except requests.RequestException: + return "master" + + +def _git_ref_candidates(ref: str) -> list[str]: + refs = [ref] + if ref.startswith("v") and len(ref) > 1: + refs.append(ref[1:]) + elif ref and ref[0].isdigit(): + refs.insert(0, f"v{ref}") + seen: set[str] = set() + out: list[str] = [] + for item in refs: + if item not in seen: + seen.add(item) + out.append(item) + return out + + +def _git_fetch_and_checkout(git_exe: str, inst: Path, ref: str) -> bool: + """Move a shallow clone onto a tag/branch (git pull does not do this).""" + specs = [] + for candidate in _git_ref_candidates(ref): + specs.extend( + ( + candidate, + f"refs/tags/{candidate}", + f"refs/heads/{candidate}", + ) + ) + for spec in specs: + fetched = run_cmd( + [git_exe, "-C", str(inst), "fetch", "--depth", "1", "origin", spec] + ) + if fetched.returncode != 0: + continue + checked = run_cmd( + [git_exe, "-C", str(inst), "checkout", "-f", "--detach", "FETCH_HEAD"] + ) + if checked.returncode == 0: + return True + return False + + def get_tool_version(tool: Tool, config: dict | None = None) -> str | None: """Get currently installed version for a tool.""" if tool.install_method == "manual": @@ -121,6 +230,11 @@ def get_tool_version(tool: Tool, config: dict | None = None) -> str | None: if ver: return ver + if tool.install_method == "git" and config is not None: + git_ver = _git_tool_version(install_dir(config) / tool.name) + if git_ver: + return git_ver + if tool.version_cmd or tool.binary_name: if not _tool_has_install_artifacts(tool, config): return None @@ -313,14 +427,32 @@ def _finalize_python_repo_install(tool: Tool, inst: Path) -> None: ) -def _download_github_repo_zip(tool: Tool, inst: Path) -> None: - """Fetch repo default branch as a zip (no git required).""" - branch = get_github_default_branch(tool.github_repo or "") - url = f"https://codeload.github.com/{tool.github_repo}/zip/refs/heads/{branch}" +def _download_github_repo_zip(tool: Tool, inst: Path, ref: str | None = None) -> None: + """Fetch a GitHub tag or branch as a zip (no git required).""" + if not tool.github_repo: + raise InstallError(f"No git repo defined for {tool.name}") + target = ref or get_github_default_branch(tool.github_repo) + urls: list[str] = [] + for candidate in _git_ref_candidates(target): + urls.append(f"https://codeload.github.com/{tool.github_repo}/zip/refs/tags/{candidate}") + urls.append(f"https://codeload.github.com/{tool.github_repo}/zip/refs/heads/{candidate}") + last_error = "download failed" with tempfile.TemporaryDirectory() as tmp: tmp_path = Path(tmp) zip_path = tmp_path / "source.zip" - _download_file(url, zip_path) + downloaded = False + for url in urls: + try: + _download_file(url, zip_path) + downloaded = True + break + except requests.RequestException as e: + last_error = str(e) + continue + if not downloaded: + raise InstallError( + f"Could not download {tool.display_name} source for {target}: {last_error}" + ) extract_root = tmp_path / "extract" extract_root.mkdir() with zipfile.ZipFile(zip_path) as zf: @@ -335,10 +467,36 @@ def _download_github_repo_zip(tool: Tool, inst: Path) -> None: shutil.rmtree(inst) shutil.move(str(children[0]), str(inst)) (inst / ".droidreaver-source").write_text("zip\n", encoding="utf-8") + _write_git_ref(inst, target) + + +def _clone_github_ref(git_exe: str, tool: Tool, inst: Path, ref: str) -> None: + repo_url = f"https://github.com/{tool.github_repo}.git" + last_err = "git clone failed" + for candidate in _git_ref_candidates(ref): + if inst.exists(): + shutil.rmtree(inst) + inst.parent.mkdir(parents=True, exist_ok=True) + result = run_cmd( + [git_exe, "clone", "--depth", "1", "--branch", candidate, repo_url, str(inst)] + ) + if result.returncode == 0: + (inst / ".droidreaver-source").write_text("git\n", encoding="utf-8") + _write_git_ref(inst, candidate) + return + last_err = (result.stderr or result.stdout or "git clone failed").strip() + raise InstallError(f"git clone failed for {tool.name} ({ref}):\n{last_err}") -def _install_git_clone(tool: Tool, cfg: dict, *, force: bool = False) -> None: - """Clone or download a GitHub repo into install_dir and create a bin wrapper.""" +def _install_git_clone( + tool: Tool, + cfg: dict, + *, + force: bool = False, + refresh: bool = False, + version: str | None = None, +) -> None: + """Clone or download a GitHub repo at the Latest release tag (or default branch).""" if not tool.github_repo: raise InstallError(f"No git repo defined for {tool.name}") @@ -353,31 +511,32 @@ def _install_git_clone(tool: Tool, cfg: dict, *, force: bool = False) -> None: or (inst / ".git").is_dir() or (inst / ".droidreaver-source").is_file() ) + ref = _resolve_git_ref(tool, version) if (inst / ".git").is_dir() and git_exe and not force: - result = run_cmd([git_exe, "-C", str(inst), "pull", "--ff-only"]) - if result.returncode != 0: - console.print("[yellow]git pull failed; continuing with existing clone[/]") - _finalize_python_repo_install(tool, inst) - return + if _git_fetch_and_checkout(git_exe, inst, ref): + _write_git_ref(inst, ref) + _finalize_python_repo_install(tool, inst) + return + if not refresh: + console.print( + f"[yellow]Could not fetch {ref}; continuing with existing {tool.display_name} clone[/]" + ) + _finalize_python_repo_install(tool, inst) + return + console.print(f"[yellow]Fetch of {ref} failed — recloning {tool.display_name}[/]") - if already_installed and not force: + if already_installed and not force and not refresh: if tool.name == "medusa": _write_medusa_launcher(inst, bin_name) console.print(f"[dim]{tool.display_name} already installed at {inst}[/]") return - if inst.exists() and force: + if inst.exists() and (force or refresh): shutil.rmtree(inst) if git_exe: - inst.parent.mkdir(parents=True, exist_ok=True) - repo_url = f"https://github.com/{tool.github_repo}.git" - result = run_cmd([git_exe, "clone", "--depth", "1", repo_url, str(inst)]) - if result.returncode != 0: - err = (result.stderr or result.stdout or "git clone failed").strip() - raise InstallError(f"git clone failed for {tool.name}:\n{err}") - (inst / ".droidreaver-source").write_text("git\n", encoding="utf-8") + _clone_github_ref(git_exe, tool, inst, ref) else: console.print( "[yellow]git not found — downloading GitHub source zip instead.[/]" @@ -387,7 +546,7 @@ def _install_git_clone(tool: Tool, cfg: dict, *, force: bool = False) -> None: "https://git-scm.com/download/win[/]" ) inst.parent.mkdir(parents=True, exist_ok=True) - _download_github_repo_zip(tool, inst) + _download_github_repo_zip(tool, inst, ref=ref) _finalize_python_repo_install(tool, inst) @@ -488,6 +647,49 @@ def _download_file( return digest +def _os_tokens(plat: str) -> tuple[str, ...]: + if plat == "windows": + return ("windows", "win64", "win32", "_win") + if plat == "macos": + return ("darwin", "macos", "osx", "mac") + return ("linux", "ubuntu") + + +def _arch_tokens(arch: str) -> tuple[str, ...]: + if arch == "x64": + return ("x64", "amd64", "x86_64", "win64") + if arch == "arm64": + return ("arm64", "aarch64") + if arch == "x86": + return ("x86", "i386", "i686", "win32", "x32", "386") + return (arch,) + + +def _arch_conflicts(arch: str, name: str) -> bool: + """True when the asset is clearly built for a different CPU.""" + if arch == "x64" and any(tok in name for tok in ("arm64", "aarch64", "armv", "_x32", "i386", "386")): + return True + if arch == "arm64" and any(tok in name for tok in ("x64", "amd64", "x86_64", "_x32", "i386")): + return True + if arch == "x86" and any(tok in name for tok in ("arm64", "aarch64", "x64", "amd64", "x86_64")): + return True + return False + + +def _asset_score(asset_name: str, plat: str, arch: str) -> int: + n = asset_name.lower() + archive = n.endswith((".zip", ".tar.gz", ".tgz", ".jar")) + if not archive: + return -1 + if not any(tok in n for tok in _os_tokens(plat)): + return -1 + if _arch_conflicts(arch, n): + return -1 + if any(tok in n for tok in _arch_tokens(arch)): + return 3 + return 1 + + def _find_asset(release: dict, pattern: str | None, version: str) -> dict | None: assets = release.get("assets", []) if pattern: @@ -495,22 +697,18 @@ def _find_asset(release: dict, pattern: str | None, version: str) -> dict | None for asset in assets: if asset["name"] == name or name in asset["name"]: return asset - # Heuristic: first zip/tar for platform plat = detect_platform() + arch = detect_cpu_arch() + ranked: list[tuple[int, dict]] = [] for asset in assets: - n = asset["name"].lower() - if plat == "windows" and n.endswith(".zip") and "win" in n: - return asset - if plat == "macos" and ("mac" in n or "darwin" in n) and ( - n.endswith(".zip") or n.endswith(".tar.gz") - ): - return asset - if plat == "linux" and ("linux" in n or "ubuntu" in n) and ( - n.endswith(".zip") or n.endswith(".tar.gz") - ): - return asset + score = _asset_score(str(asset.get("name", "")), plat, arch) + if score > 0: + ranked.append((score, asset)) + if ranked: + ranked.sort(key=lambda item: item[0], reverse=True) + return ranked[0][1] for asset in assets: - n = asset["name"].lower() + n = str(asset.get("name", "")).lower() if n.endswith(".zip") or n.endswith(".tar.gz") or n.endswith(".jar"): return asset return None @@ -877,7 +1075,7 @@ def install_tool( return if tool.install_method == "git": - _install_git_clone(tool, cfg, force=force) + _install_git_clone(tool, cfg, force=force, version=target_version) return if tool.install_method in ("apt", "brew"): @@ -887,6 +1085,105 @@ def install_tool( raise InstallError(f"Unsupported install method '{tool.install_method}' for {tool.name}") +def _rmtree(path: Path) -> None: + if not path.exists(): + return + + def _retry(func, p, _exc) -> None: + os.chmod(p, stat.S_IWRITE) + func(p) + + shutil.rmtree(path, onerror=_retry) + + +def _managed_pythons(cfg: dict) -> list[str]: + from droidreaver.versions import frida_venv_path, get_active_frida_version + + pythons: list[str] = [] + ver = get_active_frida_version(cfg) + if ver: + venv = frida_venv_path(ver) + for rel in ("Scripts/python.exe", "bin/python"): + candidate = venv / rel + if candidate.is_file(): + pythons.append(str(candidate)) + break + pythons.append(sys.executable) + # Preserve order, drop dupes + seen: set[str] = set() + unique: list[str] = [] + for py in pythons: + if py not in seen: + seen.add(py) + unique.append(py) + return unique + + +@dataclass +class UninstallResult: + tool: str + removed: list[str] = field(default_factory=list) + notes: list[str] = field(default_factory=list) + + +def uninstall_tool( + tool_name: str, + cfg: dict, + *, + remove_venv: bool = True, +) -> UninstallResult: + """Remove a DroidReaver-managed install. Does not apt/brew uninstall.""" + tool = get_tool(tool_name) + result = UninstallResult(tool=tool.name) + tool_dir = install_dir(cfg) / tool.name + if tool_dir.exists(): + _rmtree(tool_dir) + result.removed.append(str(tool_dir)) + + if tool.name == "frida" and remove_venv: + from droidreaver.versions import frida_venv_path, get_active_frida_version + + ver = get_active_frida_version(cfg) + if ver: + venv = frida_venv_path(ver) + if venv.exists(): + _rmtree(venv) + result.removed.append(str(venv)) + result.notes.append( + f"Frida pin/active in config is unchanged ({ver}). " + "Clear with: droidreaver unpin frida" + ) + + if tool.install_method == "pip" and tool.pip_package and tool.name != "frida": + from droidreaver.utils import run_cmd + + for py in _managed_pythons(cfg): + cmd = [py, "-m", "pip", "uninstall", "-y", tool.pip_package] + pip_result = run_cmd(cmd) + if pip_result.returncode == 0: + result.notes.append(f"pip uninstall {tool.pip_package} ({py})") + + if tool.install_method in ("apt", "brew"): + result.notes.append( + f"System package not removed. Uninstall with apt/brew if you installed " + f"{tool.display_name} that way." + ) + if tool.install_method == "manual": + result.notes.append( + f"{tool.display_name} is a manual install — nothing for DroidReaver to delete." + ) + if tool.install_method == "npm" and tool.npm_package: + result.notes.append( + f"Global npm package not removed. Run: npm uninstall -g {tool.npm_package}" + ) + + if not result.removed and not result.notes: + result.notes.append( + f"No managed files found for {tool.display_name} under {install_dir(cfg)}." + ) + return result + + def check_pin_group_conflict( tool_name: str, target_version: str, @@ -1014,14 +1311,18 @@ def update_tool( ) -> tuple[str | None, str | None]: """Update tool; returns (old_version, new_version).""" tool = get_tool(tool_name) - old = get_tool_version(tool) + old = get_tool_version(tool, cfg) + if tool.install_method == "git": + _install_git_clone(tool, cfg, force=force, refresh=True, version=None) + new = get_tool_version(tool, cfg) + return old, new latest = get_latest_version(tool) if not latest: raise InstallError(f"Could not determine latest version for {tool.name}") if old and compare_versions(old, latest) == 0: return old, old install_tool(tool_name, cfg, version=latest, force=force) - new = get_tool_version(tool) or latest + new = get_tool_version(tool, cfg) or latest return old, new diff --git a/droidreaver/registry.py b/droidreaver/registry.py index 164fb64..139cc11 100644 --- a/droidreaver/registry.py +++ b/droidreaver/registry.py @@ -154,7 +154,6 @@ def _register(tool: Tool) -> None: binary_name="medusa", version_cmd="medusa --version", depends_on=("frida",), - pin_with=_frida_pins(), description="Dynamic analysis and hooking framework", notes=( "Cloned from GitHub + pip requirements. Git optional (zip fallback). " diff --git a/droidreaver/runtime.py b/droidreaver/runtime.py index 9372d93..d18abe2 100644 --- a/droidreaver/runtime.py +++ b/droidreaver/runtime.py @@ -24,6 +24,8 @@ { "status", "install", + "uninstall", + "remove", "update", "pin", "unpin", @@ -106,15 +108,30 @@ def _tool_install_bin(config: dict, tool_name: str) -> Path: return install_dir(config) / tool_name / "bin" +def _is_dir(path: Path) -> bool: + try: + return path.is_dir() + except OSError: + return False + + +def _is_file(path: Path) -> bool: + try: + return path.is_file() + except OSError: + return False + + def _find_binary_in_dir(directory: Path, name: str) -> Path | None: - if not directory.is_dir(): + """Look up a binary; skip PATH entries Windows will not let us stat.""" + if not _is_dir(directory): return None candidates = [name] if detect_platform() == "windows": candidates.extend([f"{name}.exe", f"{name}.bat", f"{name}.cmd"]) for c in candidates: p = directory / c - if p.is_file(): + if _is_file(p): return p return None @@ -126,9 +143,9 @@ def frida_venv_bin(config: dict) -> Path | None: venv = frida_venv_path(ver) if detect_platform() == "windows": scripts = venv / "Scripts" - return scripts if scripts.is_dir() else None + return scripts if _is_dir(scripts) else None bindir = venv / "bin" - return bindir if bindir.is_dir() else None + return bindir if _is_dir(bindir) else None def collect_tool_bin_paths(config: dict) -> list[Path]: @@ -139,17 +156,21 @@ def collect_tool_bin_paths(config: dict) -> list[Path]: paths.append(venv_bin) inst = install_dir(config) - if inst.is_dir(): - for entry in inst.iterdir(): - if not entry.is_dir(): + if _is_dir(inst): + try: + entries = list(inst.iterdir()) + except OSError: + entries = [] + for entry in entries: + if not _is_dir(entry): continue bin_d = entry / "bin" - if bin_d.is_dir(): + if _is_dir(bin_d): paths.append(bin_d) # Global shim dir for future use shim = Path.home() / ".droidreaver" / "bin" - if shim.is_dir(): + if _is_dir(shim): paths.append(shim) return paths diff --git a/droidreaver/utils.py b/droidreaver/utils.py index 41e6200..226d383 100644 --- a/droidreaver/utils.py +++ b/droidreaver/utils.py @@ -5,6 +5,7 @@ import hashlib import json import os +import platform import re import shutil import subprocess @@ -398,6 +399,18 @@ def detect_platform() -> str: return "linux" +def detect_cpu_arch() -> str: + """Host CPU family used to pick GitHub release assets (x64 / arm64 / x86).""" + machine = platform.machine().lower() + if machine in {"amd64", "x86_64", "x64"}: + return "x64" + if machine in {"arm64", "aarch64"}: + return "arm64" + if machine in {"x86", "i386", "i686", "i586"}: + return "x86" + return machine or "x64" + + def detect_system_package_manager() -> str | None: if shutil.which("brew"): return "brew" diff --git a/droidreaver/versions.py b/droidreaver/versions.py index 07e6c41..773230c 100644 --- a/droidreaver/versions.py +++ b/droidreaver/versions.py @@ -340,7 +340,7 @@ def print_versions_report(config: dict) -> None: tool = get_tool(name) if tool.install_method == "manual": continue - ver = get_tool_version(tool) + ver = get_tool_version(tool, config) note = "" if name in FRIDA_EXACT: note = "must match active frida runtime" diff --git a/pyproject.toml b/pyproject.toml index 9c49816..8f4f75f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,11 +51,16 @@ Changelog = "https://github.com/Codeblin/droidreaver/blob/main/CHANGELOG.md" [tool.coverage.run] source = ["droidreaver"] branch = true +relative_files = true +omit = ["*/tests/*"] [tool.coverage.report] show_missing = true skip_covered = false +[tool.coverage.xml] +output = "coverage.xml" + [tool.setuptools.packages.find] where = ["."] include = ["droidreaver*"] @@ -66,3 +71,6 @@ droidreaver = ["../droidreaver.toml.default", "scripts/*.js", "banner_art.txt"] [tool.pytest.ini_options] testpaths = ["tests"] pythonpath = ["."] +markers = [ + "arsenal_live: probe real arsenal binaries from ~/.droidreaver/tools", +] diff --git a/tests/test_arsenal.py b/tests/test_arsenal.py new file mode 100644 index 0000000..d5f3593 --- /dev/null +++ b/tests/test_arsenal.py @@ -0,0 +1,440 @@ +"""Inventory and dispatch tests for every arsenal tool. + +Structural tests always run. Live probes skip when the binary is not installed +so CI stays green and a local `pytest tests/test_arsenal.py -rs` lists gaps. +""" + +from __future__ import annotations + +import os +import subprocess +from pathlib import Path +from unittest.mock import patch + +import pytest +from click.testing import CliRunner + +from droidreaver.cli import main +from droidreaver.config import DEFAULT_CONFIG_NAME, META_KEY +from droidreaver.help_fmt import CORE_SECTIONS +from droidreaver.registry import TOOLS, Tool, list_tools +from droidreaver.runtime import ( + INVOCATION_META, + RESERVED_COMMANDS, + RunError, + format_arsenal_row, + list_runnable_tools, + lookup_invocation, + prepare_dispatch_args, + resolve_executable, + run_invocation, +) +from droidreaver.utils import PROBE_CMD_TIMEOUT + +INVOCATIONS = list_runnable_tools() +INVOCATION_IDS = [inv.name for inv in INVOCATIONS] +TOOLS_LIST = list_tools() +TOOL_IDS = [tool.name for tool in TOOLS_LIST] + +# Manual GUI / commercial packages: listed in the catalog, not dispatched. +MANUAL_NO_CLI = frozenset( + name + for name, tool in TOOLS.items() + if tool.install_method == "manual" and not tool.binary_name +) + +# Registry entries that currently have no version_cmd. Shrink this set when +# a tool gains a real probe — do not add new names without a reason. +KNOWN_NO_VERSION_CMD = frozenset( + { + "apksigner", + "burp-suite", + "drozer", + "enjarify", + "ghidra", + "httptoolkit", + "mobsf", + "pidcat", + "sqlitebrowser", + "uber-apk-signer", + "wireshark", + "zipalign", + } +) + +# Do not spawn these even if installed (GUI / long-running servers). +LIVE_SPAWN_SKIP = frozenset( + { + "fastboot", # `fastboot version` waits on USB + "mitmproxy", + "mitmweb", + "mobsf", + "scrcpy", + "sqlitebrowser", + } +) + +# Safe to fetch in CI / DROIDREAVER_ARSENAL_INSTALL=all (no apt, brew, pip, git, GUI). +AUTO_INSTALL_METHODS = frozenset({"github_release", "direct"}) + +INSTALL_METHOD_REQUIREMENTS: dict[str, tuple[str, ...]] = { + "pip": ("pip_package",), + "npm": ("npm_package",), + "github_release": ("github_repo",), + "git": ("github_repo",), + "direct": ("download_url_template",), + "apt": ("apt_package",), + "brew": ("brew_package",), + "manual": (), +} + + +def _home_cfg() -> dict: + """Product cache: ~/.droidreaver/tools (never committed).""" + root = Path.home() / ".droidreaver" + return { + "settings": {"install_dir": str(root / "tools")}, + "pins": {}, + "ignored": {"tools": []}, + "active": {}, + META_KEY: {"root": str(root), "path": str(root / DEFAULT_CONFIG_NAME)}, + } + + +def parse_arsenal_install_spec(raw: str | None = None) -> set[str] | None: + """Tool names to install before live probes. + + None — do not install (skip missing). + Env ``DROIDREAVER_ARSENAL_INSTALL``: ``gitleaks,adb`` or ``all``. + """ + spec = (os.environ.get("DROIDREAVER_ARSENAL_INSTALL", "") if raw is None else raw).strip() + if not spec: + return None + if spec.lower() in {"1", "true", "all", "*"}: + return { + tool.name + for tool in TOOLS.values() + if tool.install_method in AUTO_INSTALL_METHODS + } + return {part.strip() for part in spec.split(",") if part.strip()} + + +def _cfg(root: Path) -> dict: + return { + "settings": {"install_dir": ".droidreaver/tools"}, + "pins": {}, + "ignored": {"tools": []}, + "active": {}, + META_KEY: {"root": str(root), "path": str(root / "droidreaver.toml")}, + } + + +def _probe_args(inv) -> list[str]: + cmd = inv.tool.version_cmd + if cmd: + parts = cmd.split() + return parts[1:] if len(parts) > 1 else ["--help"] + return ["--help"] + + +# --- Catalog integrity ------------------------------------------------------- + + +def test_arsenal_is_not_empty(): + assert INVOCATIONS, "arsenal produced no runnable invocations" + + +def test_every_dispatchable_tool_has_an_invocation(): + missing = [] + for tool in TOOLS_LIST: + if tool.name in MANUAL_NO_CLI: + continue + if lookup_invocation(tool.name) is None and ( + not tool.binary_name or lookup_invocation(tool.binary_name) is None + ): + missing.append(tool.name) + assert missing == [], f"tools missing from arsenal: {missing}" + + +def test_manual_gui_tools_are_not_in_arsenal(): + names = {inv.name for inv in INVOCATIONS} + leaked = sorted(name for name in MANUAL_NO_CLI if name in names) + assert leaked == [], f"manual GUI tools should not be dispatched: {leaked}" + + +def test_arsenal_names_do_not_shadow_core_commands(): + names = {inv.name for inv in INVOCATIONS} + overlap = sorted(names & RESERVED_COMMANDS) + assert overlap == [], f"arsenal names collide with core CLI: {overlap}" + + +def test_invocation_names_are_unique(): + seen: dict[str, str] = {} + dupes = [] + for inv in INVOCATIONS: + key = inv.name.lower() + if key in seen: + dupes.append(f"{inv.name} ({inv.tool.name} vs {seen[key]})") + seen[key] = inv.tool.name + assert dupes == [], f"duplicate arsenal names: {dupes}" + + +@pytest.mark.parametrize("inv", INVOCATIONS, ids=INVOCATION_IDS) +def test_lookup_roundtrip(inv): + found = lookup_invocation(inv.name) + assert found is not None + assert found.name == inv.name + assert found.tool.name == inv.tool.name + assert found.executable + assert found.executable == inv.executable + + +@pytest.mark.parametrize("inv", INVOCATIONS, ids=INVOCATION_IDS) +def test_invocation_points_at_registered_tool(inv): + assert inv.tool.name in TOOLS + assert TOOLS[inv.tool.name] is inv.tool or TOOLS[inv.tool.name].name == inv.tool.name + + +@pytest.mark.parametrize("tool", TOOLS_LIST, ids=TOOL_IDS) +def test_tool_has_required_install_fields(tool: Tool): + assert tool.install_method in INSTALL_METHOD_REQUIREMENTS, ( + f"{tool.name}: unknown install_method {tool.install_method!r}" + ) + missing = [ + field + for field in INSTALL_METHOD_REQUIREMENTS[tool.install_method] + if not getattr(tool, field) + ] + assert missing == [], f"{tool.name}: missing {missing} for {tool.install_method}" + if tool.install_method == "brew": + assert tool.brew_package + if tool.install_method == "manual": + assert tool.manual_url or tool.binary_name, ( + f"{tool.name}: manual tool needs manual_url or binary_name" + ) + + +@pytest.mark.parametrize("tool", TOOLS_LIST, ids=TOOL_IDS) +def test_version_cmd_gaps_are_explicit(tool: Tool): + if tool.version_cmd: + assert tool.name not in KNOWN_NO_VERSION_CMD, ( + f"{tool.name} now has version_cmd; remove it from KNOWN_NO_VERSION_CMD" + ) + return + assert tool.name in KNOWN_NO_VERSION_CMD, ( + f"{tool.name} has no version_cmd — add a probe or list it in KNOWN_NO_VERSION_CMD" + ) + + +@pytest.mark.parametrize("inv", INVOCATIONS, ids=INVOCATION_IDS) +def test_meta_requires_args_implies_usage_hint(inv): + meta = INVOCATION_META.get(inv.name, {}) + if meta.get("requires_args"): + assert inv.requires_args + assert inv.usage_hint, f"{inv.name} requires args but has no usage_hint" + + +@pytest.mark.parametrize("inv", INVOCATIONS, ids=INVOCATION_IDS) +def test_prepare_dispatch_args_never_drops_user_args(inv): + args = ["--sentinel", "value"] + assert prepare_dispatch_args(inv, args) == args + + +@pytest.mark.parametrize("inv", INVOCATIONS, ids=INVOCATION_IDS) +def test_prepare_dispatch_empty_args(inv): + forwarded = prepare_dispatch_args(inv, []) + if inv.requires_args: + assert forwarded == ["--help"] + else: + assert forwarded == [] + + +# --- Click wiring ------------------------------------------------------------ + + +def _cli_context(): + return main.make_context("droidreaver", [], resilient_parsing=True) + + +@pytest.mark.parametrize("inv", INVOCATIONS, ids=INVOCATION_IDS) +def test_click_registers_every_arsenal_command(inv): + cmd = main.get_command(_cli_context(), inv.name) + assert cmd is not None, f"{inv.name} is not a droidreaver subcommand" + expected = inv.tool.description or f"Run {inv.tool.display_name}" + assert cmd.help == expected, ( + f"{inv.name} help is {cmd.help!r}, expected {expected!r} " + "(late-binding closure in _register_tool_runners?)" + ) + + +@pytest.mark.parametrize("section, names", CORE_SECTIONS) +def test_core_help_sections_exist(section, names): + for name in names: + assert main.get_command(_cli_context(), name) is not None, ( + f"core command {name!r} from section {section!r} is missing" + ) + + +@pytest.mark.parametrize("inv", INVOCATIONS, ids=INVOCATION_IDS) +def test_cli_forwards_args_to_run_invocation(inv): + runner = CliRunner() + with patch("droidreaver.cli.ensure_config"), patch( + "droidreaver.cli.load_config", return_value={} + ), patch("droidreaver.cli.run_invocation", return_value=0) as run: + result = runner.invoke(main, [inv.name, "--help", "--flag", "x"]) + assert result.exit_code == 0, result.output + run.assert_called_once() + assert run.call_args[0][0] == inv.name + assert run.call_args[0][1] == ["--help", "--flag", "x"] + + +@pytest.mark.parametrize("inv", INVOCATIONS, ids=INVOCATION_IDS) +def test_run_subcommand_forwards_every_tool(inv): + runner = CliRunner() + with patch("droidreaver.cli._load_cfg", return_value={}), patch( + "droidreaver.cli.run_invocation", return_value=0 + ) as run: + result = runner.invoke(main, ["run", inv.name, "--version"]) + assert result.exit_code == 0, result.output + run.assert_called_once_with(inv.name, ["--version"], {}) + + +def test_run_unknown_tool_exits_nonzero(): + runner = CliRunner() + with patch("droidreaver.cli._load_cfg", return_value={}): + result = runner.invoke(main, ["run", "not-a-real-tool-xyz"]) + assert result.exit_code != 0 + assert "Unknown" in result.output or "not-a-real-tool-xyz" in result.output + + +def test_arsenal_lists_every_invocation(tmp_path): + runner = CliRunner() + cfg = _cfg(tmp_path) + with patch("droidreaver.cli._load_cfg", return_value=cfg), patch( + "droidreaver.cli.print_banner" + ): + result = runner.invoke(main, ["arsenal"]) + assert result.exit_code == 0, result.output + missing = [inv.name for inv in INVOCATIONS if inv.name not in result.output] + assert missing == [], f"arsenal table missing: {missing}" + + +@pytest.mark.parametrize("tool", TOOLS_LIST, ids=TOOL_IDS) +def test_info_lists_every_registry_tool(tool: Tool): + runner = CliRunner() + with patch("droidreaver.cli.print_banner"), patch( + "droidreaver.cli.get_latest_version", return_value=None + ), patch("droidreaver.cli.get_tool_version", return_value=None), patch( + "droidreaver.cli._load_cfg", return_value={"pins": {}} + ), patch("droidreaver.cli.work_spinner") as spinner: + spinner.return_value.__enter__.return_value = None + spinner.return_value.__exit__.return_value = None + result = runner.invoke(main, ["info", tool.name]) + assert result.exit_code == 0, result.output + assert tool.display_name in result.output or tool.name in result.output + assert tool.install_method in result.output + + +@pytest.mark.parametrize("inv", INVOCATIONS, ids=INVOCATION_IDS) +def test_arsenal_row_missing_when_unresolvable(inv, tmp_path): + cfg = _cfg(tmp_path) + with patch("droidreaver.runtime.resolve_executable", side_effect=RunError("missing")): + name, tool_name, status = format_arsenal_row(inv, cfg) + assert name == inv.name + assert tool_name == inv.tool.name + assert "missing" in status + + +@pytest.mark.parametrize("inv", INVOCATIONS, ids=INVOCATION_IDS) +def test_arsenal_row_ready_when_resolvable(inv, tmp_path): + cfg = _cfg(tmp_path) + with patch("droidreaver.runtime.resolve_executable", return_value=["/bin/true"]): + name, tool_name, status = format_arsenal_row(inv, cfg) + assert name == inv.name + assert tool_name == inv.tool.name + assert "ready" in status + + +def test_run_invocation_unknown_rejected(): + with pytest.raises(RunError, match="Unknown"): + run_invocation("not-a-real-tool-xyz", [], {}) + + +@pytest.mark.parametrize("inv", INVOCATIONS, ids=INVOCATION_IDS) +def test_run_invocation_builds_argv(inv, tmp_path): + cfg = _cfg(tmp_path) + with patch( + "droidreaver.runtime.resolve_executable", return_value=["tool-bin"] + ) as resolve, patch("droidreaver.runtime.subprocess.run") as run: + run.return_value.returncode = 0 + code = run_invocation(inv.name, ["--flag"], cfg, passthrough=True) + assert code == 0 + resolve.assert_called_once() + argv = run.call_args[0][0] + assert argv[0] == "tool-bin" + assert argv[-1] == "--flag" + + +# --- Live probes (real ~/.droidreaver/tools cache; skip if missing) ---------- + + +def test_parse_arsenal_install_spec_empty(monkeypatch): + monkeypatch.delenv("DROIDREAVER_ARSENAL_INSTALL", raising=False) + assert parse_arsenal_install_spec() is None + assert parse_arsenal_install_spec("") is None + assert parse_arsenal_install_spec("gitleaks, adb") == {"gitleaks", "adb"} + assert "gitleaks" in parse_arsenal_install_spec("all") + assert "adb" in parse_arsenal_install_spec("all") + assert "nmap" not in parse_arsenal_install_spec("all") + + +@pytest.fixture(scope="session") +def arsenal_live_cfg(): + """Shared DroidReaver home install dir; optional pre-install via env.""" + cfg = _home_cfg() + wanted = parse_arsenal_install_spec() + if wanted: + from droidreaver.installer import install_tools + + install_tools(sorted(wanted), cfg, continue_on_error=True) + return cfg + + +@pytest.mark.arsenal_live +@pytest.mark.parametrize("inv", INVOCATIONS, ids=INVOCATION_IDS) +def test_live_tool_probe(inv, arsenal_live_cfg): + """Probe binaries from ~/.droidreaver/tools (or PATH). Skip if missing.""" + if inv.name in LIVE_SPAWN_SKIP or inv.tool.name in LIVE_SPAWN_SKIP: + pytest.skip(f"{inv.name} is GUI/long-running or blocks; not spawned") + cfg = arsenal_live_cfg + wanted = parse_arsenal_install_spec() + try: + prefix = resolve_executable(cfg, inv.tool, inv.executable) + except RunError: + if wanted and (inv.tool.name in wanted or inv.name in wanted): + pytest.fail( + f"{inv.name} was requested via DROIDREAVER_ARSENAL_INSTALL " + "but is still missing" + ) + pytest.skip(f"{inv.name} not installed") + + args = _probe_args(inv) + try: + result = subprocess.run( + prefix + args, + capture_output=True, + text=True, + timeout=PROBE_CMD_TIMEOUT, + env={**os.environ, "DROIDREAVER_SHELL": "1"}, + ) + except FileNotFoundError: + pytest.skip(f"{inv.name} binary vanished") + except subprocess.TimeoutExpired: + pytest.fail(f"{inv.name} hung on {' '.join(args)} after {PROBE_CMD_TIMEOUT}s") + + combined = (result.stdout or "") + (result.stderr or "") + assert result.returncode != 127, f"{inv.name} command not found: {combined[:400]}" + if result.returncode not in (0, 1, 2, 3, 4): + pytest.fail( + f"{inv.name} probe exit {result.returncode}: {combined[:500]}" + ) diff --git a/tests/test_coverage_comment.py b/tests/test_coverage_comment.py new file mode 100644 index 0000000..7af25a3 --- /dev/null +++ b/tests/test_coverage_comment.py @@ -0,0 +1,33 @@ +import importlib.util +from pathlib import Path + +_SCRIPT = Path(__file__).resolve().parents[1] / ".github" / "scripts" / "coverage_comment.py" +_spec = importlib.util.spec_from_file_location("coverage_comment", _SCRIPT) +assert _spec and _spec.loader +coverage_comment = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(coverage_comment) + + +def test_format_coverage_markdown(tmp_path): + xml = tmp_path / "coverage.xml" + xml.write_text( + """ + + + + + + + + + + +""", + encoding="utf-8", + ) + md = coverage_comment.format_coverage_markdown(xml) + assert coverage_comment.MARKER in md + assert "80.0%" in md + assert "droidreaver/cli.py" in md + assert "40.0%" in md + assert "ubuntu-latest" in md diff --git a/tests/test_help.py b/tests/test_help.py index aab9b8e..b560018 100644 --- a/tests/test_help.py +++ b/tests/test_help.py @@ -35,6 +35,20 @@ def test_no_args_shows_banner_and_help_hint(): assert "Get started" not in result.output +def test_close_typo_resolves_to_apktool(): + from unittest.mock import patch + + runner = CliRunner() + with patch("droidreaver.cli.ensure_config"), patch( + "droidreaver.cli.load_config", return_value={} + ), patch("droidreaver.cli.run_invocation", return_value=0) as run: + result = runner.invoke(main, ["apktook", "d", "app-debug.apk"]) + assert result.exit_code == 0 + assert "apktool" in result.output + assert run.call_args[0][0] == "apktool" + assert run.call_args[0][1] == ["d", "app-debug.apk"] + + def test_run_passes_flags_to_tool(): """Flags after the tool name must reach the underlying CLI (not Click).""" runner = CliRunner() diff --git a/tests/test_install_direct.py b/tests/test_install_direct.py index 560b0ca..5dc4417 100644 --- a/tests/test_install_direct.py +++ b/tests/test_install_direct.py @@ -8,7 +8,7 @@ from droidreaver import installer from droidreaver.config import META_KEY -from droidreaver.installer import InstallError, install_tools +from droidreaver.installer import InstallError, install_tools, uninstall_tool, update_tool from droidreaver.registry import Tool, get_tool @@ -368,3 +368,138 @@ def should_not_run(*_args): install_tools(["adb"], cfg, force=True) assert len(calls) == 1 + + +def _gitleaks_release() -> dict: + names = [ + "gitleaks_8.30.1_checksums.txt", + "gitleaks_8.30.1_darwin_arm64.tar.gz", + "gitleaks_8.30.1_linux_x64.tar.gz", + "gitleaks_8.30.1_windows_arm64.zip", + "gitleaks_8.30.1_windows_x32.zip", + "gitleaks_8.30.1_windows_x64.zip", + ] + return {"assets": [{"name": n} for n in names]} + + +def test_find_asset_windows_x64_skips_arm64(monkeypatch): + monkeypatch.setattr(installer, "detect_platform", lambda: "windows") + monkeypatch.setattr(installer, "detect_cpu_arch", lambda: "x64") + asset = installer._find_asset(_gitleaks_release(), None, "8.30.1") + assert asset is not None + assert asset["name"] == "gitleaks_8.30.1_windows_x64.zip" + + +def test_uninstall_removes_managed_tool_dir(tmp_path): + cfg = _cfg(tmp_path) + tool_dir = tmp_path / ".droidreaver" / "tools" / "gitleaks" + (tool_dir / "extracted").mkdir(parents=True) + (tool_dir / "extracted" / "gitleaks.exe").write_bytes(b"x") + result = uninstall_tool("gitleaks", cfg) + assert not tool_dir.exists() + assert any("gitleaks" in path for path in result.removed) + + +def test_find_asset_linux_x64(monkeypatch): + monkeypatch.setattr(installer, "detect_platform", lambda: "linux") + monkeypatch.setattr(installer, "detect_cpu_arch", lambda: "x64") + asset = installer._find_asset(_gitleaks_release(), None, "8.30.1") + assert asset is not None + assert asset["name"] == "gitleaks_8.30.1_linux_x64.tar.gz" + + +def test_medusa_version_from_git_describe(tmp_path, monkeypatch): + cfg = _cfg(tmp_path) + medusa = tmp_path / ".droidreaver" / "tools" / "medusa" + (medusa / ".git").mkdir(parents=True) + (medusa / "medusa.py").write_text("print('medusa')\n", encoding="utf-8") + + monkeypatch.setattr(installer, "find_git", lambda: "git") + + def fake_run(cmd, **kwargs): + class R: + returncode = 0 + stdout = "" + stderr = "" + + if cmd[:3] == ["git", "-C", str(medusa)] and "describe" in cmd: + R.stdout = "v1.2.3-4-gabcdef\n" + return R() + R.returncode = 1 + return R() + + monkeypatch.setattr(installer, "run_cmd", fake_run) + assert installer.get_tool_version(get_tool("medusa"), cfg) == "v1.2.3-4-gabcdef" + + +def test_update_tool_git_fetches_release_tag(tmp_path, monkeypatch): + cfg = _cfg(tmp_path) + medusa = tmp_path / ".droidreaver" / "tools" / "medusa" + (medusa / ".git").mkdir(parents=True) + (medusa / "medusa.py").write_text("print('medusa')\n", encoding="utf-8") + cmds: list[list[str]] = [] + + monkeypatch.setattr(installer, "find_git", lambda: "git") + monkeypatch.setattr(installer, "_resolve_git_ref", lambda *a, **k: "v3.9.6") + monkeypatch.setattr(installer, "_finalize_python_repo_install", lambda *a, **k: None) + + def fake_run(cmd, **kwargs): + cmds.append(list(cmd)) + + class R: + returncode = 0 + stdout = "" + stderr = "" + + return R() + + monkeypatch.setattr(installer, "run_cmd", fake_run) + old, new = update_tool("medusa", cfg) + assert any("fetch" in c and "v3.9.6" in c for c in cmds) + assert any("checkout" in c and "FETCH_HEAD" in c for c in cmds) + assert not any("pull" in c for c in cmds) + assert (medusa / ".droidreaver-ref").read_text(encoding="utf-8").strip() == "v3.9.6" + assert new == "3.9.6" + assert old != "3.9.6" + + +def test_git_tool_version_uses_recorded_release_tag(tmp_path): + cfg = _cfg(tmp_path) + medusa = tmp_path / ".droidreaver" / "tools" / "medusa" + medusa.mkdir(parents=True) + (medusa / "medusa.py").write_text("print('medusa')\n", encoding="utf-8") + (medusa / ".droidreaver-ref").write_text("v3.9.6\n", encoding="utf-8") + assert installer.get_tool_version(get_tool("medusa"), cfg) == "3.9.6" + + +def test_update_tool_zip_redownloads_medusa(tmp_path, monkeypatch): + cfg = _cfg(tmp_path) + medusa = tmp_path / ".droidreaver" / "tools" / "medusa" + medusa.mkdir(parents=True) + (medusa / ".droidreaver-source").write_text("zip\n", encoding="utf-8") + (medusa / "medusa.py").write_text("old\n", encoding="utf-8") + + monkeypatch.setattr(installer, "find_git", lambda: None) + + def fake_zip(tool, inst, ref=None): + inst.mkdir(parents=True, exist_ok=True) + (inst / ".droidreaver-source").write_text("zip\n", encoding="utf-8") + (inst / "medusa.py").write_text("new\n", encoding="utf-8") + if ref: + (inst / ".droidreaver-ref").write_text(f"{ref}\n", encoding="utf-8") + + monkeypatch.setattr(installer, "_download_github_repo_zip", fake_zip) + monkeypatch.setattr(installer, "_resolve_git_ref", lambda *a, **k: "v3.9.6") + monkeypatch.setattr(installer, "_finalize_python_repo_install", lambda *a, **k: None) + update_tool("medusa", cfg) + assert (medusa / "medusa.py").read_text(encoding="utf-8") == "new\n" + + +def test_medusa_version_from_source_marker_without_git(tmp_path, monkeypatch): + cfg = _cfg(tmp_path) + medusa = tmp_path / ".droidreaver" / "tools" / "medusa" + medusa.mkdir(parents=True) + (medusa / "medusa.py").write_text("print('medusa')\n", encoding="utf-8") + (medusa / ".droidreaver-source").write_text("zip\n", encoding="utf-8") + monkeypatch.setattr(installer, "find_git", lambda: None) + assert installer.get_tool_version(get_tool("medusa"), cfg) == "zip" diff --git a/tests/test_registry.py b/tests/test_registry.py index 39fad85..2353cd9 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -49,7 +49,8 @@ def test_frida_pin_group(): assert "objection" in group assert "frida-tools" in group assert "r2frida" in group - assert "medusa" in group + assert "medusa" not in group + assert get_pin_group("medusa") == {"medusa"} def test_pin_group_singleton(): diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 54ff447..0b3a2a6 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -1,7 +1,11 @@ """Tests for tool runtime dispatch.""" +from pathlib import Path +from unittest.mock import patch + from droidreaver.runtime import ( RESERVED_COMMANDS, + _find_binary_in_dir, lookup_invocation, list_runnable_tools, ) @@ -54,3 +58,9 @@ def test_objection_invocation(): inv = lookup_invocation("objection") assert inv is not None assert inv.tool.name == "objection" + + +def test_find_binary_skips_inaccessible_path(): + denied = Path(r"C:\WINDOWS\system32\config\systemprofile\AppData\Local\Microsoft\WindowsApps") + with patch.object(Path, "is_dir", side_effect=PermissionError("Access is denied")): + assert _find_binary_in_dir(denied, "tcpdump") is None