Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 153 additions & 0 deletions .github/scripts/coverage_comment.py
Original file line number Diff line number Diff line change
@@ -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 = "<!-- droidreaver-coverage -->"
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()
48 changes: 47 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand All @@ -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
Expand Down Expand Up @@ -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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ pip-wheel-metadata/
.mypy_cache/
.ruff_cache/
.coverage
coverage.xml
htmlcov/
.tox/
.nox/
Expand Down
14 changes: 13 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
79 changes: 74 additions & 5 deletions droidreaver/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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[/]"
)


Expand Down Expand Up @@ -1592,15 +1658,15 @@ 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,
context_settings={
"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:
Expand All @@ -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)


Expand Down
Loading
Loading