diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 60049c9..ade4f07 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -25,6 +25,8 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} +# Keep package mirror/network stalls bounded, separately from analysis time. +# apt options below retain retries and fail with the actual download error. jobs: # Runners are disposable, so these build the dev profile on purpose. The # release-only rule in CLAUDE.md is about the maintainer's machine. @@ -45,6 +47,10 @@ jobs: test: runs-on: ubuntu-latest timeout-minutes: 45 + env: + # Whole-corpus decompilation takes tens of minutes without optimization. + # Keep test-profile debug assertions and overflow checks enabled. + CARGO_PROFILE_TEST_OPT_LEVEL: 2 steps: - uses: actions/checkout@v4 # The AArch64 std, for the Rust fixture: `build-fixtures.sh` builds it @@ -68,8 +74,8 @@ jobs: # golang-go the Go fixture, which found five decoder bugs - name: Install the oracles run: | - sudo apt-get update - sudo apt-get install -y clang llvm binutils qemu-user-static golang-go \ + sudo apt-get -o Acquire::Retries=2 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update + sudo apt-get -o Acquire::Retries=2 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y clang llvm binutils qemu-user-static golang-go \ gcc-aarch64-linux-gnu g++-aarch64-linux-gnu - name: Build the fixture corpus run: ./scripts/build-fixtures.sh @@ -98,8 +104,8 @@ jobs: # The cross gcc because this runner is x86-64 and a `.a64` fixture # has to be AArch64; qemu because several of them are executed. - run: | - sudo apt-get update - sudo apt-get install -y clang \ + sudo apt-get -o Acquire::Retries=2 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update + sudo apt-get -o Acquire::Retries=2 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y clang \ gcc-aarch64-linux-gnu g++-aarch64-linux-gnu qemu-user-static - run: ./scripts/build-fixtures.sh - run: cargo build --release @@ -131,8 +137,8 @@ jobs: # The cross gcc because this runner is x86-64 and a `.a64` fixture # has to be AArch64; qemu because several of them are executed. - run: | - sudo apt-get update - sudo apt-get install -y clang binutils \ + sudo apt-get -o Acquire::Retries=2 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update + sudo apt-get -o Acquire::Retries=2 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y clang binutils \ gcc-aarch64-linux-gnu g++-aarch64-linux-gnu qemu-user-static - run: ./scripts/build-fixtures.sh # Release, like determinism above: the recall numbers have to come from @@ -154,7 +160,7 @@ jobs: steps: - uses: actions/checkout@v4 - run: sh -n install.sh - - run: sudo apt-get update -q && sudo apt-get install -y shellcheck + - run: sudo apt-get -o Acquire::Retries=2 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update -q && sudo apt-get -o Acquire::Retries=2 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 install -y shellcheck - run: shellcheck --shell=sh install.sh # The one thing syntax cannot see: that the script still knows where the # release puts things. `build-release.sh` names the archive and the diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 29afade..7ace390 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -140,7 +140,9 @@ jobs: $name = "e5r-$version-x86_64-pc-windows-msvc" New-Item -ItemType Directory -Force -Path "dist/$name" | Out-Null Copy-Item "target/x86_64-pc-windows-msvc/release/e5r.exe" "dist/$name/" - Copy-Item README.md, LICENSE, MANUAL.md "dist/$name/" + Copy-Item README.md, LICENSE "dist/$name/" + New-Item -ItemType Directory -Force -Path "dist/$name/docs" | Out-Null + Copy-Item docs/MANUAL.md, docs/ROADMAP.md, docs/dashboard.md "dist/$name/docs/" & "dist/$name/e5r.exe" --version Compress-Archive -Path "dist/$name" -DestinationPath "dist/$name.zip" - uses: actions/upload-artifact@v4 diff --git a/.gitignore b/.gitignore index 1199877..bb16659 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Custom +workspace/ + # Rust /target/ **/*.rs.bk @@ -29,3 +32,231 @@ # Release archives, built by scripts/build-release.sh. dist/ + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +*.lcov +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +# Pipfile.lock + +# uv +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +# poetry.lock +# poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +# pdm.lock +# pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +# pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi/* +!.pixi/config.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule* +celerybeat.pid + +# Redis +*.rdb +*.aof +*.pid + +# RabbitMQ +mnesia/ +rabbitmq/ +rabbitmq-data/ + +# ActiveMQ +activemq-data/ + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs/Zensical documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +# .idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore that +# can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, you +# could uncomment the following to ignore the entire vscode folder +# .vscode/ +# Temporary file for partial code execution +tempCodeRunnerFile.py + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +# Streamlit +.streamlit/secrets.toml + +# Local task-store write locks and atomic-write scratch files. Task JSON is durable. +*.work/.lock +*.work/*.tmp + +*.work/*/.lock +*.work/*/*.tmp diff --git a/AGENTS.md b/AGENTS.md index b831b62..5304c33 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # CLAUDE.md Scope, milestones and the list of things this project will not build live in -[`ROADMAP.md`](ROADMAP.md). Read that first. +[`ROADMAP.md`](docs/ROADMAP.md). Read that first. ## This checkout is release-only diff --git a/CLAUDE.md b/CLAUDE.md index 31a1818..a080a14 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ # CLAUDE.md Scope, milestones and the list of things this project will not build live in -[`ROADMAP.md`](ROADMAP.md). Read that first. +[`ROADMAP.md`](docs/ROADMAP.md). Read that first. ## This checkout is release-only diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c7dcee8..911bc77 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -29,7 +29,7 @@ and under what licence, in the file that uses it. ## What a change has to come with Every claim this tool makes is measured, and a change that makes a claim has to -bring its measurement. `ROADMAP.md` says what each milestone is gated on; the +bring its measurement. `docs/ROADMAP.md` says what each milestone is gated on; the short version: - **An external oracle wherever one exists.** A decoder is compared against diff --git a/Cargo.lock b/Cargo.lock index 9de2d26..d897371 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -52,12 +52,24 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + [[package]] name = "bitflags" version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +[[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + [[package]] name = "clap" version = "4.6.6" @@ -189,6 +201,7 @@ dependencies = [ "memmap2", "serde", "serde_json", + "tiny_http", ] [[package]] @@ -208,6 +221,7 @@ dependencies = [ "e5r-format", "e5r-types", "serde", + "serde_json", ] [[package]] @@ -298,6 +312,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -322,6 +342,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + [[package]] name = "memchr" version = "2.8.3" @@ -464,6 +490,18 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/Cargo.toml b/Cargo.toml index 57c5de1..d29fcbf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ # e5r — a CLI reverse engineering toolkit. # # The library owns every fact; the CLI is a thin client of it. -# Crate boundaries and the dependency direction are documented in ROADMAP.md +# Crate boundaries and the dependency direction are documented in docs/ROADMAP.md # under "Architecture": edges run downward only, and e5r-core depends on # nothing else in the workspace. [workspace] diff --git a/README.md b/README.md index 2210283..b8ea5bf 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # e5r: The Reverse Engineering Toolkit for AI Agents **e5r** is a disassembler, decompiler and binary differ with a command line as -its only front end. Every command speaks JSON, every recovered fact carries the -evidence for it, and every name, type and comment an agent writes lands in a -git-mergeable log. One static Rust binary: no JVM, no project server, no +its primary interface and an optional local project workspace. Every command +speaks JSON, every recovered fact carries the evidence for it, and every name, +type and comment an agent writes lands in a git-mergeable log. One static Rust binary: no JVM, no required project server, no proprietary database. ```bash @@ -40,7 +40,7 @@ curl -fsSL https://raw.githubusercontent.com/h5i-dev/e5r/main/install.sh | sh One binary, no runtime dependency. The script works out the platform, verifies the download against the release's `SHA256SUMS`, and refuses to install if it -does not match. [`MANUAL.md`](MANUAL.md) has the environment variables. +does not match. [`MANUAL.md`](docs/MANUAL.md) has the environment variables. ```bash e5r completions bash > /etc/bash_completion.d/e5r # or zsh, fish, elvish @@ -110,6 +110,25 @@ e5r project new ./a.out --out a.e5rproj # reopen it later without reanalysi --- +## 3. Project workspace + +A local dashboard puts blockers, review requests and next actions first. Its +decompiler pane provides searchable functions, pseudocode, disassembly and +references and direct call graphs. The UI is read-only; agents edit tasks, +findings, notes and reports through the CLI, and humans read the live results. + +```bash +e5r project new ./a.out --name investigation +e5r ui +e5r project task investigation list --json +``` + +Projects default to `~/.local/share/e5r/projects`; `e5r ui` lists them all. +Open the printed loopback URL. See [the workspace guide](docs/dashboard.md) for +task creation, handoffs and conflict handling. + +--- + ## 6. Build The checkout is release-only; see [`CLAUDE.md`](CLAUDE.md). @@ -125,7 +144,7 @@ cargo test --release --workspace ## 7. Documentation -- [MANUAL.md](MANUAL.md) / `man e5r`: the full command reference +- [MANUAL.md](docs/MANUAL.md) / `man e5r`: the full command reference - [docs/tutorial.md](docs/tutorial.md): a stripped binary to a committed annotation log - [docs/design/](docs/design/): one document per subsystem, and why it is shaped that way - [docs/scorecard.md](docs/scorecard.md): every measured number, including the bad ones @@ -195,7 +214,7 @@ scorecard says so — it has a section for what is *not* measured. WASM, .NET, DEX and Java class files; scripting beyond the batch language; and the decompiler quality needed to clear the DecBench milestone. All of it is in -[`ROADMAP.md`](ROADMAP.md) with the reason, and none of it is claimed here. +[`ROADMAP.md`](docs/ROADMAP.md) with the reason, and none of it is claimed here. diff --git a/crates/e5r-api/src/inspect.rs b/crates/e5r-api/src/inspect.rs new file mode 100644 index 0000000..f740ed3 --- /dev/null +++ b/crates/e5r-api/src/inspect.rs @@ -0,0 +1,139 @@ +//! Function-local references and the existing evidence behind their targets. + +use e5r_analysis::{Found, Function, Program, Xref}; +use e5r_core::Addr; + +/// True only for bytes covered by recovered blocks, excluding gaps in a split function. +pub fn contains(function: &Function, addr: Addr) -> bool { + function + .cfg + .blocks + .values() + .any(|block| block.range.contains(addr)) +} + +/// A recovered reference with target context. No new references are inferred. +pub struct Reference<'a> { + /// The reference recorded by analysis. + pub xref: Xref, + /// A function or symbol name, when available. + pub name: Option, + /// A recovered function containing the target, for navigation. + pub function: Option, + /// The containing extracted string, including references into its interior. + pub string: Option<&'a Found>, + /// The section containing the target. + pub section: Option<&'a str>, + /// Up to 32 mapped bytes beginning at the target. + pub bytes: Vec, +} + +/// Every outgoing reference from the function's recovered blocks, in address order. +pub fn outgoing<'a>(program: &'a Program, function: &Function) -> Vec> { + let all = program.xrefs.all(); + function + .cfg + .blocks + .values() + .flat_map(|block| { + let lo = all.partition_point(|xref| xref.from < block.range.start()); + let hi = all.partition_point(|xref| xref.from < block.range.end()); + all[lo..hi].iter().map(|xref| { + let string = program + .strings + .iter() + .find(|s| xref.to >= s.addr && xref.to.get() - s.addr.get() < s.len); + let section = program + .object + .sections + .iter() + .find(|section| section.range.contains(xref.to)) + .map(|section| section.name.as_str()); + let bytes = program + .object + .memory + .segment_at(xref.to) + .and_then(|segment| segment.slice_to_end(xref.to)) + .map(|bytes| bytes.iter().take(32).copied().collect()) + .unwrap_or_default(); + Reference { + xref: *xref, + name: program.name_of(xref.to), + function: program.function_at(xref.to).map(|f| f.entry), + string, + section, + bytes, + } + }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use e5r_analysis::{Options, XrefIndex, XrefKind, analyze, strings::Encoding}; + use e5r_core::Arch; + use e5r_format::LoadOptions; + + #[test] + fn split_functions_exclude_gap_references_and_keep_interior_string_targets() { + // jmp +6 skips six bytes before ret; the hull contains bytes the CFG does not. + // Use addresses beyond JavaScript's integer precision as well. + let base = Addr(0x2000_0000_0000_1000); + let mut bytes = vec![0x90; 64]; + bytes[..2].copy_from_slice(&[0xeb, 6]); + bytes[8] = 0xc3; + bytes[15..20].copy_from_slice(b"hello"); + let mut object = e5r_format::raw::load( + &bytes, + &LoadOptions { + base: Some(base), + arch: Some(Arch::X86_64), + ..Default::default() + }, + ) + .unwrap(); + object.function_hints.push(e5r_format::FunctionHint { + addr: base, + size: None, + name: Some("entry".into()), + provenance: e5r_core::Provenance::new(e5r_core::Evidence::EntryPoint), + }); + let mut program = analyze( + object, + &Options { + scan_gaps: false, + follow_calls: false, + ..Default::default() + }, + ); + let target = base.wrapping_offset(16); + program.xrefs = XrefIndex::build( + [0, 4, 8] + .into_iter() + .map(|offset| Xref { + from: base.wrapping_offset(offset), + to: target, + kind: XrefKind::Data, + }) + .collect(), + ); + program.strings = vec![Found { + addr: base.wrapping_offset(15), + len: 5, + encoding: Encoding::Ascii, + text: "hello".into(), + }]; + let function = program.function(base).unwrap(); + assert!(contains(function, base.wrapping_offset(8))); + assert!(!contains(function, base.wrapping_offset(4))); + let references = outgoing(&program, function); + assert_eq!(references.len(), 2); + assert_eq!(references[1].xref.from, base.wrapping_offset(8)); + assert_eq!(references[1].string.unwrap().text, "hello"); + assert_eq!(&references[1].bytes[..4], b"ello"); + assert_eq!(references[1].bytes.len(), 32); + assert_eq!(references[1].section, Some("image")); + } +} diff --git a/crates/e5r-api/src/lib.rs b/crates/e5r-api/src/lib.rs index 424728b..58de13c 100644 --- a/crates/e5r-api/src/lib.rs +++ b/crates/e5r-api/src/lib.rs @@ -13,6 +13,7 @@ pub mod classes; pub mod dataflow; pub mod decompile; pub mod emulate; +pub mod inspect; pub mod kernel; mod msvc; pub mod prototypes; diff --git a/crates/e5r-cli/Cargo.toml b/crates/e5r-cli/Cargo.toml index 95a488b..f53ee99 100644 --- a/crates/e5r-cli/Cargo.toml +++ b/crates/e5r-cli/Cargo.toml @@ -40,5 +40,7 @@ memmap2 = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +tiny_http = "0.12" + [dev-dependencies] serde_json = { workspace = true } diff --git a/crates/e5r-cli/src/dashboard.rs b/crates/e5r-cli/src/dashboard.rs new file mode 100644 index 0000000..44dc641 --- /dev/null +++ b/crates/e5r-cli/src/dashboard.rs @@ -0,0 +1,497 @@ +//! Loopback collection and project workspaces backed by the library APIs. +use crate::{annotate, out::Out, patch}; +use e5r_db::{ + project::Project, + registry::{self, Registry}, + work::Store, +}; +use serde_json::{Value, json}; +use std::path::{Path, PathBuf}; +use tiny_http::{Header, Method, Request, Response, Server, StatusCode}; + +fn header(name: &str, value: &str) -> Header { + Header::from_bytes(name.as_bytes(), value.as_bytes()).expect("constant HTTP header") +} + +fn respond(request: Request, code: u16, kind: &str, body: String) { + let response = Response::from_string(body).with_status_code(StatusCode(code)) + .with_header(header("Content-Type", kind)) + .with_header(header("Cache-Control", "no-store")) + .with_header(header("X-Content-Type-Options", "nosniff")) + .with_header(header("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'none'")); + let _ = request.respond(response); +} + +fn field<'a>(request: &'a Request, name: &'static str) -> Option<&'a str> { + request + .headers() + .iter() + .find(|h| h.field.equiv(name)) + .map(|h| h.value.as_str()) +} + +fn trusted(request: &Request, host: &str) -> bool { + field(request, "Host") == Some(host) + && field(request, "Origin").is_none_or(|origin| origin == format!("http://{host}")) +} + +#[derive(Clone)] +struct Workspace { + path: PathBuf, + project: Project, + binary: PathBuf, + name: Option, +} + +impl Workspace { + fn new(path: &Path, binary: Option<&Path>, name: Option) -> Result { + let mut project = patch::project_read(path)?; + registry::normalize( + &mut project, + &std::env::current_dir().map_err(|e| e.to_string())?, + )?; + let binary = binary + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(&project.binary.path)); + Ok(Self { + path: path.into(), + project, + binary, + name, + }) + } + fn object(&self) -> Result { + let file = std::fs::File::open(&self.binary) + .map_err(|e| format!("{}: {e}", self.binary.display()))?; + let data = crate::map_file(&file).map_err(|e| e.to_string())?; + let verdict = self + .project + .verify_at(&self.binary.to_string_lossy(), &data); + if !verdict.is_usable() { + return Err(verdict.to_string()); + } + let load = e5r_format::LoadOptions { + base: self.project.load.base, + arch: self.project.load.arch.clone(), + ..Default::default() + }; + let object = e5r_format::load(&data, &load).map_err(|e| e.to_string())?; + if self + .project + .load + .readers + .iter() + .any(|reader| reader != object.format.as_str()) + { + return Err("dashboard does not yet support custom project readers".into()); + } + Ok(object) + } + fn options(&self) -> Result { + let mut options = e5r_analysis::Options::default(); + for (key, value) in &self.project.analysis { + let boolean = || { + value + .parse::() + .map_err(|_| format!("{key} needs true or false")) + }; + match key.as_str() { + "scan_gaps" => options.scan_gaps = boolean()?, + "follow_calls" => options.follow_calls = boolean()?, + "strings" => options.strings = boolean()?, + "xrefs" => options.xrefs = boolean()?, + "noreturn" => options.noreturn = boolean()?, + "data" => options.data = boolean()?, + "threads" => { + options.threads = Some(value.parse().map_err(|_| "threads needs a number")?) + } + _ => { + return Err(format!( + "dashboard does not understand project option {key:?}" + )); + } + } + } + Ok(options) + } + fn store(&self) -> Store { + Store::new(e5r_db::work::beside(&self.path)) + } + fn metadata(&self) -> Value { + let (info, binary_error) = match self.object() { + Ok(object) => (Some(crate::json::info(&object)), None), + Err(error) => (None, Some(error)), + }; + let options = self.options(); + json!({ "schema":"e5r.workspace.v1", "project":self.path.display().to_string(), "name":self.name, "collection":self.name.is_some(), "binary":self.binary.display().to_string(), "hash":format!("{:016x}", self.project.binary.hash), "info":info, "binary_error":binary_error, "author":crate::whoami(), "snapshot":true, "log":self.project.log, "base":self.project.load.base.map(|a| a.to_string()), "arch":self.project.load.arch.as_ref().map(|a| a.name()), "no_scan":options.as_ref().is_ok_and(|o| !o.scan_gaps), "threads":options.as_ref().ok().and_then(|o| o.threads) }) + } +} + +struct Analysis { + key: String, + program: e5r_analysis::Program, + declared: std::collections::BTreeMap, + comments: std::collections::BTreeMap, + annotations: Vec, +} + +impl Analysis { + fn load(workspace: &Workspace, key: String) -> Result { + let options = workspace.options()?; + let mut object = workspace.object()?; + e5r_api::apply_relative_relocations(&mut object); + annotate::load_pdb(&mut object, &workspace.binary); + eprintln!("Analyzing {}…", workspace.binary.display()); + let mut program = e5r_analysis::analyze(object, &options); + let db = workspace + .project + .log + .as_ref() + .map(PathBuf::from) + .unwrap_or_else(|| annotate::default_path(&workspace.binary)); + annotate::load(&db)?; + for (at, name, _) in annotate::names(&program, &db) { + if let Some(f) = program.functions.get_mut(&at) { + f.name = Some(name); + } + } + let declared = annotate::declarations(&program, &db); + let comments = annotate::comments(&program, &db); + let annotations = annotate::load(&db)?.apply(&annotate::index(&program)); + Ok(Self { + key, + program, + declared, + comments, + annotations, + }) + } + fn answer(&self, kind: &str, target: Option<&str>) -> Result { + if kind == "functions" { + return serde_json::to_value(crate::json::funcs(&self.program)) + .map_err(|e| e.to_string()); + } + let target = target.ok_or("expected a function address")?; + let at = crate::addr::parse_number(target) + .map(e5r_core::Addr) + .ok_or("expected a function address")?; + let function = self.program.function(at).ok_or("function not found")?; + if kind == "context" { + let annotations: Vec<_> = self + .annotations + .iter() + .filter(|a| a.addr == at || e5r_api::inspect::contains(function, a.addr)) + .map(|a| { + json!({"addr":a.addr.to_string(), "field":a.field.to_string(), + "value":a.value, "author":a.who, "resolution":a.resolution.to_string(), + "confident":a.resolution.is_confident()}) + }) + .collect(); + let references: Vec<_> = e5r_api::inspect::outgoing(&self.program, function).into_iter() + .map(|r| json!({"from":r.xref.from.to_string(), "to":r.xref.to.to_string(), + "kind":r.xref.kind, "name":r.name, "function":r.function.map(|a|a.to_string()), + "section":r.section, "bytes":r.bytes.iter().map(|b|format!("{b:02x}")).collect::(), + "string":r.string.map(|s|json!({"addr":s.addr.to_string(), "text":s.text, + "encoding":s.encoding, "offset":r.xref.to.get()-s.addr.get()}))})).collect(); + let blocks: Vec<_> = function.cfg.blocks.iter().map(|(addr, block)| json!({ + "addr":addr.to_string(), "end":block.range.end().to_string(), "insns":block.insns, + "successors":block.successors.iter().map(|a|a.to_string()).collect::>(), + "terminator":format!("{:?}", block.terminator), "unresolved":block.unresolved + })).collect(); + return Ok(json!({"schema":"e5r.context.v1", "function":at.to_string(), + "annotations":annotations, "references":references, "blocks":blocks, + "complete":function.is_complete(), "halt":function.cfg.halt.as_str()})); + } + if kind == "callgraph" { + let edges: Vec<_> = self + .program + .functions + .iter() + .flat_map(|(from, f)| { + f.cfg + .calls + .iter() + .filter(move |to| *from == at || **to == at) + .map(move |to| json!({"from":from.to_string(),"to":to.to_string()})) + }) + .collect(); + return Ok(json!({"schema":"e5r.callgraph.v1","edges":edges})); + } + let mut output = Out::buffer(); + let limits = crate::print::Limits { + budget: crate::budget::Budget::new(Some(10.0), Some(1)), + progress: false, + declared: &self.declared, + comments: &self.comments, + }; + match kind { + "decompile" => { + crate::print::decompile(&mut output, &self.program, target, true, limits)?; + } + "disas" => { + crate::print::disas(&mut output, &self.program, target, true, true, limits)?; + } + "xrefs" => { + crate::print::xrefs(&mut output, &self.program, target, false, true)?; + } + _ => return Err("route not found".into()), + } + serde_json::from_str(&output.take()).map_err(|e| e.to_string()) + } +} + +fn answer(request: Request, result: Result) { + match result { + Ok(value) => respond(request, 200, "application/json", value.to_string()), + Err(error) => { + let code = if error.contains("revision conflict") || error.starts_with("cannot lock") { + 409 + } else if error.contains("not found") { + 404 + } else { + 400 + }; + respond( + request, + code, + "application/json", + json!({"error":error}).to_string(), + ); + } + } +} + +struct Job { + request: Request, + workspace: Workspace, + kind: String, + target: Option, +} + +fn worker() -> std::sync::mpsc::SyncSender { + let (sender, receiver) = std::sync::mpsc::sync_channel::(16); + std::thread::spawn(move || { + let mut cached: Option = None; + for job in receiver { + let result = (|| { + let key = format!( + "{}\n{}\n{}", + job.workspace.path.display(), + job.workspace.project.to_text(), + job.workspace.binary.display() + ); + if cached.as_ref().is_none_or(|a| a.key != key) { + // Drop the old analysis before loading another: one resident + // program keeps a global collection affordable on this host. + cached = None; + cached = Some(Analysis::load(&job.workspace, key)?); + } + cached + .as_ref() + .ok_or("analysis unavailable")? + .answer(&job.kind, job.target.as_deref()) + })(); + answer(job.request, result); + } + }); + sender +} + +pub fn serve(project_path: &Path, port: u16, binary: Option<&Path>) -> Result { + let workspace = Workspace::new(project_path, binary, None)?; + // Single-project mode keeps its existing early refusal of a wrong binary. + workspace.object()?; + serve_inner(port, Some(workspace), None) +} + +pub fn serve_all(port: u16) -> Result { + serve_inner(port, None, Some(Registry::new(registry::root()?))) +} + +fn serve_inner( + port: u16, + single: Option, + registry: Option, +) -> Result { + let server = Server::http((std::net::Ipv4Addr::LOCALHOST, port)).map_err(|e| e.to_string())?; + let host = server.server_addr().to_string(); + let analysis = worker(); + eprintln!( + "{}: http://{host}\nCtrl-C stops it. Analysis loads on demand; tasks stay live.", + if single.is_some() { + "Project workspace" + } else { + "Project collection" + } + ); + for request in server.incoming_requests() { + if !trusted(&request, &host) { + respond( + request, + 403, + "application/json", + json!({"error":"untrusted host or origin"}).to_string(), + ); + continue; + } + if request.method() != &Method::Get { + respond( + request, + 405, + "application/json", + json!({"error":"read-only UI; ask an agent to edit through the CLI"}).to_string(), + ); + continue; + } + let path = request.url().to_string(); + if request.method() == &Method::Get { + let asset = match path.as_str() { + "/" if single.is_some() => Some(( + "text/html; charset=utf-8", + include_str!("dashboard/index.html"), + )), + "/" => Some(( + "text/html; charset=utf-8", + include_str!("dashboard/projects.html"), + )), + "/app.js" => Some(( + "text/javascript; charset=utf-8", + include_str!("dashboard/app.js"), + )), + "/projects.js" => Some(( + "text/javascript; charset=utf-8", + include_str!("dashboard/projects.js"), + )), + "/style.css" => Some(( + "text/css; charset=utf-8", + include_str!("dashboard/style.css"), + )), + _ if registry.is_some() + && path + .strip_prefix("/p/") + .and_then(|s| s.strip_suffix('/')) + .is_some_and(|name| registry::validate_name(name).is_ok()) => + { + Some(( + "text/html; charset=utf-8", + include_str!("dashboard/index.html"), + )) + } + _ => None, + }; + if let Some((kind, body)) = asset { + respond(request, 200, kind, body.into()); + continue; + } + } + if let Some(registry) = ®istry { + if path == "/api/projects" { + let result = match *request.method() { + Method::Get => registry.list().map(|projects| json!({"schema":"e5r.projects.v1","root":registry.root().display().to_string(),"projects":projects})), + + _ => Err("route not found".into()), + }; + answer(request, result); + continue; + } + } + let resolved = if let Some(workspace) = &single { + Ok((workspace.clone(), path.clone())) + } else { + (|| { + let (name, tail) = path + .strip_prefix("/p/") + .and_then(|s| s.split_once('/')) + .ok_or("route not found")?; + let registry = registry.as_ref().ok_or("route not found")?; + let workspace = Workspace::new(®istry.path(name)?, None, Some(name.into()))?; + Ok((workspace, format!("/{tail}"))) + })() + }; + let (workspace, route) = match resolved { + Ok(value) => value, + Err(e) => { + answer(request, Err(e)); + continue; + } + }; + if request.method() == &Method::Get { + let analysis_route = route + .strip_prefix("/api/") + .and_then(|s| s.split_once('/')) + .map(|(kind, target)| (kind, Some(target))) + .or_else(|| (route == "/api/functions").then_some(("functions", None))); + if let Some((kind, target)) = analysis_route.filter(|(kind, _)| { + matches!( + *kind, + "functions" | "decompile" | "disas" | "xrefs" | "callgraph" | "context" + ) + }) { + if target.is_some_and(|target| crate::addr::parse_number(target).is_none()) { + answer(request, Err("expected a function address".into())); + continue; + } + let job = Job { + request, + workspace, + kind: kind.into(), + target: target.map(str::to_string), + }; + if let Err(error) = analysis.try_send(job) { + let job = match error { + std::sync::mpsc::TrySendError::Full(job) + | std::sync::mpsc::TrySendError::Disconnected(job) => job, + }; + respond( + job.request, + 503, + "application/json", + json!({"error":"analysis queue is busy; retry shortly"}).to_string(), + ); + } + continue; + } + } + let result = (|| match (request.method(), route.as_str()) { + (&Method::Get, "/api/project") => Ok(workspace.metadata()), + (&Method::Get, "/api/tasks") => { + serde_json::to_value(workspace.store().board()?).map_err(|e| e.to_string()) + } + (&Method::Get, "/api/records") => { + let mut records = Vec::new(); + for kind in ["finding", "note", "report"] { + for record in + Store::new(e5r_db::work::beside(&workspace.path).join(kind)).list()? + { + records.push(json!({"kind":kind,"record":record})); + } + } + Ok(json!({"schema":"e5r.records.v1","records":records})) + } + _ => Err("route not found".into()), + })(); + answer(request, result); + } + Ok(0) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn cross_origin_and_rebound_hosts_are_rejected() { + let good: Request = tiny_http::TestRequest::new() + .with_header(header("Host", "127.0.0.1:7879")) + .into(); + assert!(trusted(&good, "127.0.0.1:7879")); + let bad: Request = tiny_http::TestRequest::new() + .with_header(header("Host", "attacker.example:7879")) + .into(); + assert!(!trusted(&bad, "127.0.0.1:7879")); + let bad: Request = tiny_http::TestRequest::new() + .with_header(header("Host", "127.0.0.1:7879")) + .with_header(header("Origin", "https://attacker.example")) + .into(); + assert!(!trusted(&bad, "127.0.0.1:7879")); + } +} diff --git a/crates/e5r-cli/src/dashboard/app.js b/crates/e5r-cli/src/dashboard/app.js new file mode 100644 index 0000000..25581b3 --- /dev/null +++ b/crates/e5r-cli/src/dashboard/app.js @@ -0,0 +1,1107 @@ +"use strict"; +const base = location.pathname.startsWith("/p/") + ? location.pathname.replace(/\/$/, "") + : ""; +const $ = (id) => document.getElementById(id); +const state = { + project: null, + board: { tasks: [] }, + functions: [], + selected: null, + pane: "decompile", + currentTask: null, + recordStamp: "", + records: null, + recordsError: "", + request: 0, + contextRequest: 0, + context: null, + contextError: "", + contextPromise: null, + referenceDirection: "incoming", + instructionTarget: null, + history: [], + future: [], + code: "", + boardStamp: "", + functionLimit: 400, +}; +const labels = { + planned: "Planned", + active: "Active", + blocked: "Blocked", + review: "Review", + done: "Done", +}; +function el(tag, text, className) { + const e = document.createElement(tag); + if (text !== undefined) e.textContent = text; + if (className) e.className = className; + return e; +} +function clear(node) { + node.replaceChildren(); +} +function tag(value) { + return el("span", labels[value] || value, `state ${value}`); +} +function message(node, title, detail) { + clear(node); + const e = el("div", undefined, "empty"); + e.append(el("strong", title)); + if (detail) e.append(el("span", detail)); + node.append(e); +} +function shellQuote(text) { + return "'" + text.replaceAll("'", "'\\''") + "'"; +} +async function api(path) { + const response = await fetch(base + path); + const data = await response.json(); + if (!response.ok) { + const error = new Error( + data.error || `Request failed (${response.status})`, + ); + error.status = response.status; + throw error; + } + return data; +} +function error(text) { + $("error").textContent = text; + $("error").hidden = !text; +} +async function copy(text, button) { + try { + await navigator.clipboard.writeText(text); + const original = button.textContent; + button.textContent = "Copied"; + setTimeout(() => (button.textContent = original), 1200); + } catch { + error("Clipboard unavailable. Select and copy the displayed text."); + } +} +function view(name) { + $("overview").hidden = name !== "overview"; + $("workspace").hidden = name !== "workspace"; + document + .querySelectorAll("[data-view]") + .forEach((b) => b.classList.toggle("selected", b.dataset.view === name)); + $("view-title").textContent = + name === "overview" ? "Project overview" : "Decompiler workspace"; + if (name === "workspace" && !state.functions.length) loadFunctions(); +} +function route() { + const parts = location.hash.slice(1).split("/"); + view(parts[0] === "workspace" ? "workspace" : "overview"); + if (parts[0] === "task" && parts[1]) { + const task = state.board.tasks.find( + (row) => row.task.id === parts[1], + )?.task; + if (task && !$("task-dialog").open) openTask(task); + } + if ( + parts[0] === "workspace" && + parts[1] && + /^0x[0-9a-f]+$/i.test(parts[1]) && + state.functions.length + ) { + const f = state.functions.find((f) => f.addr === parts[1]); + if (f && f.addr !== state.selected?.addr) selectFunction(f, false); + } +} +function rowButton(row, reason) { + const button = el("button", undefined, "work-row"); + button.type = "button"; + const title = el("div", undefined, "work-row-title"); + const text = el("span"); + text.append( + el("span", row.task.id, "id"), + document.createTextNode(row.task.content.title), + ); + title.append(text, tag(row.task.content.state)); + button.append( + title, + el( + "div", + reason || + row.task.content.next || + "Add a next action to make this easy to hand off.", + "work-row-sub", + ), + ); + button.addEventListener("click", () => openTask(row.task)); + return button; +} +function renderBoard() { + const tasks = state.board.tasks, + open = tasks.filter((r) => r.task.content.state !== "done"), + attention = tasks.filter((r) => r.attention), + ready = tasks.filter((r) => r.ready), + active = tasks.filter((r) => r.task.content.state === "active"); + $("headline").textContent = attention.length + ? `${attention.length} ${attention.length === 1 ? "item needs" : "items need"} attention.` + : open.length + ? "The next step is clear." + : "A clear desk. A new investigation."; + $("headline-sub").textContent = attention.length + ? "Blockers and review requests come first. Each item says what is needed." + : open.length + ? `${active.length} active · ${ready.length} ready to pick up. Keep owners and next actions current for a smooth handoff.` + : "Start with an outcome, assign an owner, and record the evidence as you go."; + clear($("counts")); + for (const [n, label, tone] of [ + [attention.length, "need attention", "attention"], + [active.length, "active", ""], + [ready.length, "ready", ""], + ]) { + const e = el("div", undefined, `count ${n ? tone : ""}`); + e.append(el("b", String(n)), el("span", label)); + $("counts").append(e); + } + $("attention-count").textContent = String(attention.length); + $("attention").closest(".attention-panel").classList.toggle("quiet", !attention.length); + $("ready-count").textContent = String(ready.length); + clear($("attention")); + clear($("ready")); + attention + .slice(0, 6) + .forEach((r) => $("attention").append(rowButton(r, r.attention))); + ready.slice(0, 6).forEach((r) => $("ready").append(rowButton(r))); + if (!attention.length) + message( + $("attention"), + "Nothing is waiting on you.", + "No blocked task or result awaiting review.", + ); + if (!ready.length) + message( + $("ready"), + open.length ? "No task ready to claim." : "Start the first task.", + open.length + ? "Active work, blockers and prerequisites are listed below." + : "Ask an agent to capture the next investigation.", + ); + if (attention.length > 6) + $("attention").append( + el("p", `${attention.length - 6} more in Project work below.`, "empty"), + ); + if (ready.length > 6) + $("ready").append( + el("p", `${ready.length - 6} more in Project work below.`, "empty"), + ); + renderTasks(); + renderRelated(); +} +function renderTasks() { + const q = $("task-search").value.toLowerCase(), + filter = $("task-filter").value; + const tasks = state.board.tasks.filter( + (r) => + (filter === "all" || + (filter === "open" && r.task.content.state !== "done") || + r.task.content.state === filter) && + [ + r.task.id, + r.task.content.title, + r.task.content.owner, + r.task.content.next, + ] + .join(" ") + .toLowerCase() + .includes(q), + ); + clear($("task-rows")); + for (const row of tasks) { + const t = row.task, + d = t.content, + tr = el("tr"), + outcome = el("td"), + button = el("button", d.title, "task-open"); + button.type = "button"; + button.onclick = () => openTask(t); + outcome.append( + el("span", t.id, "id"), + button, + el( + "span", + `${d.priority} priority · revision ${t.revision}`, + "task-note", + ), + ); + const status = el("td"); + status.append(tag(d.state)); + if (row.waiting_on.length) + status.append( + el("span", `Waiting on ${row.waiting_on.join(", ")}`, "task-note"), + ); + tr.append( + outcome, + status, + el("td", d.owner || "Unassigned", "muted"), + el("td", row.attention || d.next || "No next action recorded"), + ); + $("task-rows").append(tr); + } + $("task-empty").hidden = !!tasks.length; + $("task-empty").textContent = state.board.tasks.length + ? "No tasks match this view." + : "No tasks yet. Create an outcome for a person or agent to work toward."; +} +async function refreshTasks() { + try { + const board = await api("/api/tasks"), + stamp = JSON.stringify(board); + state.board = board; + if ($("task-dialog").open && state.currentTask) { + const latest = board.tasks.find(r => r.task.id === state.currentTask.id)?.task; + if (latest && latest.revision !== state.currentTask.revision) openTask(latest); + } + if (stamp !== state.boardStamp) { + state.boardStamp = stamp; + renderBoard(); + } + $("sync").textContent = "Tasks up to date"; + if (!state.project?.binary_error) error(""); + } catch (e) { + $("sync").textContent = "Tasks unavailable"; + error(`Could not refresh tasks: ${e.message}`); + } +} +function openTask(task) { + state.currentTask = task; + $("edit-id").textContent = `${task.id} · REVISION ${task.revision}`; + $("edit-heading").textContent = task.content.title; + clear($("task-detail")); + const dl = el("dl", undefined, "task-detail"); + for (const [label, value] of [ + ["State", labels[task.content.state]], ["Priority", task.content.priority], + ["Owner", task.content.owner], ["Context & acceptance criteria", task.content.description], + ["Next action / handoff", task.content.next], ["Blocker", task.content.blocker], + ["Evidence", task.content.evidence.join("\n")], ["Prerequisites", task.content.depends_on.join(", ")], + ]) { + dl.append(el("dt", label)); + const dd = el("dd", value || "None recorded"); + if (label === "Evidence") dd.className = "task-evidence"; + dl.append(dd); + } + $("task-detail").append(dl); + $("history").hidden = !task.history.length; + clear($("history-items")); + for (const h of [...task.history].reverse()) { + const row = el("div", undefined, "history-row"); + row.append(el("strong", `${h.author} · ${new Date(h.at * 1000).toLocaleString()}`), el("pre", JSON.stringify(h.previous, null, 2))); + $("history-items").append(row); + } + if (!$("task-dialog").open) $("task-dialog").showModal(); +} +async function refreshRecords() { + try { + const data = await api("/api/records"); + const stamp = JSON.stringify(data); + if (stamp === state.recordStamp && !state.recordsError) return; + state.records = data.records; + state.recordsError = ""; + const opened = new Set([...$("records").querySelectorAll("details[open]")].map(d => d.dataset.key)); + state.recordStamp = stamp; + clear($("records")); + for (const {kind, record} of data.records) { + const details = el("details", undefined, "record"); + details.dataset.key = `${kind}/${record.id}`; + details.open = opened.has(details.dataset.key); + details.append(el("summary", `${kind} · ${record.id} · ${record.content.title}`), + el("p", `Revision ${record.revision} · ${record.author || record.content.owner || "Unassigned"}`, "muted"), + el("pre", record.content.description, "record-body"), el("pre", record.content.evidence.join("\n"), "record-body")); + $("records").append(details); + } + if (!data.records.length) + message($("records"), "No records yet.", "Ask an agent to write a finding, note or report with e5r project record."); + renderFunctionNotes(); + } catch (e) { + state.recordsError = e.message; + message($("records"), "Records unavailable", e.message); + renderFunctionNotes(); + } +} +async function loadFunctions() { + $("function-count").textContent = "Loading…"; + try { + const data = await api("/api/functions"); + state.functions = data.items; + renderFunctions(); + route(); + } catch (e) { + message($("functions"), "Could not load functions.", e.message); + $("function-count").textContent = "Error"; + } +} +function renderFunctions() { + const query = $("function-search").value.toLowerCase(); + const items = state.functions.filter((f) => + `${f.name} ${f.addr}`.toLowerCase().includes(query), + ); + clear($("functions")); + $("function-count").textContent = + `${items.length.toLocaleString()} / ${state.functions.length.toLocaleString()}`; + for (const f of items.slice(0, state.functionLimit)) { + const b = el("button", undefined, "function-row"); + b.classList.toggle("selected", f.addr === state.selected?.addr); + b.setAttribute("aria-current", String(f.addr === state.selected?.addr)); + b.title = f.name; + b.append( + el("strong", f.name), + el( + "small", + `${f.addr} · ${f.insns} insns${f.complete ? "" : " · incomplete"}`, + ), + ); + b.onclick = () => selectFunction(f); + $("functions").append(b); + } + if (items.length > state.functionLimit) { + const more = el( + "button", + `Show next ${Math.min(400, items.length - state.functionLimit)} functions`, + "work-row", + ); + more.onclick = () => { + state.functionLimit += 400; + renderFunctions(); + }; + $("functions").append(more); + } + if (!items.length) + message( + $("functions"), + "No functions found.", + query + ? "Try a name or address." + : "This binary has no recovered functions.", + ); +} +function selectFunction(f, push = true) { + if (push && state.selected && state.selected.addr !== f.addr) { + state.history.push(state.selected.addr); + state.future = []; + } + // A call followed from a filtered list must still have a visible selection. + const query = $("function-search").value.toLowerCase(); + if (!`${f.name} ${f.addr}`.toLowerCase().includes(query)) + $("function-search").value = ""; + const index = state.functions.filter(item => + `${item.name} ${item.addr}`.toLowerCase().includes($("function-search").value.toLowerCase()), + ).findIndex(item => item.addr === f.addr); + state.functionLimit = Math.max(state.functionLimit, index + 1); + $("workspace").classList.remove("mobile-functions"); + syncLayout(); + state.selected = f; + state.context = null; + state.contextError = ""; + state.instructionTarget = null; + state.contextPromise = loadContext(f); + state.code = ""; + $("back").disabled = !state.history.length; + $("forward").disabled = !state.future.length; + $("function-name").textContent = f.name; + $("function-address").textContent = f.addr; + $("copy-code").disabled = true; + history.replaceState(null, "", `#workspace/${f.addr}`); + view("workspace"); + renderFunctions(); + $("functions").querySelector(".selected")?.scrollIntoView({ block: "nearest" }); + renderContext(); + renderAnnotations(); + renderFunctionNotes(); + $("variables-section").hidden = true; + renderRelated(); + loadPane(); +} +function renderContext() { + const f = state.selected; + clear($("function-context")); + const dl = el("dl"); + for (const [name, value] of [ + ["Boundary strength", f.strength], + ["Evidence", f.evidence.join(", ") || "None reported"], + ["Coverage", f.complete ? "Complete" : "Incomplete — " + f.halt], + ["Shape", `${f.blocks} blocks · ${f.insns} instructions · ${f.size} bytes`], + ["Indirect control flow", f.indirect ? "Present" : "None reported"], + ]) + dl.append(el("dt", name), el("dd", value)); + $("function-context").append(dl); +} +function renderRelated() { + if (!state.selected) return; + const addr = state.selected.addr; + const tasks = state.board.tasks.filter((r) => + r.task.content.evidence.some((e) => e === `function:${addr}` || e === addr), + ); + clear($("related-work")); + tasks.forEach((r) => + $("related-work").append(rowButton(r, r.attention || r.task.content.next)), + ); + if (!tasks.length) + $("related-work").textContent = + "No task linked yet. Ask an agent to link this function as evidence."; +} +function functionNotes() { + const addr = state.selected?.addr; + return (state.records || []).filter(({kind, record}) => ["note", "finding", "report"].includes(kind) && + record.content.evidence.some(e => e === `function:${addr}` || e === addr)); +} +function noteMetadata(record) { + return `${record.id} · ${record.author || record.content.owner || "Unassigned"} · revision ${record.revision}`; +} +function openNotes() { + state.pane = "notes"; + if (mobileLayout.matches || !wideLayout.matches) { + $("workspace").classList.remove("context-open"); + syncLayout(); + } + loadPane(); + syncTabs(); +} +function renderFunctionNotes() { + if (!state.selected) return; + const notes = functionNotes(); + clear($("function-notes")); + $("function-note-count").textContent = state.recordsError ? "Unavailable" : state.records === null ? "Loading…" : String(notes.length); + if (state.recordsError) $("function-notes").textContent = `Records unavailable: ${state.recordsError}`; + else if (state.records === null) $("function-notes").textContent = "Loading records…"; + else if (!notes.length) $("function-notes").textContent = `No records linked to ${state.selected.addr}. Link a note with function:${state.selected.addr} as evidence.`; + else { + for (const {kind, record} of notes) { + const button = el("button", undefined, "function-note-preview"); + button.append(el("strong", `${kind} · ${record.content.title}`), + el("span", record.content.description || "No record text recorded.", "note-excerpt"), + el("small", noteMetadata(record), "muted")); + button.onclick = openNotes; + $("function-notes").append(button); + } + } + if (state.pane === "notes") renderNotesPane(); +} +function renderNotesPane() { + const notes = functionNotes(); + $("quality").className = "quality"; + $("quality").textContent = `Function records · ${state.selected.addr} · Live project records, written by people or agents.`; + $("analysis-command").textContent = `e5r project record ${shellQuote(state.project.project)} note add 'Note title' --description 'Note text' --evidence function:${state.selected.addr}`; + $("copy-analysis").disabled = false; + clear($("code")); + state.code = ""; + $("copy-code").disabled = true; + if (state.recordsError) message($("code"), "Records unavailable", state.recordsError); + else if (state.records === null) message($("code"), "Loading records…"); + else if (!notes.length) message($("code"), "No records for this function yet.", `Ask an agent to save a note with function:${state.selected.addr} as evidence. The command is available in Evidence & tasks.`); + else { + for (const {kind, record} of notes) { + const article = el("article", undefined, "function-note"); + article.append(el("h3", `${kind} · ${record.content.title}`), + el("p", noteMetadata(record), "muted"), + el("pre", record.content.description || "No record text recorded.", "record-body"), + el("p", "Evidence", "muted"), el("pre", record.content.evidence.join("\n") || "None recorded", "record-body")); + $("code").append(article); + } + state.code = notes.map(({kind, record}) => `${kind} · ${record.content.title}\n${record.content.description}`).join("\n\n"); + $("copy-code").disabled = false; + } +} +function renderBinaryInfo() { + clear($("binary-info")); + const info = state.project.info; + if (!info) { + message($("binary-info"), "Binary information unavailable", state.project.binary_error); + return; + } + const dl = el("dl"); + for (const [name, value] of [ + ["Format / architecture", `${info.format} · ${info.arch} · ${info.bits}-bit · ${info.endian} endian`], + ["Entry point", info.entry || "None reported"], ["Image base", info.image_base], + ["Position independent", info.pic ? "Yes" : "No"], + ["Contents", `${info.segments} segments · ${info.symbols} symbols · ${info.imports} imports · ${info.exports} exports`], + ["Function hints", info.function_hints], ["Project content hash", state.project.hash], + ...Object.entries(info.metadata || {}), + ]) dl.append(el("dt", name), el("dd", String(value))); + $("binary-info").append(dl); + clear($("loader-warnings")); + $("loader-warnings").hidden = !info.warnings?.length; + if (info.warnings?.length) $("loader-warnings").append(el("h3", "Loader warnings")); + for (const warning of info.warnings || []) $("loader-warnings").append(el("p", warning)); +} +async function loadContext(f) { + const serial = ++state.contextRequest; + try { + const data = await api(`/api/context/${f.addr}`); + if (serial !== state.contextRequest) return; + state.context = data; + renderAnnotations(); + } catch (e) { + if (serial !== state.contextRequest) return; + state.contextError = e.message; + renderAnnotations(); + } +} +function showPane(pane) { + if (!wideLayout.matches) { + $("workspace").classList.remove("context-open", "mobile-functions"); + syncLayout(); + } + state.pane = pane; + loadPane(); +} +function annotationRow(annotation) { + const article = el("article", undefined, "annotation-row"); + article.append(el("strong", `${annotation.field} · ${annotation.addr}`), + el("p", annotation.value, "annotation-text"), + el("small", `${annotation.author} · asserted · ${annotation.resolution} match${annotation.confident ? "" : " · low confidence"}`, "muted")); + if (!annotation.confident) article.classList.add("warning"); + return article; +} +function renderAnnotations() { + clear($("function-annotations")); + if (state.contextError) $("function-annotations").textContent = `Annotations unavailable: ${state.contextError}`; + else if (!state.context) $("function-annotations").textContent = "Loading annotations…"; + else if (!state.context.annotations.length) $("function-annotations").textContent = "No saved annotations for this function."; + else { + const button = el("button", `Read ${state.context.annotations.length} ${state.context.annotations.length === 1 ? "annotation" : "annotations"}`, "button subtle"); + button.onclick = () => showPane("annotations"); + $("function-annotations").append(button); + for (const annotation of state.context.annotations.filter(a => a.field === "comment")) + $("function-annotations").append(annotationRow(annotation)); + } +} +function renderAnnotationPane() { + clear($("code")); + for (const annotation of state.context.annotations) $("code").append(annotationRow(annotation)); + if (!state.context.annotations.length) message($("code"), "No saved annotations for this function."); + $("quality").textContent = "Saved names, types and comments · author and anchor match shown · analysis snapshot"; + state.code = state.context.annotations.map(a => `${a.addr} ${a.field}: ${a.value}\n${a.author} · ${a.resolution}`).join("\n\n"); + $("copy-code").disabled = !state.code; +} +function goInstruction(addr) { + state.instructionTarget = addr; + showPane("disas"); +} +function addressLink(addr, functionAddr = null) { + const functionTarget = state.functions.find(f => f.addr === (functionAddr || addr)); + const local = state.context?.blocks.some(b => BigInt(addr) >= BigInt(b.addr) && BigInt(addr) < BigInt(b.end)); + if (!local && !functionTarget) return el("code", addr); + const button = el("button", addr, "address-link"); + button.onclick = () => local ? goInstruction(addr) : selectFunction(functionTarget); + return button; +} +function renderDisassembly(data) { + clear($("code")); + const known = new Map(state.functions.flatMap(f => [[f.name, f], [f.addr, f]])); + const lines = []; + for (const group of data.items || []) for (const insn of group.insns || []) { + const row = el("div", undefined, "instruction"); + row.dataset.addr = insn.addr; + const content = el("div", undefined, "instruction-content"); + content.append(syntaxLine(insn.text, known)); + const detail = el("div", undefined, "instruction-detail"); + detail.append(el("span", `${insn.flow} · ${insn.len} bytes`)); + if (insn.target) { + detail.append(document.createTextNode(" → "), addressLink(insn.target)); + if (insn.target_name) detail.append(document.createTextNode(` <${insn.target_name}>`)); + } + content.append(detail); + for (const reference of state.context?.references.filter(r => r.from === insn.addr && ["data", "read", "write"].includes(r.kind)) || []) + content.append(el("div", `${reference.kind} → ${reference.to} · ${reference.string ? JSON.stringify(reference.string.text) : reference.name || reference.section || "mapped data"}`, "instruction-detail")); + for (const annotation of state.context?.annotations.filter(a => a.addr === insn.addr && a.field === "comment") || []) + content.append(el("div", `; ${annotation.value} — ${annotation.author} · ${annotation.resolution} match`, "instruction-comment")); + row.append(el("code", insn.addr, "instruction-address"), el("code", insn.bytes ?? "Unavailable", "instruction-bytes"), content); + $("code").append(row); + const comments = state.context?.annotations.filter(a => a.addr === insn.addr && a.field === "comment") || []; + lines.push(`${insn.addr} ${insn.bytes || ""} ${insn.text} [${insn.flow}${insn.target ? " → " + insn.target : ""}]${comments.map(a => `\n; ${a.value} — ${a.author} · ${a.resolution} match`).join("")}`); + } + if (state.contextError) $("code").prepend(el("p", `Annotations and reference context unavailable: ${state.contextError}`, "context-warning")); + if (!lines.length) message($("code"), "No decoded instructions for this function."); + state.code = lines.join("\n"); + $("copy-code").disabled = !state.code; + if (state.instructionTarget) { + const selected = [...$("code").querySelectorAll(".instruction")].find(row => row.dataset.addr === state.instructionTarget); + selected?.classList.add("selected"); + selected?.scrollIntoView({block:"center"}); + } else $("code").scrollTop = 0; +} +function renderOutgoing() { + clear($("code")); + const rows = state.context.references.filter(r => state.referenceDirection !== "data" || ["data", "read", "write"].includes(r.kind)); + const table = el("table", undefined, "references outgoing-references"); + const head = el("tr"); + for (const label of ["Instruction", "Kind", "Target", "Target context"]) head.append(el("th", label)); + const thead = el("thead"); thead.append(head); table.append(thead); + const body = el("tbody"); + for (const r of rows) { + const row = el("tr"), from = el("td"), target = el("td"), context = el("td"); + from.append(addressLink(r.from)); target.append(addressLink(r.to, r.function)); + if (r.name) context.append(el("strong", r.name)); + if (r.section) context.append(el("div", r.section, "muted")); + if (r.string) context.append(el("pre", r.string.text, "record-body"), el("small", `${r.string.encoding} string at ${r.string.addr} +${r.string.offset} bytes`, "muted")); + else context.append(el("code", r.bytes || "No mapped bytes", "data-bytes")); + row.append(from, el("td", r.kind), target, context); body.append(row); + } + table.append(body); $("code").append(table); + if (!rows.length) message($("code"), "No outgoing references reported in this view.", "References come from every recovered block; unresolved targets are not guessed."); + $("quality").textContent = `${rows.length} outgoing references · all recovered blocks · ${state.referenceDirection === "data" ? "strings and data" : "code and data"} · analysis snapshot`; + state.code = JSON.stringify(rows, null, 2); $("copy-code").disabled = !rows.length; +} +$("reference-direction").onchange = () => { + state.referenceDirection = $("reference-direction").value; + loadPane(); +}; +function renderCfg() { + clear($("code")); + const blocks = state.context.blocks; + $("quality").textContent = `${blocks.length} recovered basic blocks · ${state.context.complete ? "Complete" : "Incomplete: " + state.context.halt} · arrows are recovered successors`; + if (!blocks.length) { message($("code"), "No recovered basic blocks."); return; } + const byAddr = new Map(blocks.map(b => [b.addr, b])); + if (blocks.length <= 160) { + const levels = new Map(), queue = []; + if (byAddr.has(state.selected.addr)) { levels.set(state.selected.addr, 0); queue.push(state.selected.addr); } + for (let i = 0; i < queue.length; i++) for (const next of byAddr.get(queue[i])?.successors || []) { + if (byAddr.has(next) && !levels.has(next)) { levels.set(next, levels.get(queue[i]) + 1); queue.push(next); } + } + for (const b of blocks) if (!levels.has(b.addr)) levels.set(b.addr, Math.max(-1, ...levels.values()) + 1); + const layers = []; + for (const b of blocks) (layers[levels.get(b.addr)] ||= []).push(b); + const width = Math.max(1, ...layers.map(row => row?.length || 0)) * 330 + 80; + const height = layers.length * 190 + 50; + const canvas = el("div", undefined, "cfg-canvas"); + canvas.style.width = `${width}px`; canvas.style.height = `${height}px`; + const ns = "http://www.w3.org/2000/svg"; + const svg = document.createElementNS(ns, "svg"); + svg.setAttribute("width", String(width)); svg.setAttribute("height", String(height)); + svg.setAttribute("aria-label", "Recovered control flow edges"); svg.setAttribute("role", "img"); + const defs = document.createElementNS(ns, "defs"), marker = document.createElementNS(ns, "marker"); + for (const [key,value] of Object.entries({id:"cfg-arrow", viewBox:"0 0 10 10", refX:"9", refY:"5", markerWidth:"7", markerHeight:"7", orient:"auto-start-reverse"})) marker.setAttribute(key,value); + const tip = document.createElementNS(ns,"path"); tip.setAttribute("d","M 0 0 L 10 5 L 0 10 z"); tip.setAttribute("fill","#8cbcff"); marker.append(tip); defs.append(marker); svg.append(defs); canvas.append(svg); + const positions = new Map(); + layers.forEach((row, level) => row.forEach((block, column) => { + const left = 40 + (width - 80 - row.length * 330) / 2 + column * 330, top = 20 + level * 190; + positions.set(block.addr, {x:left + 145, y:top}); + const node = el("article", undefined, `cfg-node${block.unresolved ? " unresolved" : ""}`); + node.dataset.addr = block.addr; node.style.left = `${left}px`; node.style.top = `${top}px`; + node.append(el("strong", `${block.addr}${block.addr === state.selected.addr ? " · entry" : ""}`), + el("p", `${block.insns} instructions · ${block.terminator}${block.unresolved ? " · unresolved" : ""}`)); + const button = el("button", "View instructions", "button subtle"); button.onclick = () => goInstruction(block.addr); node.append(button); canvas.append(node); + })); + for (const block of blocks) for (const successor of block.successors) { + const from = positions.get(block.addr), to = positions.get(successor); + if (!to) continue; // External successors remain explicit in the table below. + const path = document.createElementNS(ns, "path"), y = from.y + 130; + const bend = Math.max(from.x, to.x) + 165; + path.setAttribute("d", to.y > from.y + ? `M ${from.x} ${y} C ${from.x} ${y+28}, ${to.x} ${to.y-28}, ${to.x} ${to.y}` + : `M ${from.x+145} ${from.y+65} C ${bend} ${from.y+65}, ${bend} ${to.y-25}, ${to.x} ${to.y}`); + path.setAttribute("marker-end", "url(#cfg-arrow)"); + const title = document.createElementNS(ns,"title"); title.textContent = `${block.addr} → ${successor}`; path.append(title); svg.append(path); + } + $("code").append(canvas); + } else $("code").append(el("p", "Large control flow graph: all recovered blocks and successors are listed below.", "context-warning")); + const table = el("table", undefined, "references cfg-table"), head = el("tr"), thead = el("thead"); + for (const label of ["Block", "End (exclusive)", "Ending", "Successors"]) head.append(el("th", label)); + thead.append(head); table.append(thead); const body = el("tbody"); + table.append(body); $("code").append(table); + let shown = 0; + const more = el("button", "", "button subtle cfg-more"); + const appendBlocks = () => { + const batch = blocks.slice(shown, shown + 200); + for (const block of batch) { + const row = el("tr"), address = el("td"), successors = el("td"); address.append(addressLink(block.addr)); + for (const addr of block.successors) successors.append(addressLink(addr), document.createTextNode(byAddr.has(addr) ? " " : " (outside recovered blocks) ")); + if (!block.successors.length) successors.textContent = block.unresolved ? "Unresolved" : "None"; + row.append(address, el("td", block.end), el("td", `${block.terminator}${block.unresolved ? " · unresolved" : ""}`), successors); body.append(row); + } + shown += batch.length; + more.hidden = shown >= blocks.length; + more.textContent = `Show next ${Math.min(200, blocks.length - shown)} blocks (${shown} / ${blocks.length} shown)`; + }; + more.onclick = appendBlocks; + appendBlocks(); $("code").append(more); + state.code = JSON.stringify(blocks, null, 2); $("copy-code").disabled = false; +} +function syntaxLine(line, known) { + const container = el("span", undefined, "line-source"); + const token = + /\/\/.*$|\/\*.*?\*\/|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\b(?:0x[0-9a-fA-F]+|\d+)\b|\b[a-zA-Z_][\w]*\b/g; + let position = 0; + for (const match of line.matchAll(token)) { + container.append( + document.createTextNode(line.slice(position, match.index)), + ); + const word = match[0]; + let style = ""; + if (word.startsWith("//") || word.startsWith("/*")) style = "comment"; + else if (word.startsWith('"') || word.startsWith("'")) style = "string"; + else if (/^\d/.test(word)) style = "number"; + else if ( + /^(if|else|for|while|do|return|switch|case|default|goto|break|continue|void|int|char|unsigned|signed|long|short|struct|const|static|uint\d+_t|int\d+_t)$/.test( + word, + ) + ) + style = "keyword"; + const target = known.get(word); + if (target && !["comment", "string"].includes(style)) { + const link = el("a", word, "call"); + link.href = `#workspace/${target.addr}`; + link.onclick = (e) => { + e.preventDefault(); + selectFunction(target); + }; + container.append(link); + } else container.append(el("span", word, style)); + position = match.index + word.length; + } + container.append(document.createTextNode(line.slice(position))); + return container; +} +function renderCode(code) { + state.code = code; + clear($("code")); + $("code").scrollTop = 0; + $("code").scrollLeft = 0; + const known = new Map( + state.functions.flatMap((f) => [ + [f.name, f], + [f.addr, f], + ]), + ); + code.split("\n").forEach((line, i) => { + const e = el("div", undefined, "code-line"); + e.append(el("span", String(i + 1), "line-number"), syntaxLine(line, known)); + $("code").append(e); + }); + $("copy-code").disabled = !code; +} +function renderVariables(variables) { + clear($("variables")); + $("variables-section").hidden = !variables?.length; + const dl = el("dl"); + for (const v of variables || []) + dl.append( + el("dt", `${v.role} · ${v.storage}`), + el("dd", `${v.type} ${v.name}`), + ); + $("variables").append(dl); +} +function renderReferences(data) { + clear($("code")); + state.code = ""; + $("copy-code").disabled = true; + const rows = data.items || []; + if (!rows.length) { + message( + $("code"), + "No incoming references reported.", + "This is the analysis result for this analysis snapshot.", + ); + return; + } + const table = el("table", undefined, "references"), + head = el("thead"), + tr = el("tr"); + for (const k of Object.keys(rows[0])) tr.append(el("th", k)); + head.append(tr); + table.append(head); + const body = el("tbody"); + for (const r of rows) { + const tr = el("tr"); + for (const [k, v] of Object.entries(r)) { + const td = el("td"), + f = state.functions.find( + (f) => + f.addr === String(v) || (k === "from_function" && f.name === v), + ); + if (f) { + const b = el("button", String(v), "task-open"); + b.onclick = () => selectFunction(f); + td.append(b); + } else + td.textContent = + typeof v === "object" ? JSON.stringify(v) : String(v ?? ""); + tr.append(td); + } + body.append(tr); + } + table.append(body); + $("code").append(table); + state.code = JSON.stringify(data, null, 2); + $("copy-code").disabled = false; +} +function renderCallGraph(data) { + clear($("code")); + const graph = el("div", undefined, "call-graph"); + const addr = state.selected.addr; + const callers = [...new Set(data.edges.filter(edge => edge.to === addr).map(edge => edge.from))]; + const callees = [...new Set(data.edges.filter(edge => edge.from === addr).map(edge => edge.to))]; + for (const [label, addresses] of [["Callers →", callers], ["Selected function", [addr]], ["→ Callees", callees]]) { + const column = el("section", undefined, "call-column"); + column.append(el("h3", `${label} (${addresses.length})`)); + for (const target of addresses) { + const f = state.functions.find(f => f.addr === target); + const current = label === "Selected function"; + const node = el(f && !current ? "button" : "div", f ? `${f.name}\n${target}` : target, `call-node${current ? " current" : ""}`); + if (f && !current) node.onclick = () => selectFunction(f); + column.append(node); + } + if (!addresses.length) column.append(el("p", "None recovered", "muted")); + graph.append(column); + } + $("code").append(graph); + state.code = JSON.stringify(data, null, 2); + $("copy-code").disabled = false; +} + +async function loadPane() { + if (!state.selected) return; + $("variables-section").hidden = true; + const serial = ++state.request, + f = state.selected, + pane = state.pane; + document + .querySelectorAll("[data-pane]") + .forEach((b) => + b.setAttribute("aria-selected", String(b.dataset.pane === pane)), + ); + $("reference-controls").hidden = pane !== "xrefs"; + syncTabs(); + if (pane === "notes") { + renderNotesPane(); + return; + } + $("quality").className = "quality"; + if (["cfg", "annotations"].includes(pane) || (pane === "xrefs" && state.referenceDirection !== "incoming")) { + message($("code"), "Loading function context…"); + $("quality").textContent = "Loading function context…"; + state.code = ""; + $("copy-code").disabled = true; + $("copy-analysis").disabled = true; + await state.contextPromise; + if (serial !== state.request) return; + if (state.contextError) { message($("code"), "Function context unavailable", state.contextError); $("quality").textContent = "Context unavailable"; return; } + if (pane === "cfg") renderCfg(); + else if (pane === "annotations") renderAnnotationPane(); + else renderOutgoing(); + $("analysis-command").textContent = pane === "annotations" + ? `e5r annotate ${shellQuote(state.project.binary)} list${state.project.log ? " --db " + shellQuote(state.project.log) : ""}` + : `Function ${pane === "cfg" ? "control flow" : "outgoing references"} from the analysis snapshot.`; + $("copy-analysis").disabled = pane !== "annotations"; + return; + } + $("quality").className = "quality"; + $("quality").textContent = + `Loading ${pane === "decompile" ? "pseudocode" : pane === "disas" ? "disassembly" : pane === "callgraph" ? "call graph" : "references"}…`; + message( + $("code"), + "Reading the function…", + "Results come directly from the e5r analysis engine.", + ); + $("copy-code").disabled = true; + const command = + pane === "disas" ? "disas" : pane === "xrefs" ? "xrefs" : "decompile"; + const options = [ + state.project.log ? "--db " + shellQuote(state.project.log) : "", + state.project.base ? "--base " + state.project.base : "", + state.project.arch ? "--arch " + shellQuote(state.project.arch) : "", + state.project.no_scan ? "--no-scan" : "", + state.project.threads !== null ? "--threads " + state.project.threads : "", + ] + .filter(Boolean) + .join(" "); + $("analysis-command").textContent = + `e5r ${command} ${shellQuote(state.project.binary)} ${f.addr}${options ? " " + options : ""}${pane === "disas" ? " --bytes" : ""}`; + $("copy-analysis").disabled = pane === "callgraph"; + if (pane === "callgraph") $("analysis-command").textContent = "Call graph from recovered CFG direct-call edges."; + try { + const data = await api(`/api/${pane}/${f.addr}`); + if (serial !== state.request) return; + if (pane === "decompile") { + const d = data.items?.[0]; + if (!d) throw new Error("No decompiler result for this function."); + const warnings = []; + if (!f.complete) warnings.push(`Incomplete control flow: ${f.halt}`); + if (d.unmodelled) warnings.push(`${d.unmodelled} unmodelled operations`); + if (d.conflicts.length) warnings.push(...d.conflicts); + $("quality").textContent = warnings.length + ? warnings.join(" · ") + : `${f.strength} boundary · ${d.locals} locals · ${d.gotos} gotos${d.asserted ? " · asserted signature" : ""} · Pseudocode is an analysis result.`; + $("quality").classList.toggle("warning", !!warnings.length); + renderCode(d.code); + renderVariables(d.variables); + } else if (pane === "disas") { + await state.contextPromise; + if (serial !== state.request) return; + renderDisassembly(data); + $("quality").textContent = + `${f.insns} instructions · ${f.strength} boundary · ${f.complete ? "Complete" : "Incomplete: " + f.halt}`; + } else if (pane === "callgraph") { + renderCallGraph(data); + $("quality").textContent = "Incoming → outgoing direct calls around this function. Click a node to navigate. Indirect targets are not resolved."; + } else { + renderReferences(data); + $("quality").textContent = + "Incoming references reported by e5r. Known function addresses open their function."; + } + } catch (e) { + if (serial !== state.request) return; + $("quality").textContent = "Output unavailable"; + message($("code"), "Could not show this function.", e.message); + } +} +for (const b of document.querySelectorAll("[data-view]")) + b.onclick = () => { + location.hash = b.dataset.view; + route(); + }; +for (const b of document.querySelectorAll("[data-pane]")) + b.onclick = () => { + state.pane = b.dataset.pane; + loadPane(); + }; +$("refresh").onclick = () => { refreshTasks(); refreshRecords(); }; +$("close-dialog").onclick = () => $("task-dialog").close(); +$("expand-task").onclick = () => { + const expanded = $("task-dialog").classList.toggle("full-view"); + $("expand-task").textContent = expanded ? "← Back to popup" : "Full view"; +}; +$("task-dialog").addEventListener("close", () => { + $("task-dialog").classList.remove("full-view"); + $("expand-task").textContent = "Full view"; +}); +$("task-search").oninput = renderTasks; +$("task-filter").onchange = renderTasks; +$("function-search").oninput = () => { + state.functionLimit = 400; + renderFunctions(); +}; +$("copy-agent").onclick = () => + copy($("agent-command").textContent, $("copy-agent")); +$("copy-analysis").onclick = () => + copy($("analysis-command").textContent, $("copy-analysis")); +$("wrap-code").onchange = () => { + $("code").classList.toggle("wrap", $("wrap-code").checked); + localStorage.setItem("e5r.wrap", String($("wrap-code").checked)); +}; +$("wrap-code").checked = localStorage.getItem("e5r.wrap") !== "false"; +$("code").classList.toggle("wrap", $("wrap-code").checked); +$("copy-code").onclick = () => copy(state.code, $("copy-code")); +function navigateHistory(forward = false) { + const source = forward ? state.future : state.history; + const destination = forward ? state.history : state.future; + const addr = source.pop(); + const f = state.functions.find(f => f.addr === addr); + if (!f) return; + if (state.selected) destination.push(state.selected.addr); + selectFunction(f, false); +} +$("back").onclick = () => navigateHistory(); +$("forward").onclick = () => navigateHistory(true); + +const mobileLayout = matchMedia("(max-width: 700px)"); +const wideLayout = matchMedia("(min-width: 1101px)"); +function syncLayout() { + const workspace = $("workspace"); + $("toggle-functions").setAttribute("aria-expanded", String(mobileLayout.matches + ? workspace.classList.contains("mobile-functions") + : !workspace.classList.contains("functions-hidden"))); + $("toggle-context").setAttribute("aria-expanded", String(workspace.classList.contains("context-open"))); +} +$("workspace").classList.toggle("context-open", wideLayout.matches); +$("toggle-functions").onclick = () => { + $("workspace").classList.toggle(mobileLayout.matches ? "mobile-functions" : "functions-hidden"); + if (mobileLayout.matches) $("workspace").classList.remove("context-open"); + syncLayout(); +}; +$("toggle-context").onclick = () => { + $("workspace").classList.toggle("context-open"); + $("workspace").classList.remove("mobile-functions"); + syncLayout(); +}; +mobileLayout.addEventListener("change", () => { + $("workspace").classList.remove("mobile-functions", "functions-hidden"); + syncLayout(); +}); +syncLayout(); +let codeSize = Number(localStorage.getItem("e5r.code-size") || 14); +if (!Number.isFinite(codeSize)) codeSize = 14; +function resizeCode(delta = 0) { + codeSize = Math.max(12, Math.min(22, codeSize + delta)); + document.documentElement.style.setProperty("--code-size", `${codeSize}px`); + $("font-size").textContent = `${codeSize}px`; + $("font-smaller").disabled = codeSize <= 12; + $("font-larger").disabled = codeSize >= 22; + localStorage.setItem("e5r.code-size", String(codeSize)); +} +$("font-smaller").onclick = () => resizeCode(-1); +$("font-larger").onclick = () => resizeCode(1); +resizeCode(); +window.addEventListener("hashchange", route); +window.addEventListener("keydown", (e) => { + if ($("workspace").hidden || $("task-dialog").open || + ["INPUT", "TEXTAREA", "SELECT"].includes(document.activeElement.tagName)) return; + if (e.key === "/") { + e.preventDefault(); + $("workspace").classList.remove("functions-hidden"); + if (mobileLayout.matches) { + $("workspace").classList.add("mobile-functions"); + $("workspace").classList.remove("context-open"); + } + syncLayout(); + $("function-search").focus(); + } else if (e.altKey && ["ArrowLeft", "ArrowRight"].includes(e.key)) { + e.preventDefault(); + navigateHistory(e.key === "ArrowRight"); + } else if (e.key === "Escape") { + $("workspace").classList.remove("mobile-functions"); + if (!wideLayout.matches) $("workspace").classList.remove("context-open"); + syncLayout(); + } +}); +const tabs = [...document.querySelectorAll("[data-pane]")]; +function syncTabs() { + tabs.forEach(tab => { + tab.tabIndex = tab.dataset.pane === state.pane ? 0 : -1; + tab.setAttribute("aria-selected", String(tab.dataset.pane === state.pane)); + }); +} +for (const tab of tabs) { + tab.addEventListener("click", syncTabs); + tab.addEventListener("keydown", e => { + if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(e.key)) return; + e.preventDefault(); + const index = e.key === "Home" ? 0 : e.key === "End" ? tabs.length - 1 + : (tabs.indexOf(tab) + (e.key === "ArrowRight" ? 1 : -1) + tabs.length) % tabs.length; + tabs[index].click(); + tabs[index].focus(); + }); +} +syncTabs(); + +async function boot() { + try { + state.project = await api("/api/project"); + const name = state.project.name || state.project.project.split("/").pop(); + $("all-projects").hidden = !state.project.collection; + $("project-name").textContent = name; + document.title = `${name} · e5r`; + $("binary-name").textContent = state.project.binary; + renderBinaryInfo(); + $("agent-command").textContent = + `e5r project task ${shellQuote(state.project.project)} list --json`; + renderBoard(); + await refreshTasks(); + await refreshRecords(); + if (state.project.binary_error) + error( + `Binary unavailable: ${state.project.binary_error}. Project tasks remain available.`, + ); + route(); + setInterval(() => { refreshTasks(); refreshRecords(); }, 5000); + } catch (e) { + error(`Could not open project: ${e.message}`); + $("sync").textContent = "Unavailable"; + } +} +boot(); diff --git a/crates/e5r-cli/src/dashboard/index.html b/crates/e5r-cli/src/dashboard/index.html new file mode 100644 index 0000000..f81e456 --- /dev/null +++ b/crates/e5r-cli/src/dashboard/index.html @@ -0,0 +1,273 @@ + + + + + + e5r · Project workspace + + + + +
+ +
+
+
+ Project overview +
+
+ Connecting… +
+
+ +
+
+
+
WHERE TO FOCUS
+

Reading the project.

+

+ Your work, its evidence, and the next step. +

+
+
+
+
+
+
+

Needs attention

+ +
+
+
+
+
+

Ready to pick up

+ +
+
+
+
+
+
+
+

Project work

+ Outcomes, owners, and the next action +
+
+ +
+
+
+ + + + + + + + + + +
Task / outcomeStateOwnerNext action
+
+ +
+

Findings, notes & reports

Written by agents · read-only
+
+

Binary information

Container and loader results
+
+ +
+
+
+
AGENT HANDOFF
+

Read the same tasks, claim work, leave evidence.

+ +
+ +
+
+
+
+
+ + +
+ Follow a function name to navigate · / search · Alt ← / → history +
+ + 14px + +
+
+
+
+

Functions

+ +
+ + +
+
+
+
+
+ SELECT A FUNCTION +
+

Explore the binary

+
+
+ +
+
+
+ +
+ +
+ Choose a function on the left. Click a known function name in code + to follow it. +
+
+
+ { } +

Select a function to begin.

+

+ Search by name or address on the left.
Follow calls in the code; use Back to retrace your steps. +

+
+
+
+ +
+
+
+ +

+
+

Read-only. Ask an agent to update this task through the CLI.

+
Revision history
+
+ + diff --git a/crates/e5r-cli/src/dashboard/projects.html b/crates/e5r-cli/src/dashboard/projects.html new file mode 100644 index 0000000..efccd2a --- /dev/null +++ b/crates/e5r-cli/src/dashboard/projects.html @@ -0,0 +1,114 @@ + + + + + + Projects · e5r + + + + +
+ +
+
+
+ Project collectionReading the library… +
+
+ Connecting… +
+
+ +
+
+
+
ACROSS ALL PROJECTS
+

Your investigations, together.

+

+ One place for the work that needs you and the projects behind + it. +

+
+
+
+
+
+

Needs attention

+ +
+
+
+
+
+
+

Projects

+ Open a project to read tasks or explore its code +
+
+ +
+
+
+ + + + + + + + + + + +
Project / binaryOpen workActiveNeeds attentionBinary
+
+ +
+
+
+
SHARED WITH AGENTS
+

+ Create projects once. Read their live state from any directory. +

+ e5r project list --json +
+ +
+
+
+
+ + diff --git a/crates/e5r-cli/src/dashboard/projects.js b/crates/e5r-cli/src/dashboard/projects.js new file mode 100644 index 0000000..becf2f6 --- /dev/null +++ b/crates/e5r-cli/src/dashboard/projects.js @@ -0,0 +1,170 @@ +"use strict"; +const $ = (id) => document.getElementById(id); +let projects = [], + last = ""; +function el(tag, text, className) { + const e = document.createElement(tag); + if (text !== undefined) e.textContent = text; + if (className) e.className = className; + return e; +} +function link(name, hash = "overview") { + return `/p/${encodeURIComponent(name)}/#${hash}`; +} +const availability = { + available: "Available", + missing: "Missing", + size_changed: "Size changed", + unreadable: "Unreadable", +}; +function render() { + const attention = projects.flatMap((p) => + p.attention.map((t) => ({ ...t, project: p.name })), + ), + errors = projects.filter((p) => p.error || p.availability !== "available"); + $("headline").textContent = attention.length + ? `${attention.length} ${attention.length === 1 ? "item needs" : "items need"} attention.` + : errors.length + ? `${errors.length} ${errors.length === 1 ? "project needs" : "projects need"} a closer look.` + : projects.length + ? "Your projects are in view." + : "Start your first investigation."; + $("headline-sub").textContent = projects.length + ? `${projects.length} projects · task updates appear here automatically. Open a project to keep its next step clear.` + : "Ask an agent to run e5r project new PATH --name NAME. Existing manifests can be imported with e5r project import."; + $("counts").replaceChildren(); + for (const [n, label, tone] of [ + [projects.length, "projects", ""], + [projects.reduce((n, p) => n + p.counts.active, 0), "active tasks", ""], + [attention.length + errors.length, "need attention", "attention"], + ]) { + const c = el("div", undefined, `count ${n ? tone : ""}`); + c.append(el("b", String(n)), el("span", label)); + $("counts").append(c); + } + $("attention").replaceChildren(); + $("attention-count").textContent = String(attention.length + errors.length); + $("attention").closest(".attention-panel").classList.toggle("quiet", !attention.length && !errors.length); + for (const t of attention.slice(0, 10)) { + const a = el("a", undefined, "work-row collection-row"); + a.href = link(t.project, `task/${t.id}`); + const title = el("div", undefined, "work-row-title"); + title.append( + el("span", `${t.project} / ${t.id} · ${t.title}`), + el( + "span", + t.state === "review" ? "Review" : "Blocked", + `state ${t.state}`, + ), + ); + a.append( + title, + el("div", t.why + (t.owner ? ` · ${t.owner}` : ""), "work-row-sub"), + ); + $("attention").append(a); + } + for (const p of errors) { + const a = el("a", undefined, "work-row collection-row"); + a.href = link(p.name); + a.append( + el("div", p.name, "work-row-title"), + el( + "div", + p.error || + `${availability[p.availability]} binary: ${p.binary}. Tasks remain available; use e5r project relocate ${p.name} PATH for a moved binary.`, + "work-row-sub", + ), + ); + $("attention").append(a); + } + if (!attention.length && !errors.length) { + const e = el("div", undefined, "empty"); + e.append( + el("strong", "Nothing is waiting on you."), + el( + "span", + "No task is blocked or awaiting review, and every binary path is available.", + ), + ); + $("attention").append(e); + } + if (attention.length > 10) + $("attention").append( + el( + "div", + `${attention.length - 10} more attention items inside their projects.`, + "empty", + ), + ); + renderProjects(); +} +function renderProjects() { + const query = $("search").value.toLowerCase(), + filter = $("filter").value; + const visible = projects.filter( + (p) => + `${p.name} ${p.binary || ""}`.toLowerCase().includes(query) && + (filter === "all" || + (filter === "attention" && + (p.attention.length || p.error || p.availability !== "available")) || + (filter === "active" && p.counts.active) || + (filter === "unavailable" && p.availability !== "available")), + ); + $("project-rows").replaceChildren(); + for (const p of visible) { + const tr = el("tr"), + name = el("td"), + a = el("a", p.name, "project-link"); + a.href = link(p.name); + name.append(a, el("span", p.binary || "Unreadable manifest", "task-note")); + if (p.error) name.append(el("span", p.error, "project-error")); + tr.append( + name, + el("td", `${p.counts.open} / ${p.counts.total}`), + el("td", String(p.counts.active)), + el("td", `${p.counts.blocked} blocked · ${p.counts.review} review`), + el( + "td", + availability[p.availability] || p.availability, + p.availability === "available" ? "muted" : "project-error", + ), + ); + $("project-rows").append(tr); + } + $("empty").hidden = !!visible.length; + $("empty").textContent = projects.length + ? "No projects match this view." + : "No projects registered. Ask an agent to register or import a project through the CLI."; +} +async function refresh() { + try { + const response = await fetch("/api/projects"), + data = await response.json(); + if (!response.ok) throw new Error(data.error); + const stamp = JSON.stringify(data); + projects = data.projects; + $("storage").textContent = data.root; + if (stamp !== last) { + last = stamp; + render(); + } + $("sync").textContent = "Projects up to date"; + $("error").hidden = true; + } catch (e) { + $("error").textContent = `Could not refresh projects: ${e.message}`; + $("error").hidden = false; + $("sync").textContent = "Unavailable"; + } +} +$("refresh").onclick = refresh; +$("search").oninput = renderProjects; +$("filter").onchange = renderProjects; +$("copy-command").onclick = async () => { + try { + await navigator.clipboard.writeText("e5r project list --json"); + $("copy-command").textContent = "Copied"; + setTimeout(() => ($("copy-command").textContent = "Copy command"), 1200); + } catch {} +}; +refresh(); +setInterval(refresh, 5000); diff --git a/crates/e5r-cli/src/dashboard/style.css b/crates/e5r-cli/src/dashboard/style.css new file mode 100644 index 0000000..772579a --- /dev/null +++ b/crates/e5r-cli/src/dashboard/style.css @@ -0,0 +1,991 @@ +:root { + color-scheme: dark; + --ground: #101318; + --surface: #181d25; + --raised: #222b36; + --line: #35404e; + --ink: #eef0f4; + --muted: #a3afbd; + --secondary: #8996a7; + --comment: #9eafa9; + --selection: #24374b; + --hover: #202832; + --focus: #eef0f4; + --flow: #94a6bd; + --accent: #8cbcff; + --code-size: 14px; + --amber: #f0bd69; + --green: #8ccead; + --blue: #9abdec; + --red: #f19992; + font-family: + system-ui, + -apple-system, + "Segoe UI", + sans-serif; + font-size: 14px; +} +* { + box-sizing: border-box; +} +body { + margin: 0; + background: var(--ground); + color: var(--ink); +} +button, +input, +select, +textarea { + font: inherit; +} +button { + cursor: pointer; +} +button, +a, +input, +select, +textarea { + outline-offset: 4px; +} +button:focus-visible, +a:focus-visible, +input:focus-visible, +select:focus-visible, +textarea:focus-visible { + outline: 2px solid var(--focus); +} +button:disabled { + opacity: 0.45; + cursor: default; +} +button { + color: inherit; +} +a { + color: var(--blue); +} +[hidden] { + display: none !important; +} +/* Project identity and navigation share one quiet strip; analysis owns the width. */ +.shell { min-height: 100vh; } +.rail { + display: flex; align-items: center; gap: 24px; padding: 0 24px; + height: 56px; border-bottom: 1px solid var(--line); background: var(--surface); +} +.brand { font-size: 24px; font-weight: 750; text-decoration: none; color: var(--ink); letter-spacing: -1px; } +.brand span, .rail-label, .rail-foot { display: none; } +.project-name { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 260px; } +.rail nav { display: flex; height: 100%; gap: 8px; } +.nav { background: transparent; border: 0; border-bottom: 2px solid transparent; padding: 0 16px; color: var(--muted); display: flex; align-items: center; text-decoration: none; } +.nav.selected { border-bottom-color: var(--accent); color: var(--ink); background: var(--raised); } +.nav:hover { background: var(--raised); } +.rail-label, .eyebrow { font-size: 12px; letter-spacing: .6px; color: var(--muted); font-weight: 600; } +.local-dot { display: inline-block; width: 6px; height: 6px; background: var(--green); border-radius: 50%; margin-right: 6px; } +.body { + min-width: 0; +} +.topbar { + height: 56px; + border-bottom: 1px solid var(--line); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 24px; + gap: 20px; +} +.crumb { + display: flex; + flex-direction: column; + gap: 4px; + font-weight: 600; +} +.crumb .muted { + font-size: 12px; + font-weight: 400; + overflow-wrap: anywhere; +} +.top-actions { + display: flex; + align-items: center; + gap: 16px; + flex-shrink: 0; +} +.top-actions .muted { + font-size: 12px; +} +.button { + border: 1px solid var(--line); + padding: 9px 13px; + border-radius: 5px; + white-space: nowrap; + background: var(--raised); + font-size: 12px; + font-weight: 550; +} +.button:hover { + filter: brightness(1.15); +} +.button.primary { + background: var(--accent); + border-color: var(--accent); + color: #19160f; +} +.button.subtle { + background: transparent; + color: var(--muted); +} +.muted { + color: var(--muted); + font-weight: 400; +} +.overview { + max-width: 1500px; + margin: auto; + padding: 24px 28px 40px; +} +.hero { + display: flex; + justify-content: space-between; + align-items: center; + gap: 36px; + padding: 4px 0 24px; +} +.hero h1 { + font-size: clamp(24px, 2.5vw, 30px); + font-weight: 600; + letter-spacing: -1px; + margin: 12px 0; +} +.hero p { + font-size: 13px; + line-height: 1.7; + max-width: 620px; + margin: 0; +} +.counts { + display: flex; + gap: 28px; +} +.count b { + font-size: 30px; + font-weight: 550; + display: block; +} +.count span { + color: var(--muted); + font-size: 12px; + white-space: nowrap; +} +.count.attention b { + color: var(--amber); +} +.overview-grid { + display: grid; + grid-template-columns: 1.2fr 1fr; + gap: 20px; +} +.panel { + background: var(--surface); + border: 1px solid var(--line); + border-radius: 8px; + overflow: hidden; +} +.attention-panel { + border-top: 2px solid var(--amber); +} +.attention-panel.quiet { border-top-color: var(--line); } +.attention-panel.quiet .badge { color: var(--muted); } +.attention-panel.quiet .empty { padding-top: 8px; } +.panel-head { + padding: 21px 23px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} +.panel-head h2, +.pane-head h2 { + font-size: 14px; + font-weight: 600; + margin: 0; +} +.panel-head .muted { + font-size: 12px; + display: block; + margin-top: 5px; +} +.badge { + font-size: 12px; + color: var(--amber); +} +.work-row { + display: block; + text-align: left; + width: 100%; + padding: 17px 23px; + border: 0; + border-top: 1px solid var(--line); + background: transparent; +} +.work-row:hover { + background: var(--raised); +} +.work-row-title { + display: flex; + justify-content: space-between; + gap: 15px; + align-items: baseline; + font-size: 13px; + font-weight: 550; +} +.work-row-sub { + color: var(--muted); + font-size: 12px; + line-height: 1.7; + margin-top: 7px; + overflow-wrap: anywhere; +} +.id { + font-family: ui-monospace, monospace; + font-size: 12px; + color: var(--muted); + margin-right: 8px; +} +.state { + font-size: 12px; + border-radius: 4px; + padding: 4px 7px; + font-weight: 500; + white-space: nowrap; + display: inline-block; + background: #262c35; + color: #c2cbd5; +} +.state.blocked { + background: #3b2e1d; + color: var(--amber); +} +.state.review { + background: #263345; + color: var(--blue); +} +.state.active { + background: #23362d; + color: var(--green); +} +.state.done { + background: #21292b; + color: #a7b6b3; +} +.empty { + padding: 28px 23px; + color: var(--muted); + font-size: 13px; + line-height: 1.8; +} +.empty strong { + display: block; + color: var(--ink); + font-weight: 500; +} +.tasks-panel { + margin-top: 25px; +} +.filters { + display: flex; + gap: 10px; +} +input, +select, +textarea { + background: #0e1116; + border: 1px solid #353f4c; + border-radius: 5px; + color: var(--ink); + padding: 10px 12px; + min-width: 0; +} +input::placeholder, +textarea::placeholder { + color: #8290a0; +} +.filters input { + width: 230px; + font-size: 12px; +} +.filters select { + font-size: 12px; +} +table { + border-collapse: collapse; + width: 100%; + font-size: 14px; + text-align: left; +} +th { + padding: 12px 23px; + color: var(--muted); + font-size: 12px; + font-weight: 500; + letter-spacing: 0.6px; + background: #151a21; +} +td { + border-top: 1px solid var(--line); + padding: 17px 23px; + vertical-align: top; +} +td:first-child { + width: 34%; +} +td:last-child { + width: 35%; + color: var(--muted); + line-height: 1.6; +} +td .task-open { + font: inherit; + background: none; + border: 0; + text-align: left; + padding: 0; + font-weight: 550; + line-height: 1.5; +} +td .task-open:hover { + color: var(--accent); +} +.table-scroll { + overflow: auto; +} +.task-note { + display: block; + font-size: 12px; + color: var(--muted); + margin-top: 5px; +} +.agent-strip { + display: flex; + align-items: center; + justify-content: space-between; + border: 1px dashed #303b48; + border-radius: 6px; + padding: 20px 23px; + margin-top: 25px; + gap: 15px; +} +.agent-strip p { + font-size: 12px; + margin: 7px 0 10px; + color: var(--muted); +} +code, +pre { + font-family: ui-monospace, "SFMono-Regular", Consolas, monospace; + font-size: 12px; +} +code { + overflow-wrap: anywhere; +} +.workspace { + height: calc(100dvh - 112px); + position: relative; + display: grid; + grid-template-columns: 260px minmax(0, 1fr); + grid-template-rows: 46px minmax(0, 1fr); + min-width: 0; +} +.function-pane { + display: flex; + flex-direction: column; + border-right: 1px solid var(--line); + min-height: 0; + background: var(--surface); +} +.pane-head { + padding: 20px 16px; + display: flex; + justify-content: space-between; + gap: 10px; + align-items: center; +} +.pane-head .muted { + font-size: 12px; +} +.function-search { + padding: 0 12px 15px; +} +.function-search input { + width: 100%; + font-size: 12px; +} +.function-list { + overflow: auto; + flex: 1; +} +.function-row { + width: 100%; + background: transparent; + border: 0; + border-left: 2px solid transparent; + text-align: left; + padding: 12px 15px; + display: block; + overflow: hidden; +} +.function-row:hover { + background: var(--raised); +} +.function-row.selected { + border-left-color: var(--accent); + background: var(--selection); +} +.function-row strong { + display: block; + font-family: ui-monospace, monospace; + font-size: 12px; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.function-row small { + display: block; + color: var(--muted); + font-family: ui-monospace, monospace; + font-size: 12px; + margin-top: 5px; +} +.code-pane { + display: flex; + flex-direction: column; + min-height: 0; + min-width: 0; +} +.code-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + gap: 12px; +} +.code-header h2 { + font: + 500 18px ui-monospace, + monospace; + margin: 7px 0 0; + overflow-wrap: anywhere; +} +.code-actions { + display: flex; + gap: 7px; +} +.code-actions .button { + padding: 7px 10px; + font-size: 12px; +} +.code-tabs { + display: flex; + gap: 25px; + border-bottom: 1px solid var(--line); + padding: 0 24px; +} +.code-tabs button { + border: 0; + background: none; + font-size: 12px; + color: var(--muted); + padding: 12px 0; + border-bottom: 2px solid transparent; +} +.code-tabs button[aria-selected="true"] { + color: var(--ink); + border-bottom-color: var(--accent); +} +.quality { + padding: 13px 24px; + border-bottom: 1px solid var(--line); + font-size: 12px; + color: var(--muted); + line-height: 1.6; + background: #12161c; +} +.quality.warning { + color: var(--amber); +} +.code { + overflow: auto; + flex: 1; + min-height: 0; + padding: 18px 0; + background: #0e1116; + counter-reset: line; +} +.code-line { + display: flex; + min-width: max-content; + font: + var(--code-size)/1.7 ui-monospace, + "SFMono-Regular", + Consolas, + monospace; + min-height: 23px; +} +.line-number { + flex: 0 0 58px; + text-align: right; + padding-right: 20px; + color: var(--secondary); + user-select: none; + position: sticky; + left: 0; + background: #0e1116; +} +.line-source { + white-space: pre; + padding-right: 30px; +} +.code-line:hover, +.code-line:hover .line-number { + background: var(--hover); +} +.keyword { + color: #c3a6dd; +} +.number { + color: #dfbc83; +} +.comment { + color: var(--comment); +} +.string { + color: #a7c7a1; +} +.call { + color: var(--blue); + text-decoration: underline dotted; + text-underline-offset: 3px; +} +.call:hover { + text-decoration-style: solid; +} +.code-empty { + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + color: var(--muted); + font-size: 12px; + line-height: 1.8; + padding: 30px; +} +.code-empty h3 { + color: var(--ink); + font-size: 17px; + font-weight: 500; +} +.code-mark { + font: + 45px ui-monospace, + monospace; + color: #414e5f; +} +.inspector { + background: var(--surface); + border-left: 1px solid var(--line); + overflow: auto; + padding: 24px 18px; +} +.inspector section { + margin-bottom: 32px; +} +.inspector .pane-head { + padding: 0 0 15px; +} +.context { + font-size: 12px; + line-height: 1.8; + color: var(--muted); + margin-top: 14px; +} +.context dl { + margin: 0; +} +.context dt { + font-size: 12px; + color: var(--muted); + margin-top: 16px; +} +.context dd { + margin: 4px 0 0; + color: var(--ink); + overflow-wrap: anywhere; +} +.inspector .work-row { + padding: 13px 0; +} +.inspector .work-row-title { + font-size: 12px; + display: block; +} +.inspector code { + display: block; + font-size: 12px; + line-height: 1.7; + margin: 15px 0; +} +.inspector .muted { + font-size: 12px; + line-height: 1.7; +} +.references { + width: 100%; + font-size: 12px; +} +#error { + padding: 14px 32px; + background: #3c2726; + color: #ffc7c0; + font-size: 12px; +} +dialog { + width: 690px; + max-width: calc(100vw - 32px); + max-height: 90vh; + overflow: auto; + border: 1px solid #404b59; + border-radius: 10px; + background: var(--surface); + color: var(--ink); + padding: 28px; + box-shadow: 0 25px 100px #0008; +} +dialog::backdrop { + background: #0009; + backdrop-filter: blur(3px); +} +.dialog-head { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 22px; +} +.dialog-head h2 { + font-size: 19px; + font-weight: 550; + margin: 9px 0 0; +} +label { + display: flex; + flex-direction: column; + gap: 8px; + font-size: 12px; + color: var(--muted); + margin-bottom: 17px; +} +label input, +label textarea, +label select { + width: 100%; + color: var(--ink); + font-size: 13px; +} +textarea { + resize: vertical; + line-height: 1.6; +} +.form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0 18px; +} +.dialog-foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border-top: 1px solid var(--line); + padding-top: 20px; + margin-top: 20px; +} +.dialog-foot .muted { + font-size: 12px; +} +.dialog-foot .button { + flex-shrink: 0; +} +#form-error { + padding: 12px; + background: #3b2725; + color: #ffc7c0; + font-size: 12px; + border-radius: 5px; + line-height: 1.7; +} +details { + font-size: 12px; + color: var(--muted); +} +summary { + cursor: pointer; +} +.history-row { + padding: 10px 0; + line-height: 1.7; + border-bottom: 1px solid var(--line); +} + +.code-wrap { + display: flex; + flex-direction: row; + align-items: center; + gap: 7px; + margin: 0 0 0 auto; + font-size: 12px; + white-space: nowrap; +} +.code-wrap input { + width: 13px; + height: 13px; + margin: 0; + padding: 0; + accent-color: var(--accent); +} +.code.wrap .code-line { + min-width: 0; + align-items: flex-start; +} +.code.wrap .line-source { + white-space: pre-wrap; + overflow-wrap: anywhere; + min-width: 0; + flex: 1; +} +.code.wrap .line-number { + flex-shrink: 0; +} +@media (max-width: 600px) { + .code-tabs { + flex-wrap: wrap; + } + .code-wrap { + padding: 8px 0; + } +} + +.collection-row { + color: var(--ink); + text-decoration: none; +} +.project-link { + font-weight: 600; + text-decoration: none; + color: var(--ink); +} +.project-link:hover { + color: var(--accent); + text-decoration: underline; +} +.project-error { + color: var(--amber); + font-size: 12px; + display: block; + overflow-wrap: anywhere; +} +.all-projects { + margin-top: 0; + font-size: 12px; + text-decoration: none; + color: var(--muted); +} +.all-projects:hover { + color: var(--ink); +} +.rail a.nav { + text-decoration: none; +} +@media (max-width: 900px) { + .all-projects { + margin: 0; + font-size: 12px; + } +} + +/* Reading long evidence should not compete with prerequisites. */ +.task-detail dt { color: var(--muted); margin-top: 20px; font-size: 12px; } +.task-detail dd { margin: 8px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.7; } +.task-evidence { min-height: 150px; padding: 16px; background: var(--surface); } +dialog.full-view { width: calc(100vw - 32px); height: calc(100vh - 32px); max-height: calc(100vh - 32px); } +.record { padding: 16px; border-bottom: 1px solid #404b59; } +.record-body { white-space: pre-wrap; overflow-wrap: anywhere; font-family: inherit; line-height: 1.7; } +.call-graph { display: grid; gap: 14px; padding: 24px; } +.call-node { background: var(--surface); color: var(--ink); font-family: inherit; cursor: pointer; padding: 16px; border: 1px solid #404b59; border-radius: 8px; overflow-wrap: anywhere; } + + +/* Layout controls keep context reachable, including on small screens. */ +.workspace-tools { grid-column: 1 / -1; display: flex; align-items: center; gap: 16px; padding: 6px 12px; border-bottom: 1px solid var(--line); background: var(--surface); } +.layout-controls, .reading-controls { display: flex; align-items: center; gap: 6px; } +.workspace-tools .button { padding: 5px 9px; } +.workspace-tools button[aria-expanded="true"] { color: var(--ink); border-color: var(--accent); background: var(--selection); } +.workspace-hint { font-size: 12px; color: var(--muted); } +.reading-controls { margin-left: auto; flex-shrink: 0; } +.reading-controls output { font-size: 12px; color: var(--muted); min-width: 32px; text-align: center; } +kbd { font: inherit; color: var(--ink); } +.workspace .inspector { display: none; } +.workspace.context-open { grid-template-columns: 260px minmax(0, 1fr) 280px; } +.workspace.context-open .inspector { display: block; } +.workspace.functions-hidden { grid-template-columns: minmax(0, 1fr); } +.workspace.functions-hidden .function-pane { display: none; } +.workspace.functions-hidden.context-open { grid-template-columns: minmax(0, 1fr) 280px; } +.function-row strong { font-size: 13px; } +.function-row small { font-size: 12px; } +.function-row { padding: 10px 14px; } +.function-search input { font-size: 13px; } +.code-header h2 { font-size: 20px; } +.code-line { border-left: 2px solid transparent; } +.code-line:focus-within { border-left-color: var(--focus); background: var(--hover); } +.code-tabs { gap: 20px; padding: 0 20px; } +.code-tabs button { font-size: 13px; } +.quality { font-size: 12px; padding: 9px 20px; } +.quality.warning { background: #302719; border-left: 3px solid var(--amber); } +.inspector { padding: 18px; } +.inspector section { margin-bottom: 24px; } +.context dt { margin-top: 12px; } +.context dd { font-size: 13px; } +.work-row-title { font-size: 14px; } +.work-row-sub, .task-note, .record, details { font-size: 13px; } +.record summary { color: var(--ink); font-weight: 550; } +#records:has(.record) { border-top: 1px solid var(--line); } +.overview > .panel + .panel { margin-top: 20px; } +.count { border-left: 1px solid var(--line); padding-left: 20px; } +.count b { font-variant-numeric: tabular-nums; } +.call-graph { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr); align-items: start; } +.call-column { display: flex; flex-direction: column; gap: 12px; min-width: 0; } +.call-column h3 { font-size: 13px; color: var(--muted); margin: 0 0 4px; } +.call-node { text-align: left; white-space: pre-wrap; font-size: 13px; line-height: 1.7; border-radius: 4px; } +.call-node.current { border-color: var(--accent); background: var(--selection); } +button.call-node:hover { border-color: var(--accent); } +@media (max-width: 1100px) { + .workspace.context-open { grid-template-columns: 220px minmax(0, 1fr); } + .workspace { grid-template-columns: 220px minmax(0, 1fr); } + .workspace.functions-hidden.context-open { grid-template-columns: minmax(0, 1fr); } + .workspace.context-open .inspector { position: absolute; top: 46px; right: 0; bottom: 0; width: min(320px, 90%); z-index: 3; box-shadow: -16px 0 40px #0008; } + .workspace-hint { display: none; } + .filters input { width: 180px; } + .hero { align-items: flex-start; flex-direction: column; gap: 20px; } + .top-actions #sync { display: none; } +} +@media (max-width: 700px) { + .rail { gap: 12px; padding: 0 12px; } + .rail .project-name { display: none; } + .rail nav { margin-left: auto; } + .nav { padding: 0 10px; } + .all-projects { font-size: 12px; } + .topbar { padding: 0 14px; } + .crumb { min-width: 0; } + .crumb .muted { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .top-actions #refresh { display: none; } + .overview { padding: 20px 14px; } + .overview-grid { grid-template-columns: 1fr; } + .counts { gap: 16px; flex-wrap: wrap; } + .count { padding-left: 12px; } + .tasks-panel .panel-head { flex-direction: column; align-items: flex-start; } + .filters { width: 100%; } + .filters input { width: 100%; } + .panel-head { padding: 18px; } + td, th { padding: 12px 16px; } + td:first-child { min-width: 160px; } + td:last-child { min-width: 180px; } + .workspace, .workspace.context-open { grid-template-columns: minmax(0, 1fr); } + .workspace .function-pane { display: none; } + .workspace.mobile-functions .function-pane { display: flex; position: absolute; top: 46px; left: 0; bottom: 0; width: min(300px, 90%); z-index: 4; box-shadow: 16px 0 40px #0008; } + .workspace-tools { gap: 4px; padding: 6px; } + .workspace-tools .button { padding: 5px 7px; } + .code-header { flex-direction: column; align-items: flex-start; padding: 14px; } + .code-header h2 { font-size: 18px; } + .code-tabs { padding: 0 14px; gap: 15px; flex-wrap: wrap; } + .code-tabs button { font-size: 12px; } + .line-number { flex-basis: 40px; padding-right: 10px; } + .quality { padding: 9px 14px; } + .call-graph { grid-template-columns: 1fr; padding: 16px; } + .agent-strip { flex-direction: column; align-items: flex-start; } + .form-grid { grid-template-columns: 1fr; } + dialog { padding: 20px; } + .dialog-head { align-items: flex-start; gap: 12px; } + .dialog-foot { flex-wrap: wrap; } +} + +.function-notes { font-size: 13px; line-height: 1.7; overflow-wrap: anywhere; } +.function-note-preview { display: block; width: 100%; text-align: left; background: transparent; border: 0; border-top: 1px solid var(--line); padding: 12px 0; line-height: 1.7; } +.function-note-preview strong { display: block; font-size: 13px; } +.function-note-preview:hover strong { color: var(--accent); } +.note-excerpt { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; overflow: hidden; white-space: pre-wrap; color: var(--muted); margin: 6px 0; } +.function-note-preview small { display: block; font-size: 12px; } +.function-note { padding: 20px 24px; border-bottom: 1px solid var(--line); } +.function-note h3 { margin: 0; font-size: 17px; font-weight: 600; overflow-wrap: anywhere; } +.function-note .record-body { font-size: 14px; } + +/* Analysis views use the same readable surfaces as project records. */ +.code-tabs { flex-wrap: wrap; gap: 0 20px; } +.binary-panel { margin-top: 20px; } +.binary-info { padding: 0 24px 20px; } +.binary-info dl { display: grid; grid-template-columns: minmax(130px, 200px) minmax(0, 1fr); gap: 12px 20px; margin: 0; } +.binary-info dt { color: var(--muted); font-size: 13px; } +.binary-info dd { margin: 0; overflow-wrap: anywhere; line-height: 1.6; } +.loader-warnings, .context-warning { padding: 12px 24px; color: var(--amber); background: #302719; border-left: 3px solid var(--amber); } +.loader-warnings p { white-space: pre-wrap; overflow-wrap: anywhere; } +.reference-controls { padding: 10px 20px; background: var(--surface); } +.reference-controls label { display: flex; flex-direction: row; align-items: center; margin: 0; } +.reference-controls select { width: auto; padding: 6px 10px; } +.annotation-row { padding: 16px 20px; border-bottom: 1px solid var(--line); font-size: 13px; line-height: 1.7; } +.annotation-row strong { font-size: 13px; } +.annotation-text { white-space: pre-wrap; overflow-wrap: anywhere; color: var(--ink); } +.annotation-row.warning { border-left: 3px solid var(--amber); } +.inspector .annotation-row { padding: 12px 0; } +.instruction { display: grid; grid-template-columns: 100px 150px minmax(0, 1fr); padding: 8px 16px; gap: 14px; border-left: 3px solid transparent; } +.instruction:hover { background: var(--hover); } +.instruction.selected { background: var(--selection); border-left-color: var(--accent); } +.instruction code, .instruction .line-source { font-size: var(--code-size); } +.instruction-address { color: var(--secondary); } +.instruction-bytes { color: var(--secondary); overflow-wrap: anywhere; } +.instruction-content { min-width: 0; } +.instruction .line-source { display: block; white-space: pre; padding: 0; } +.code.wrap .instruction .line-source { white-space: pre-wrap; overflow-wrap: anywhere; } +.instruction-detail { color: var(--secondary); font-size: 12px; line-height: 1.7; overflow-wrap: anywhere; } +.instruction-comment { color: var(--comment); font-size: 13px; line-height: 1.7; white-space: pre-wrap; overflow-wrap: anywhere; } +.address-link { font: 13px ui-monospace, monospace; color: var(--blue); border: 0; padding: 0; background: transparent; text-decoration: underline; text-underline-offset: 3px; } +.data-bytes { display: block; overflow-wrap: anywhere; } +.outgoing-references td:first-child, .cfg-table td:first-child { width: auto; } +.outgoing-references td:last-child, .cfg-table td:last-child { width: auto; } +.cfg-canvas { position: relative; margin: 20px; } +.cfg-canvas svg { position: absolute; inset: 0; } +.cfg-canvas svg > path { fill: none; stroke: var(--flow); stroke-width: 2; } +.cfg-node { position: absolute; width: 290px; height: 130px; padding: 14px; background: var(--surface); border: 1px solid var(--line); border-radius: 6px; font-size: 13px; } +.cfg-node strong { color: var(--ink); font-family: ui-monospace, monospace; } +.cfg-node p { font-size: 12px; color: var(--muted); margin: 8px 0; } +.cfg-node.unresolved { border-color: var(--amber); } +@media (max-width: 700px) { + .binary-info { padding: 0 18px 18px; } + .binary-info dl { grid-template-columns: 1fr; gap: 4px; } + .binary-info dd { margin-bottom: 12px; } + .instruction { grid-template-columns: 80px minmax(0, 1fr); gap: 8px; padding: 10px; } + .instruction-bytes { grid-column: 2; grid-row: 1; } + .instruction-content { grid-column: 1 / -1; } + .code-tabs { gap: 0 15px; } +} + +/* Secondary facts stay readable without competing with the instruction text. */ +.function-row small, .data-bytes { color: var(--secondary); } +.function-row.selected strong { font-weight: 650; } +.function-row.selected small, +.instruction.selected .instruction-address, +.instruction.selected .instruction-bytes, +.instruction.selected .instruction-detail { color: var(--muted); } +.instruction:focus-within { outline: 2px solid var(--focus); outline-offset: -2px; } +.cfg-canvas marker path { fill: var(--flow); } +.function-row:focus-visible, .nav:focus-visible, .code-tabs button:focus-visible { outline-offset: -3px; } diff --git a/crates/e5r-cli/src/json.rs b/crates/e5r-cli/src/json.rs index 2276ceb..5aeff16 100644 --- a/crates/e5r-cli/src/json.rs +++ b/crates/e5r-cli/src/json.rs @@ -272,9 +272,11 @@ pub fn stats(p: &Program) -> Stats { pub struct InsnOut { addr: String, len: u8, + bytes: Option, text: String, flow: String, target: Option, + target_name: Option, } #[derive(Serialize)] @@ -287,6 +289,11 @@ fn insn_out(p: &Program, i: &Insn) -> InsnOut { InsnOut { addr: hex(i.addr), len: i.len, + bytes: p + .object + .memory + .slice(i.addr, u64::from(i.len)) + .map(|bytes| bytes.iter().map(|b| format!("{b:02x}")).collect()), text: e5r_arch::format(&p.object.arch, i, false).replace('\t', " "), flow: match i.flow { e5r_arch::Flow::Next => "next", @@ -301,6 +308,7 @@ fn insn_out(p: &Program, i: &Insn) -> InsnOut { } .to_string(), target: i.flow.target().map(hex), + target_name: i.flow.target().and_then(|at| p.name_of(at)), } } @@ -768,6 +776,70 @@ pub fn archive(a: &e5r_format::archive::Archive) -> ArchiveOut { } } +#[derive(Serialize)] +pub struct BunModuleOut { + index: u32, + name: String, + loader: u8, + loader_name: String, + encoding: String, + format: String, + side: String, + contents_offset: String, + contents_size: u64, + sourcemap_size: u32, + bytecode_size: u32, + entry: bool, +} + +#[derive(Serialize)] +pub struct BunOut { + schema: &'static str, + offset: String, + byte_count: u64, + trailer_offset: String, + record_size: u64, + entry_point: u32, + flags: u32, + argv: Option, + argv_size: u64, + modules: Vec, + warnings: Vec, +} + +pub fn bun(graph: &e5r_format::bun::Graph) -> BunOut { + BunOut { + schema: SCHEMA, + offset: format!("{:#x}", graph.offset), + byte_count: graph.byte_count, + trailer_offset: format!("{:#x}", graph.trailer_offset), + record_size: graph.record_size, + entry_point: graph.entry_point_id, + flags: graph.flags, + argv: std::str::from_utf8(graph.argv).ok().map(str::to_string), + argv_size: graph.argv.len() as u64, + modules: graph + .modules + .iter() + .map(|m| BunModuleOut { + index: m.index, + name: m.name.clone(), + loader: m.loader, + loader_name: m.loader_name(), + encoding: m.encoding_name(), + format: m.format_name(), + side: m.side_name(), + contents_offset: format!("{:#x}", m.contents_offset), + contents_size: m.contents.len() as u64, + sourcemap_size: m.sourcemap_len, + bytecode_size: m.bytecode_len, + entry: m.entry, + }) + .collect(), + warnings: graph.warnings.clone(), + } +} + #[derive(Serialize)] pub struct BaseOut { name: String, diff --git a/crates/e5r-cli/src/main.rs b/crates/e5r-cli/src/main.rs index 511e0f5..af603b5 100644 --- a/crates/e5r-cli/src/main.rs +++ b/crates/e5r-cli/src/main.rs @@ -12,13 +12,16 @@ mod addr; mod annotate; mod batch; mod budget; +mod dashboard; mod json; mod out; mod patch; mod print; mod progress; +mod projects; mod repl; mod shell; +mod work; use std::path::PathBuf; use std::process::ExitCode; @@ -99,6 +102,12 @@ pub struct Common { #[derive(Subcommand)] pub enum Command { + /// Manage and monitor every registered project from one local UI. + Ui { + /// Loopback port; zero chooses a free port. + #[arg(long, default_value_t = 7879)] + port: u16, + }, /// Container, architecture, entry point, and what the loader noticed. Info(Common), /// Sections and their mapping. @@ -306,6 +315,25 @@ pub enum Command { #[arg(long)] symbols: bool, }, + /// List the modules embedded in a Bun standalone executable. + /// + /// `bun build --compile` stores its scripts in a graph after the native + /// image. The graph is source and bytecode, so it is listed rather than + /// loaded, for the same reason an archive is. + Bun { + #[command(flatten)] + common: Common, + /// Write this module's bytes to standard output. + /// + /// A path as the graph spells it, a decimal index, or a unique + /// suffix that starts on a `/`. + #[arg(long, value_name = "MODULE")] + extract: Option, + /// Which graph, when the file has more than one. 0 is the last, + /// which is the one Bun reads. + #[arg(long, value_name = "N", default_value_t = 0)] + which: usize, + }, /// Write a completion script for a shell. /// /// Generated from the command tree, so it cannot describe a command that @@ -434,13 +462,66 @@ pub enum PatchCommand { /// What to do with a project file. #[derive(Subcommand)] pub enum ProjectCommand { + /// List all globally registered projects and their live task state. + List { + /// Emit versioned JSON. + #[arg(long)] + json: bool, + }, + /// Copy an existing project and task history into the global directory. + Import { + /// Existing project file. + project: PathBuf, + /// Global project name. + #[arg(long)] + name: String, + /// Base for legacy relative file references (default: manifest directory). + #[arg(long)] + base_dir: Option, + }, + /// Update the path of a moved binary, refusing different content. + Relocate { + /// Global name or project file. + project: PathBuf, + /// New binary path. + binary: PathBuf, + }, + /// Manage durable tasks shared by people and agents. + Task { + /// Global project name or project file. + project: PathBuf, + #[command(subcommand)] + what: work::Command, + }, + /// Write or read findings, notes and reports using the revisioned content store. + Record { + project: PathBuf, + #[arg(value_parser = ["finding", "note", "report"])] + kind: String, + #[command(subcommand)] + what: work::Command, + }, + /// Open a local project dashboard and decompiler workspace. + Dashboard { + /// Global project name or project file. + project: PathBuf, + /// Loopback port; zero chooses a free port. + #[arg(long, default_value_t = 7879)] + port: u16, + /// Override a moved binary path; content must still match. + #[arg(long)] + binary: Option, + }, /// Record what it takes to reopen this session. New { /// The binary. binary: PathBuf, - /// Where to write the project. - #[arg(short, long)] - out: PathBuf, + /// Explicit local file; omitted projects go into the global directory. + #[arg(short, long, conflicts_with = "name")] + out: Option, + /// Global name (defaults to the binary filename). + #[arg(long)] + name: Option, /// An analysis option, as `key=value`. Repeatable. #[arg(long = "set")] settings: Vec, @@ -533,7 +614,7 @@ impl Command { // Both writers are redirected into a file the first time and // never read on screen. Command::Completions { .. } | Command::Manpage => None, - Command::Project { .. } => Some((out::When::Auto, false)), + Command::Project { .. } | Command::Ui { .. } => Some((out::When::Auto, false)), _ => { let c = self.common(); if c.json { @@ -556,13 +637,17 @@ impl Command { Command::Annotate { common, .. } | Command::Batch { common, .. } | Command::Diff { common, .. } => common, - Command::Project { .. } | Command::Completions { .. } | Command::Manpage => { + Command::Project { .. } + | Command::Ui { .. } + | Command::Completions { .. } + | Command::Manpage => { unreachable!("handled before a file is opened") } Command::Vtables { common, .. } | Command::Classes { common, .. } | Command::Overlay { common, .. } | Command::Archive { common, .. } + | Command::Bun { common, .. } | Command::Repl { common } | Command::Patch { common, .. } | Command::Emulate { common, .. } @@ -607,6 +692,7 @@ pub fn run(cli: &Cli, w: &mut out::Out) -> Result { // A project names its own files; none of them is the binary the other // commands open up front. Command::Project { what } => return project(w, what), + Command::Ui { port } => return dashboard::serve_all(*port), Command::Completions { shell } => { let script = shell::completions(&Cli::command(), *shell); for line in script.lines() { @@ -633,6 +719,17 @@ pub fn run(cli: &Cli, w: &mut out::Out) -> Result { if let Command::Archive { common, symbols } = &cli.command { return print::archive(w, &data, *symbols, common.json); } + // Same reason as an archive: the answer is about bytes the loader does + // not map as code, and analyzing the native image first would spend + // seconds to ignore them. + if let Command::Bun { + common, + extract, + which, + } = &cli.command + { + return print::bun(w, &data, extract.as_deref(), *which, common.json); + } let arch = match &common.arch { Some(a) => Some( @@ -749,6 +846,11 @@ pub fn dispatch( Command::Overlay { common } => { return print::overlay(w, &program.object, data, common.json); } + Command::Bun { + common, + extract, + which, + } => return print::bun(w, data, extract.as_deref(), *which, common.json), _ => {} } @@ -950,9 +1052,42 @@ pub fn dispatch( /// The project commands, which open the files they name and nothing else. fn project(w: &mut out::Out, what: &ProjectCommand) -> Result { match what { + ProjectCommand::List { json } => projects::list(w, *json), + ProjectCommand::Import { + project, + name, + base_dir, + } => projects::import(w, project, name, base_dir.as_deref()), + ProjectCommand::Relocate { project, binary } => { + projects::relocate(w, &projects::resolve(project)?, binary) + } + ProjectCommand::Task { project, what } => { + let project = projects::resolve(project)?; + patch::project_read(&project)?; + work::run(w, &project, what) + } + ProjectCommand::Record { + project, + kind, + what, + } => { + let project = projects::resolve(project)?; + patch::project_read(&project)?; + work::run_store( + w, + e5r_db::work::Store::new(e5r_db::work::beside(&project).join(kind)), + what, + ) + } + ProjectCommand::Dashboard { + project, + port, + binary, + } => dashboard::serve(&projects::resolve(project)?, *port, binary.as_deref()), ProjectCommand::New { binary, out, + name, settings, log, } => { @@ -961,11 +1096,22 @@ fn project(w: &mut out::Out, what: &ProjectCommand) -> Result { let data = map_file(&file).map_err(|e| format!("{}: {e}", binary.display()))?; let object = e5r_format::load(&data, &LoadOptions::default()) .map_err(|e| format!("{}: {e}", binary.display()))?; - patch::project_new(w, binary, &object, &data, settings, log.as_deref(), out) + if let Some(out) = out { + patch::project_new(w, binary, &object, &data, settings, log.as_deref(), out) + } else { + let project = + patch::make_project(binary, &object, &data, settings, log.as_deref())?; + let name = name + .clone() + .unwrap_or_else(|| e5r_db::registry::default_name(binary)); + projects::create(w, &name, &project) + } + } + ProjectCommand::Show { project } => { + patch::project_show(w, &patch::project_read(&projects::resolve(project)?)?) } - ProjectCommand::Show { project } => patch::project_show(w, &patch::project_read(project)?), ProjectCommand::Verify { project, binary } => { - let proj = patch::project_read(project)?; + let proj = patch::project_read(&projects::resolve(project)?)?; let path = binary .clone() .unwrap_or_else(|| PathBuf::from(&proj.binary.path)); @@ -979,7 +1125,13 @@ fn project(w: &mut out::Out, what: &ProjectCommand) -> Result { signatures, patches, log, - } => patch::project_add(w, project, signatures, patches, log.as_deref()), + } => patch::project_add( + w, + &projects::resolve(project)?, + signatures, + patches, + log.as_deref(), + ), } } diff --git a/crates/e5r-cli/src/patch.rs b/crates/e5r-cli/src/patch.rs index 33d3c3d..d6e9c4a 100644 --- a/crates/e5r-cli/src/patch.rs +++ b/crates/e5r-cli/src/patch.rs @@ -390,6 +390,20 @@ pub fn project_new( log: Option<&Path>, out: &Path, ) -> R { + let proj = make_project(binary, o, data, settings, log)?; + std::fs::write(out, proj.to_text()).map_err(|e| format!("{}: {e}", out.display()))?; + outln!(w, "{} written", out.display()); + Ok(exit::OK) +} + +/// Build a manifest with absolute references so opening it never depends on cwd. +pub fn make_project( + binary: &Path, + o: &Object, + data: &[u8], + settings: &[String], + log: Option<&Path>, +) -> Result { let mut proj = Project::for_binary(&binary.display().to_string(), data); proj.load.base = Some(o.image_base); proj.load.arch = Some(o.arch.clone()); @@ -403,9 +417,11 @@ pub fn project_new( proj.log = log .map(|p| p.display().to_string()) .or_else(|| Some(annotate::default_path(binary).display().to_string())); - std::fs::write(out, proj.to_text()).map_err(|e| format!("{}: {e}", out.display()))?; - outln!(w, "{} written", out.display()); - Ok(exit::OK) + e5r_db::registry::normalize( + &mut proj, + &std::env::current_dir().map_err(|e| e.to_string())?, + )?; + Ok(proj) } /// Read a project file. @@ -457,6 +473,10 @@ pub fn project_add( if let Some(l) = log { proj.log = Some(l.display().to_string()); } + e5r_db::registry::normalize( + &mut proj, + &std::env::current_dir().map_err(|e| e.to_string())?, + )?; std::fs::write(path, proj.to_text()).map_err(|e| format!("{}: {e}", path.display()))?; outln!( w, diff --git a/crates/e5r-cli/src/print.rs b/crates/e5r-cli/src/print.rs index 734127a..bfab953 100644 --- a/crates/e5r-cli/src/print.rs +++ b/crates/e5r-cli/src/print.rs @@ -1041,6 +1041,117 @@ pub fn archive(w: &mut Out, data: &[u8], symbols: bool, as_json: bool) -> R { Ok(exit::OK) } +/// Modules embedded by `bun build --compile`. +pub fn bun(w: &mut Out, data: &[u8], extract: Option<&str>, which: usize, as_json: bool) -> R { + if extract.is_some() && as_json { + return Err( + "--extract writes the module's bytes, so it cannot be combined with --json".into(), + ); + } + let opts = e5r_format::bun::Options { + which, + ..e5r_format::bun::Options::default() + }; + let graph = e5r_format::bun::open_with(data, &opts).map_err(|e| e.to_string())?; + if let Some(query) = extract { + let index = match graph.find(query) { + Ok(index) => index, + Err(e5r_core::Error::NotRecognized { .. }) => { + eprintln!("no module {query}"); + return Ok(exit::NOT_FOUND); + } + Err(e) => return Err(e.to_string()), + }; + let module = &graph.modules[index]; + eprintln!( + "module {} ({} bytes, {})", + module.name, + module.contents.len(), + module.loader_name() + ); + std::io::Write::write_all(&mut std::io::stdout(), module.contents) + .map_err(|e| e.to_string())?; + return Ok(exit::OK); + } + if as_json { + return json::emit(w, &json::bun(&graph)); + } + outln!( + w, + "bun standalone, {} module(s), entry {}, record {} bytes, at {:#x}", + graph.modules.len(), + graph.entry_point_id, + graph.record_size, + graph.offset + ); + outln!( + w, + "flags {:#x}{}", + graph.flags, + flag_suffix(graph.flags) + ); + if !graph.argv.is_empty() { + match std::str::from_utf8(graph.argv) { + Ok(text) => outln!(w, "argv {text}"), + Err(_) => outln!(w, "argv {} bytes, not utf-8", graph.argv.len()), + } + } + if !graph.modules.is_empty() { + outln!(w, ""); + outln!( + w, + "{:>4} {:>10} {:<16} {}", + "#", + "size", + "loader", + "name" + ); + for module in &graph.modules { + outln!( + w, + "{:>4} {:>10} {:<16} {}{}", + module.index, + module.contents.len(), + module.loader_name(), + module.name, + if module.entry { " entry" } else { "" } + ); + } + } + for warn in &graph.warnings { + eprintln!("note: {warn}"); + } + if graph.modules.is_empty() { + return Ok(exit::NOT_FOUND); + } + Ok(exit::OK) +} + +fn flag_suffix(flags: u32) -> String { + let named = [ + (0u32, "disable_default_env_files"), + (1, "disable_autoload_bunfig"), + (2, "disable_autoload_tsconfig"), + (3, "disable_autoload_package_json"), + ]; + let mut set: Vec<&str> = named + .iter() + .filter(|(bit, _)| flags & (1 << bit) != 0) + .map(|(_, name)| *name) + .collect(); + // Bits above the four Bun named first are real on current executables. + // The hex in the listing is the whole word; this only avoids reading + // the parenthetical as a complete decoding. + if flags & !0b1111 != 0 { + set.push("other bits set"); + } + if set.is_empty() { + String::new() + } else { + format!(" ({})", set.join(", ")) + } +} + /// Recovered C++ classes, their hierarchy and their member functions. pub fn classes(w: &mut Out, p: &Program, members: bool, as_json: bool) -> R { let opts = e5r_api::classes::Options { diff --git a/crates/e5r-cli/src/projects.rs b/crates/e5r-cli/src/projects.rs new file mode 100644 index 0000000..9d1f9f3 --- /dev/null +++ b/crates/e5r-cli/src/projects.rs @@ -0,0 +1,102 @@ +//! Global project CLI: registration, name resolution and live summaries. +use crate::{ + out::{Out, outln}, + patch, +}; +use e5r_db::project::Project; +use e5r_db::registry::{self, Registry}; +use std::path::{Path, PathBuf}; + +pub fn registry() -> Result { + Ok(Registry::new(registry::root()?)) +} + +pub fn resolve(reference: &Path) -> Result { + // Default project names often equal the binary filename in the current + // directory. Prefer a registered name; ./FILE explicitly selects a file. + if !reference.is_absolute() && reference.components().count() == 1 { + if let Some(name) = reference + .to_str() + .filter(|name| registry::validate_name(name).is_ok()) + { + if let Ok(registry) = registry() { + let path = registry.path(name)?; + if path.is_file() { + return Ok(path); + } + } + } + } + if reference.is_file() { + return std::fs::canonicalize(reference).map_err(|e| e.to_string()); + } + if reference.is_absolute() || reference.components().count() > 1 { + return Err(format!("project {} not found", reference.display())); + } + let name = reference.to_str().ok_or("project name must be UTF-8")?; + let path = registry()?.path(name)?; + if !path.is_file() { + return Err(format!("project {name:?} not found; use e5r project list")); + } + Ok(path) +} + +pub fn list(w: &mut Out, json: bool) -> Result { + let registry = registry()?; + let projects = registry.list()?; + if json { + return crate::json::emit( + w, + &serde_json::json!({"schema":"e5r.projects.v1", "root":registry.root().display().to_string(), "projects":projects}), + ); + } + outln!(w, "Projects: {}", registry.root().display()); + for p in projects { + outln!( + w, + "{} {} open · {} active · {} blocked · {} review [{}]", + p.name, + p.counts.open, + p.counts.active, + p.counts.blocked, + p.counts.review, + p.availability + ); + if let Some(error) = p.error { + outln!(w, " error: {error}"); + } + if let Some(binary) = p.binary { + outln!(w, " binary: {binary}"); + } + } + Ok(0) +} + +pub fn create(w: &mut Out, name: &str, project: &Project) -> Result { + let path = registry()?.create(name, project, None)?; + outln!(w, "{} registered: {}", name, path.display()); + Ok(0) +} + +pub fn import(w: &mut Out, source: &Path, name: &str, base: Option<&Path>) -> Result { + let path = registry()?.import(source, name, base)?; + outln!(w, "{} imported: {}", name, path.display()); + Ok(0) +} + +pub fn relocate(w: &mut Out, path: &Path, binary: &Path) -> Result { + let mut project = patch::project_read(path)?; + let binary = std::fs::canonicalize(binary).map_err(|e| e.to_string())?; + let file = std::fs::File::open(&binary).map_err(|e| e.to_string())?; + let data = crate::map_file(&file).map_err(|e| e.to_string())?; + let verdict = project.verify_at(&binary.to_string_lossy(), &data); + if !verdict.is_usable() { + return Err(verdict.to_string()); + } + project.binary.path = binary.to_string_lossy().into_owned(); + let temporary = path.with_extension(format!("{}.tmp", std::process::id())); + std::fs::write(&temporary, project.to_text()).map_err(|e| e.to_string())?; + std::fs::rename(temporary, path).map_err(|e| e.to_string())?; + outln!(w, "{} now opens {}", path.display(), binary.display()); + Ok(0) +} diff --git a/crates/e5r-cli/src/repl.rs b/crates/e5r-cli/src/repl.rs index 4a0a5c4..7e9a356 100644 --- a/crates/e5r-cli/src/repl.rs +++ b/crates/e5r-cli/src/repl.rs @@ -186,7 +186,10 @@ fn help(w: &mut Out) { w, " xrefs [where] strings query " ); - outln!(w, " classes vtables sig annotate overlay archive"); + outln!( + w, + " classes vtables sig annotate overlay archive bun" + ); outln!(w, ""); outln!( w, diff --git a/crates/e5r-cli/src/work.rs b/crates/e5r-cli/src/work.rs new file mode 100644 index 0000000..e0e09e7 --- /dev/null +++ b/crates/e5r-cli/src/work.rs @@ -0,0 +1,149 @@ +//! Project task commands; the database owns readiness and mutation rules. +use crate::{ + json, + out::{Out, outln}, +}; +use clap::Subcommand; +use e5r_db::work::{Draft, Priority, Store}; +use std::path::{Path, PathBuf}; + +#[derive(Subcommand)] +pub enum Command { + /// Show tasks, readiness, blockers and review requests. + List { + /// Emit the same snapshot used by the dashboard. + #[arg(long)] + json: bool, + }, + /// Add a task, optionally from a full Draft JSON file. + Add { + /// Concrete outcome. Required unless --input is supplied. + #[arg(required_unless_present = "input", conflicts_with = "input")] + title: Option, + /// Full task content as JSON (see docs/dashboard.md). + #[arg(long)] + input: Option, + /// Responsible person or agent. + #[arg(long, default_value = "", conflicts_with = "input")] + owner: String, + /// Concrete next action. + #[arg(long, default_value = "", conflicts_with = "input")] + next: String, + /// Context and acceptance criteria. + #[arg(long, default_value = "", conflicts_with = "input")] + description: String, + /// Supporting paths, addresses, commands or URLs; repeatable. + #[arg(long, conflicts_with = "input")] + evidence: Vec, + /// Prerequisite task id; repeatable. + #[arg(long, conflicts_with = "input")] + depends_on: Vec, + /// high, normal or low. + #[arg(long, default_value = "normal", value_parser = ["high", "normal", "low"], conflicts_with = "input")] + priority: String, + /// Writer, recorded in the task history. + #[arg(long)] + author: Option, + }, + /// Replace content, refusing an update based on a stale revision. + Update { + /// Task id from list. + id: String, + /// Revision from the last read. + #[arg(long)] + revision: u64, + /// Full task content as JSON. + #[arg(long)] + input: PathBuf, + /// Writer, recorded in the task history. + #[arg(long)] + author: Option, + }, +} + +fn read(path: &Path) -> Result { + let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?; + serde_json::from_str(&text).map_err(|e| format!("{}: {e}", path.display())) +} + +pub fn run(w: &mut Out, project: &Path, command: &Command) -> Result { + run_store(w, Store::new(e5r_db::work::beside(project)), command) +} + +pub fn run_store(w: &mut Out, store: Store, command: &Command) -> Result { + match command { + Command::List { json: true } => json::emit(w, &store.board()?), + Command::List { json: false } => { + for row in store.board()?.tasks { + let t = row.task; + outln!( + w, + "{} r{} {:?} {} owner: {}", + t.id, + t.revision, + t.content.state, + t.content.title, + if t.content.owner.is_empty() { + "unassigned" + } else { + &t.content.owner + } + ); + if let Some(why) = row.attention { + outln!(w, " attention: {why}"); + } + if !row.waiting_on.is_empty() { + outln!(w, " waiting on: {}", row.waiting_on.join(", ")); + } + if row.ready { + outln!(w, " ready to claim"); + } + if !t.content.next.is_empty() { + outln!(w, " next: {}", t.content.next); + } + } + Ok(0) + } + Command::Add { + title, + input, + owner, + next, + description, + evidence, + depends_on, + priority, + author, + } => { + let content = if let Some(path) = input { + read(path)? + } else { + Draft { + title: title.clone().unwrap_or_default(), + owner: owner.clone(), + next: next.clone(), + description: description.clone(), + evidence: evidence.clone(), + depends_on: depends_on.clone(), + priority: match priority.as_str() { + "high" => Priority::High, + "low" => Priority::Low, + _ => Priority::Normal, + }, + ..Draft::default() + } + }; + let author = author.clone().unwrap_or_else(crate::whoami); + json::emit(w, &store.create(content, &author)?) + } + Command::Update { + id, + revision, + input, + author, + } => { + let author = author.clone().unwrap_or_else(crate::whoami); + json::emit(w, &store.update(id, *revision, read(input)?, &author)?) + } + } +} diff --git a/crates/e5r-db/Cargo.toml b/crates/e5r-db/Cargo.toml index 2602696..d3c39d0 100644 --- a/crates/e5r-db/Cargo.toml +++ b/crates/e5r-db/Cargo.toml @@ -13,6 +13,7 @@ e5r-core = { workspace = true } e5r-arch = { workspace = true } e5r-types = { workspace = true } serde = { workspace = true } +serde_json = { workspace = true } # The signature gate measures against a real statically linked binary and # builds its library out of the distribution's own static archives, so it needs diff --git a/crates/e5r-db/src/lib.rs b/crates/e5r-db/src/lib.rs index 6732cdf..56f0d99 100644 --- a/crates/e5r-db/src/lib.rs +++ b/crates/e5r-db/src/lib.rs @@ -12,7 +12,9 @@ pub mod anchor; pub mod log; pub mod patch; pub mod project; +pub mod registry; pub mod signature; +pub mod work; pub use anchor::{Anchor, AnchorIndex, Fnv, Resolution}; pub use log::{Assertion, Declared, Field, Log, declared}; diff --git a/crates/e5r-db/src/registry.rs b/crates/e5r-db/src/registry.rs new file mode 100644 index 0000000..424ba1a --- /dev/null +++ b/crates/e5r-db/src/registry.rs @@ -0,0 +1,455 @@ +//! Named projects in a global directory, independent of the working directory. +//! +//! Project manifests and task files are ordinary text. Binaries stay where the +//! user put them; absolute references make the registry usable from any cwd. +use crate::{ + project::Project, + work::{self, Store}, +}; +use serde::Serialize; +use std::fs; +use std::path::{Path, PathBuf}; + +/// Global storage directory, following h5i's data-directory convention. +pub fn root() -> Result { + root_from( + std::env::var_os("E5R_PROJECT_HOME").map(PathBuf::from), + std::env::var_os("XDG_DATA_HOME").map(PathBuf::from), + std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from), + ) +} + +fn root_from( + explicit: Option, + data: Option, + home: Option, +) -> Result { + if let Some(path) = explicit.filter(|p| !p.as_os_str().is_empty()) { + return absolute(&path, &std::env::current_dir().map_err(|e| e.to_string())?); + } + if let Some(path) = data.filter(|p| p.is_absolute()) { + return Ok(path.join("e5r/projects")); + } + home.filter(|p| p.is_absolute()) + .map(|p| p.join(".local/share/e5r/projects")) + .ok_or_else(|| "no home directory; set E5R_PROJECT_HOME".into()) +} + +/// Validate a project name before using it as a directory or URL segment. +pub fn validate_name(name: &str) -> Result<(), String> { + if name.is_empty() + || name.len() > 64 + || name.starts_with('.') + || !name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.')) + { + return Err("project names need 1–64 ASCII letters, digits, '-', '_' or '.', and cannot start with '.'".into()); + } + Ok(()) +} + +/// Make a default project name from a binary filename. +pub fn default_name(binary: &Path) -> String { + let name: String = binary + .file_name() + .unwrap_or_default() + .to_string_lossy() + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { + c + } else { + '-' + } + }) + .take(64) + .collect(); + let name = name.trim_start_matches('.'); + if name.is_empty() { + "analysis".into() + } else { + name.into() + } +} + +/// An absolute reference; canonicalize existing files and retain missing paths. +pub fn absolute(path: &Path, base: &Path) -> Result { + let path = if path.is_absolute() { + path.to_path_buf() + } else { + base.join(path) + }; + if !path.is_absolute() { + return Err("reference base must be absolute".into()); + } + Ok(fs::canonicalize(&path).unwrap_or(path)) +} + +/// Normalize every project reference against its original working directory. +pub fn normalize(project: &mut Project, base: &Path) -> Result<(), String> { + fn path(value: &mut String, base: &Path) -> Result<(), String> { + *value = absolute(Path::new(value), base)? + .to_string_lossy() + .into_owned(); + Ok(()) + } + path(&mut project.binary.path, base)?; + if let Some(log) = &mut project.log { + path(log, base)?; + } else { + project.log = Some(format!("{}.e5r", project.binary.path)); + } + for reference in project.signatures.iter_mut().chain(&mut project.patches) { + path(reference, base)?; + } + Ok(()) +} + +/// Counts have literal task meanings, not agent-process or inferred health states. +#[derive(Default, Serialize)] +pub struct Counts { + /// All recorded tasks. + pub total: usize, + /// Unfinished tasks. + pub open: usize, + /// Tasks being worked on. + pub active: usize, + /// Explicitly blocked tasks. + pub blocked: usize, + /// Results awaiting review. + pub review: usize, + /// Planned tasks with completed prerequisites. + pub ready: usize, + /// Accepted tasks. + pub done: usize, +} + +/// A cross-project item needing attention. +#[derive(Serialize)] +pub struct Attention { + /// Task id, scoped to this project. + pub id: String, + /// Outcome. + pub title: String, + /// Explicit state. + pub state: work::State, + /// Recorded reason or handoff. + pub why: String, + /// Responsible person or agent. + pub owner: String, +} + +/// A lightweight project summary; listing does not analyze binary bytes. +#[derive(Serialize)] +pub struct Summary { + /// Global name. + pub name: String, + /// Absolute manifest path. + pub project: String, + /// Recorded binary path, if the manifest is readable. + pub binary: Option, + /// Available, missing, size_changed, or unreadable; content verified on open. + pub availability: String, + /// Latest task timestamp, if any. + pub updated: Option, + /// Task counts. + pub counts: Counts, + /// Tasks requiring attention. + pub attention: Vec, + /// Manifest or task-store errors remain visible in the collection. + pub error: Option, +} + +/// Global project directory. Construct directly in tests; use [`root`] in CLI. +pub struct Registry { + root: PathBuf, +} + +impl Registry { + /// Open a directory without creating it until the first write. + pub fn new(root: PathBuf) -> Self { + Self { root } + } + /// Directory where managed projects live. + pub fn root(&self) -> &Path { + &self.root + } + /// Resolve a validated name to its global manifest. + pub fn path(&self, name: &str) -> Result { + validate_name(name)?; + let dir = self.root.join(name); + if fs::symlink_metadata(&dir).is_ok_and(|m| m.file_type().is_symlink()) { + return Err("a registered project directory cannot be a symlink".into()); + } + Ok(dir.join("project.e5rproj")) + } + /// Read a named manifest. + pub fn read(&self, name: &str) -> Result { + let path = self.path(name)?; + let text = fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?; + Project::from_text(&text).map_err(|e| format!("{}: {e}", path.display())) + } + /// Store a new manifest and optional imported tasks, refusing name collisions. + pub fn create( + &self, + name: &str, + project: &Project, + source_work: Option<&Path>, + ) -> Result { + let path = self.path(name)?; + let mut project = project.clone(); + // Callers must supply absolute references; a registry never depends on cwd. + if !Path::new(&project.binary.path).is_absolute() + || project + .log + .iter() + .chain(&project.signatures) + .chain(&project.patches) + .any(|path| !Path::new(path).is_absolute()) + { + return Err("registered projects require absolute file references".into()); + } + if project.log.is_none() { + project.log = Some(format!("{}.e5r", project.binary.path)); + } + let tasks = match source_work { + Some(root) => Store::new(root).list()?, + None => Vec::new(), + }; + let mut records = Vec::new(); + if let Some(root) = source_work { + for kind in ["finding", "note", "report"] { + records.push((kind, Store::new(root.join(kind)).list()?)); + } + } + fs::create_dir_all(&self.root).map_err(|e| e.to_string())?; + let dir = path.parent().ok_or("invalid project path")?; + fs::create_dir(dir) + .map_err(|e| format!("cannot create project {name}: {e}; choose a different name"))?; + let result = (|| { + if !tasks.is_empty() { + let work = work::beside(&path); + fs::create_dir(&work).map_err(|e| e.to_string())?; + for task in &tasks { + let text = serde_json::to_vec_pretty(task).map_err(|e| e.to_string())?; + fs::write(work.join(format!("{}.json", task.id)), text) + .map_err(|e| e.to_string())?; + } + } + for (kind, items) in &records { + if items.is_empty() { + continue; + } + let dest = work::beside(&path).join(kind); + fs::create_dir_all(&dest).map_err(|e| e.to_string())?; + for item in items { + fs::write( + dest.join(format!("{}.json", item.id)), + serde_json::to_vec_pretty(item).map_err(|e| e.to_string())?, + ) + .map_err(|e| e.to_string())?; + } + } + let temporary = dir.join("project.tmp"); + fs::write(&temporary, project.to_text()).map_err(|e| e.to_string())?; + fs::rename(&temporary, &path).map_err(|e| e.to_string())?; + Ok(path.clone()) + })(); + if result.is_err() { + // Only remove files this creation wrote. Never recursively remove a + // directory somebody else may have changed in the meantime. + let work = work::beside(&path); + for task in &tasks { + let _ = fs::remove_file(work.join(format!("{}.json", task.id))); + } + for (kind, items) in &records { + let dest = work.join(kind); + for item in items { + let _ = fs::remove_file(dest.join(format!("{}.json", item.id))); + } + let _ = fs::remove_dir(dest); + } + let _ = fs::remove_dir(work); + let _ = fs::remove_file(dir.join("project.tmp")); + let _ = fs::remove_dir(dir); + } + result + } + /// Import a manifest and its task history as a new global project. + pub fn import( + &self, + source: &Path, + name: &str, + base: Option<&Path>, + ) -> Result { + let source = fs::canonicalize(source).map_err(|e| e.to_string())?; + let text = fs::read_to_string(&source).map_err(|e| e.to_string())?; + let mut project = Project::from_text(&text).map_err(|e| e.to_string())?; + let base = base.unwrap_or(source.parent().ok_or("project has no parent directory")?); + let base = absolute(base, &std::env::current_dir().map_err(|e| e.to_string())?)?; + normalize(&mut project, &base)?; + self.create(name, &project, Some(&work::beside(&source))) + } + /// List projects and live task signals without loading any binary. + pub fn list(&self) -> Result, String> { + let entries = match fs::read_dir(&self.root) { + Ok(entries) => entries, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(e) => return Err(e.to_string()), + }; + let mut summaries = Vec::new(); + for entry in entries { + let entry = entry.map_err(|e| e.to_string())?; + if !entry.file_type().map_err(|e| e.to_string())?.is_dir() { + continue; + } + let name = entry.file_name().to_string_lossy().into_owned(); + if validate_name(&name).is_err() { + continue; + } + let path = self.path(&name)?; + let mut summary = Summary { + name: name.clone(), + project: path.display().to_string(), + binary: None, + availability: "unreadable".into(), + updated: None, + counts: Counts::default(), + attention: Vec::new(), + error: None, + }; + match self.read(&name) { + Ok(project) => { + summary.binary = Some(project.binary.path.clone()); + summary.availability = match fs::metadata(&project.binary.path) { + Ok(m) if m.is_file() && m.len() == project.binary.size => "available", + Ok(m) if m.is_file() => "size_changed", + _ => "missing", + } + .into(); + } + Err(e) => summary.error = Some(e), + } + match Store::new(work::beside(&path)).board() { + Ok(board) => { + for row in board.tasks { + summary.counts.total += 1; + if row.task.content.state != work::State::Done { + summary.counts.open += 1; + } + match row.task.content.state { + work::State::Active => summary.counts.active += 1, + work::State::Blocked => summary.counts.blocked += 1, + work::State::Review => summary.counts.review += 1, + work::State::Done => summary.counts.done += 1, + _ => {} + } + if row.ready { + summary.counts.ready += 1; + } + summary.updated = Some(summary.updated.unwrap_or(0).max(row.task.updated)); + if let Some(why) = row.attention { + summary.attention.push(Attention { + id: row.task.id, + title: row.task.content.title, + state: row.task.content.state, + why, + owner: row.task.content.owner, + }); + } + } + } + Err(e) => { + summary.error = Some(match summary.error { + Some(previous) => format!("{previous}; {e}"), + None => e, + }) + } + } + summaries.push(summary); + } + summaries.sort_by(|a, b| { + b.attention + .len() + .cmp(&a.attention.len()) + .then_with(|| a.name.cmp(&b.name)) + }); + Ok(summaries) + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn directory() -> PathBuf { + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!("e5r-registry-{}-{stamp}", std::process::id())) + } + #[test] + fn storage_precedence_and_names_are_predictable() { + assert_eq!( + root_from( + Some("/custom".into()), + Some("/data".into()), + Some("/home/test".into()) + ) + .unwrap(), + PathBuf::from("/custom") + ); + assert_eq!( + root_from(None, Some("/data".into()), Some("/home/test".into())).unwrap(), + PathBuf::from("/data/e5r/projects") + ); + assert_eq!( + root_from(None, Some("relative".into()), Some("/home/test".into())).unwrap(), + PathBuf::from("/home/test/.local/share/e5r/projects") + ); + assert!(root_from(None, None, None).is_err()); + for name in ["../escape", ".hidden", "a/b", "", "a?b"] { + assert!(validate_name(name).is_err()); + } + } + #[test] + fn imports_keep_tasks_paths_and_corruption_visible() { + let base = directory(); + fs::create_dir_all(&base).unwrap(); + fs::write(base.join("binary"), b"binary").unwrap(); + let project = Project::for_binary("binary", b"binary"); + let source = base.join("old.e5rproj"); + fs::write(&source, project.to_text()).unwrap(); + let task = Store::new(work::beside(&source)) + .create( + work::Draft { + title: "Need sample".into(), + state: work::State::Blocked, + blocker: "Awaiting input".into(), + ..Default::default() + }, + "agent", + ) + .unwrap(); + let registry = Registry::new(base.join("global")); + let saved = registry.import(&source, "parser", None).unwrap(); + assert_eq!( + registry.read("parser").unwrap().binary.path, + base.join("binary").to_string_lossy() + ); + assert_eq!( + Store::new(work::beside(&saved)).list().unwrap()[0].id, + task.id + ); + let summaries = registry.list().unwrap(); + assert_eq!(summaries[0].counts.blocked, 1); + assert_eq!(summaries[0].attention[0].why, "Awaiting input"); + assert!(registry.import(&source, "parser", None).is_err()); + fs::write(&saved, "corrupt").unwrap(); + assert!(registry.list().unwrap()[0].error.is_some()); + fs::remove_dir_all(base).unwrap(); + } +} diff --git a/crates/e5r-db/src/work.rs b/crates/e5r-db/src/work.rs new file mode 100644 index 0000000..60c1f03 --- /dev/null +++ b/crates/e5r-db/src/work.rs @@ -0,0 +1,538 @@ +//! Durable coordination beside a project, separate from its binary configuration. +//! +//! One JSON file per task makes unrelated changes reviewable and mergeable in git. +//! A store lock serializes local writers; revisions reject stale human/agent edits. + +use serde::{Deserialize, Serialize}; +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// The task's workflow state. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum State { + /// Available for planning or claiming. + #[default] + Planned, + /// An owner is working on it. + Active, + /// Cannot proceed; the reason is recorded. + Blocked, + /// A person or another agent should review the result. + Review, + /// The result has been accepted. + Done, +} + +/// Explicit urgency, not an inferred score. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Priority { + /// Address before ordinary work. + High, + /// Ordinary work. + #[default] + Normal, + /// Can wait. + Low, +} + +/// A saved revision, so handoffs retain their author and context. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Change { + /// Unix timestamp in seconds. + pub at: u64, + /// Person or agent who wrote it. + pub author: String, + /// The previous task content. + pub previous: Draft, +} + +/// Fields shared by creation and full replacement updates. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Draft { + /// Short, concrete outcome. + pub title: String, + /// Context and acceptance criteria. + #[serde(default)] + pub description: String, + /// Workflow state. + #[serde(default)] + pub state: State, + /// Explicit urgency. + #[serde(default)] + pub priority: Priority, + /// Person or agent responsible for the next step. + #[serde(default)] + pub owner: String, + /// Concrete next step, also used for a handoff. + #[serde(default)] + pub next: String, + /// Why work cannot proceed; required when blocked. + #[serde(default)] + pub blocker: String, + /// Paths, addresses, commands or links supporting the result. + #[serde(default)] + pub evidence: Vec, + /// Tasks that must be done before this one can be started. + #[serde(default)] + pub depends_on: Vec, +} + +/// A durable task with an optimistic concurrency revision. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Task { + /// Stable task id. + pub id: String, + /// Starts at one, incremented for each update. + pub revision: u64, + /// Current task content. + pub content: Draft, + /// Unix timestamp in seconds. + pub created: u64, + /// Last update timestamp. + pub updated: u64, + /// Last writer. + pub author: String, + /// Earlier contents, in chronological order. + pub history: Vec, +} + +/// A task plus mechanically derived coordination signals. +#[derive(Serialize)] +pub struct Row { + /// Saved task. + pub task: Task, + /// Unfinished prerequisite task ids. + pub waiting_on: Vec, + /// Can be claimed now. + pub ready: bool, + /// Why this needs a person's attention, if anything. + pub attention: Option, +} + +/// Shared dashboard/agent snapshot, versioned independently from binary analysis. +#[derive(Serialize)] +pub struct Board { + /// Shape version. + pub schema: &'static str, + /// Tasks ordered by urgency and id. + pub tasks: Vec, +} + +/// Task storage directory (typically `.work`). +pub struct Store { + root: PathBuf, +} + +struct Lock(PathBuf); +impl Drop for Lock { + fn drop(&mut self) { + let _ = fs::remove_file(&self.0); + } +} + +fn stamp() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn valid_id(id: &str) -> bool { + id.starts_with("T-") && id.len() > 2 && id[2..].bytes().all(|b| b.is_ascii_digit()) +} + +impl Store { + /// Open a directory without creating it until the first write. + pub fn new(root: impl Into) -> Self { + Self { root: root.into() } + } + + fn lock(&self) -> Result { + fs::create_dir_all(&self.root).map_err(|e| e.to_string())?; + let path = self.root.join(".lock"); + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + .map_err(|e| { + format!( + "cannot lock {}: {e}; another writer may be active", + self.root.display() + ) + })?; + let guard = Lock(path); + writeln!(file, "pid={} at={}", std::process::id(), stamp()).map_err(|e| e.to_string())?; + Ok(guard) + } + + /// Read tasks, refusing corrupt or unknown data rather than hiding it. + pub fn list(&self) -> Result, String> { + let entries = match fs::read_dir(&self.root) { + Ok(entries) => entries, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(e) => return Err(e.to_string()), + }; + let mut tasks = Vec::new(); + for entry in entries { + let path = entry.map_err(|e| e.to_string())?.path(); + if path.extension().and_then(|s| s.to_str()) != Some("json") { + continue; + } + let text = fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?; + let task: Task = + serde_json::from_str(&text).map_err(|e| format!("{}: {e}", path.display()))?; + if !valid_id(&task.id) + || path.file_stem().and_then(|s| s.to_str()) != Some(&task.id) + || task.revision == 0 + { + return Err(format!( + "{}: invalid task identity or revision", + path.display() + )); + } + validate(&task.content)?; + tasks.push(task); + } + tasks.sort_by(|a, b| { + a.content + .priority + .cmp(&b.content.priority) + .then_with(|| a.id.cmp(&b.id)) + }); + Ok(tasks) + } + + /// Compute readiness and reasons from the same saved state for all clients. + pub fn board(&self) -> Result { + let tasks = self.list()?; + let rows = tasks + .iter() + .map(|task| { + let waiting_on: Vec<_> = task + .content + .depends_on + .iter() + .filter(|id| { + !tasks + .iter() + .any(|t| &t.id == *id && t.content.state == State::Done) + }) + .cloned() + .collect(); + let attention = match task.content.state { + State::Blocked => Some(task.content.blocker.clone()), + State::Review => Some(if task.content.next.trim().is_empty() { + "Result needs review".into() + } else { + task.content.next.clone() + }), + State::Active if task.content.owner.trim().is_empty() => { + Some("Active work has no owner".into()) + } + _ => None, + }; + Row { + task: task.clone(), + ready: task.content.state == State::Planned && waiting_on.is_empty(), + waiting_on, + attention, + } + }) + .collect(); + Ok(Board { + schema: "e5r.work.v1", + tasks: rows, + }) + } + + /// Create a new task. The author is explicit for human/agent parity. + pub fn create(&self, content: Draft, author: &str) -> Result { + validate_author(author)?; + let _lock = self.lock()?; + let tasks = self.list()?; + let next = tasks + .iter() + .filter_map(|t| t.id[2..].parse::().ok()) + .max() + .unwrap_or(0) + .checked_add(1) + .ok_or("task ids exhausted")?; + let id = format!("T-{next:04}"); + self.check(&id, &content, &tasks)?; + let now = stamp(); + let task = Task { + id, + revision: 1, + content, + created: now, + updated: now, + author: author.into(), + history: Vec::new(), + }; + self.save(&task)?; + Ok(task) + } + + /// Replace task content only if the caller read its current revision. + pub fn update( + &self, + id: &str, + revision: u64, + content: Draft, + author: &str, + ) -> Result { + if !valid_id(id) { + return Err("invalid task id".into()); + } + validate_author(author)?; + let _lock = self.lock()?; + let tasks = self.list()?; + let mut task = tasks + .iter() + .find(|t| t.id == id) + .cloned() + .ok_or("task not found")?; + if task.revision != revision { + return Err(format!( + "revision conflict: expected {revision}, current {}; reload before editing", + task.revision + )); + } + self.check(id, &content, &tasks)?; + task.history.push(Change { + at: task.updated, + author: task.author.clone(), + previous: task.content, + }); + task.content = content; + task.revision = task.revision.checked_add(1).ok_or("revision exhausted")?; + task.updated = stamp(); + task.author = author.into(); + self.save(&task)?; + Ok(task) + } + + fn check(&self, id: &str, content: &Draft, tasks: &[Task]) -> Result<(), String> { + validate(content)?; + for dep in &content.depends_on { + if dep == id { + return Err("a task cannot depend on itself".into()); + } + let target = tasks + .iter() + .find(|t| &t.id == dep) + .ok_or_else(|| format!("unknown prerequisite {dep}"))?; + if matches!(content.state, State::Active | State::Review | State::Done) + && target.content.state != State::Done + { + return Err(format!("prerequisite {dep} is not done")); + } + let mut pending = vec![dep.as_str()]; + let mut seen = std::collections::BTreeSet::new(); + while let Some(current) = pending.pop() { + if current == id { + return Err("prerequisites would form a cycle".into()); + } + if seen.insert(current) + && let Some(t) = tasks.iter().find(|t| t.id == current) + { + pending.extend(t.content.depends_on.iter().map(String::as_str)); + } + } + } + // Reopening a prerequisite must not invalidate already-started dependents. + if content.state != State::Done + && tasks.iter().any(|t| { + t.id != id + && t.content.depends_on.iter().any(|d| d == id) + && matches!(t.content.state, State::Active | State::Review | State::Done) + }) + { + return Err( + "cannot reopen a prerequisite while a dependent is active, in review or done" + .into(), + ); + } + Ok(()) + } + + fn save(&self, task: &Task) -> Result<(), String> { + let path = self.root.join(format!("{}.json", task.id)); + let temporary = self.root.join(format!("{}.tmp", task.id)); + let data = serde_json::to_vec_pretty(task).map_err(|e| e.to_string())?; + let mut file = fs::File::create(&temporary).map_err(|e| e.to_string())?; + file.write_all(&data) + .and_then(|_| file.write_all(b"\n")) + .and_then(|_| file.sync_all()) + .map_err(|e| e.to_string())?; + fs::rename(&temporary, &path).map_err(|e| e.to_string())?; + Ok(()) + } +} + +fn validate_author(author: &str) -> Result<(), String> { + if author.trim().is_empty() || author.len() > 200 { + return Err("writer name must contain 1 to 200 bytes".into()); + } + Ok(()) +} + +fn validate(content: &Draft) -> Result<(), String> { + if content.title.trim().is_empty() { + return Err("a task needs a title".into()); + } + if content.title.len() > 240 { + return Err("title exceeds 240 bytes".into()); + } + if content.state == State::Blocked && content.blocker.trim().is_empty() { + return Err("blocked work needs a reason".into()); + } + if content.state == State::Active && content.owner.trim().is_empty() { + return Err("active work needs an owner".into()); + } + if content.state != State::Blocked && !content.blocker.trim().is_empty() { + return Err("clear the blocker when leaving blocked state".into()); + } + if serde_json::to_vec(content) + .map_err(|e| e.to_string())? + .len() + > 64 * 1024 + { + return Err("task content exceeds 64 KiB".into()); + } + let mut unique = std::collections::BTreeSet::new(); + if content + .depends_on + .iter() + .any(|id| !valid_id(id) || !unique.insert(id)) + { + return Err("prerequisites must be unique task ids".into()); + } + Ok(()) +} + +/// The coordination directory for a binary project file. +pub fn beside(project: &Path) -> PathBuf { + let mut path = project.as_os_str().to_os_string(); + path.push(".work"); + path.into() +} + +#[cfg(test)] +mod tests { + use super::*; + fn store() -> Store { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + Store::new(std::env::temp_dir().join(format!("e5r-work-{}-{unique}", std::process::id()))) + } + fn draft(title: &str) -> Draft { + Draft { + title: title.into(), + ..Draft::default() + } + } + #[test] + fn stale_handoffs_cannot_overwrite_and_history_survives() { + let s = store(); + let t = s.create(draft("Trace input"), "human").unwrap(); + let mut d = t.content.clone(); + d.state = State::Active; + d.owner = "agent".into(); + d.next = "Inspect main".into(); + let updated = s.update(&t.id, 1, d.clone(), "agent").unwrap(); + assert_eq!(updated.revision, 2); + assert!( + s.update(&t.id, 1, d, "human") + .unwrap_err() + .contains("conflict") + ); + let loaded = s.list().unwrap(); + assert_eq!(loaded[0].history[0].author, "human"); + assert_eq!(loaded[0].content.owner, "agent"); + fs::remove_dir_all(&s.root).unwrap(); + } + #[test] + fn readiness_cycles_and_reopening_are_enforced() { + let s = store(); + let a = s.create(draft("Find parser"), "human").unwrap(); + let mut d = draft("Trace parser"); + d.depends_on.push(a.id.clone()); + let b = s.create(d.clone(), "agent").unwrap(); + assert!( + !s.board() + .unwrap() + .tasks + .iter() + .find(|r| r.task.id == b.id) + .unwrap() + .ready + ); + let mut cycle = a.content.clone(); + cycle.depends_on.push(b.id.clone()); + assert!( + s.update(&a.id, 1, cycle, "human") + .unwrap_err() + .contains("cycle") + ); + d.state = State::Active; + d.owner = "agent".into(); + assert!(s.update(&b.id, 1, d.clone(), "agent").is_err()); + let mut done = a.content.clone(); + done.state = State::Done; + s.update(&a.id, 1, done, "human").unwrap(); + s.update(&b.id, 1, d, "agent").unwrap(); + assert!(s.update(&a.id, 2, a.content, "human").is_err()); + fs::remove_dir_all(&s.root).unwrap(); + } + #[test] + fn simultaneous_claims_have_only_one_winner() { + let s = store(); + let task = s.create(draft("Claim parser analysis"), "human").unwrap(); + let barrier = std::sync::Arc::new(std::sync::Barrier::new(2)); + let handles: Vec<_> = ["agent-a", "agent-b"] + .into_iter() + .map(|author| { + let root = s.root.clone(); + let barrier = barrier.clone(); + let id = task.id.clone(); + let mut content = task.content.clone(); + content.state = State::Active; + content.owner = author.into(); + std::thread::spawn(move || { + barrier.wait(); + Store::new(root).update(&id, 1, content, author) + }) + }) + .collect(); + let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect(); + assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1); + let loaded = s.list().unwrap(); + assert_eq!(loaded[0].revision, 2); + assert_eq!(loaded[0].history.len(), 1); + assert!(matches!( + loaded[0].content.owner.as_str(), + "agent-a" | "agent-b" + )); + fs::remove_dir_all(&s.root).unwrap(); + } + #[test] + fn invalid_or_corrupt_work_is_visible() { + let s = store(); + let mut d = draft("Review parser"); + d.state = State::Blocked; + assert!(s.create(d, "agent").is_err()); + fs::write(s.root.join("T-0001.json"), "broken").unwrap(); + assert!(s.board().is_err()); + fs::remove_dir_all(&s.root).unwrap(); + } +} diff --git a/crates/e5r-decomp/tests/datatests.rs b/crates/e5r-decomp/tests/datatests.rs index fde8d63..3b2ea48 100644 --- a/crates/e5r-decomp/tests/datatests.rs +++ b/crates/e5r-decomp/tests/datatests.rs @@ -946,13 +946,29 @@ fn plt_calls_keep_prepared_arguments() { /// return operation itself does not mention it. #[test] fn a_returned_call_is_the_call_result() { - let Some(cc) = ["clang", "cc", "gcc"] - .into_iter() + returned_call(None); +} + +#[test] +fn a_returned_x86_call_is_the_call_result() { + returned_call(Some("x86_64-linux-gnu")); +} + +fn returned_call(target: Option<&str>) { + let compilers: &[&str] = if target.is_some() { + &["clang"] + } else { + &["clang", "cc", "gcc"] + }; + let Some(cc) = compilers + .iter() + .copied() .find(|name| Command::new(name).arg("--version").output().is_ok()) else { return; }; - let dir = std::env::temp_dir().join("e5r-returned-call"); + let dir = + std::env::temp_dir().join(format!("e5r-returned-call-{}", target.unwrap_or("native"))); std::fs::create_dir_all(&dir).unwrap(); let src = dir.join("w.c"); std::fs::write( @@ -963,7 +979,11 @@ fn a_returned_call_is_the_call_result() { ) .unwrap(); let bin = dir.join("w.o"); - let compiled = Command::new(cc) + let mut command = Command::new(cc); + if let Some(target) = target { + command.args(["-target", target]); + } + let compiled = command .args(["-c", "-O0", "-fno-builtin", "-o"]) .arg(&bin) .arg(&src) diff --git a/crates/e5r-format/src/bun.rs b/crates/e5r-format/src/bun.rs new file mode 100644 index 0000000..6cb19c3 --- /dev/null +++ b/crates/e5r-format/src/bun.rs @@ -0,0 +1,1016 @@ +//! Bun standalone module graphs. +//! +//! `bun build --compile` appends a graph of source and bytecode to the +//! executable. Those bytes are not a program the loader should disassemble, +//! so this module lists them the way [`crate::archive`] lists an archive: +//! separately from [`crate::load`], and only as far as the caller asks. +//! +//! The layout is the one Bun writes in `StandaloneModuleGraph.zig` (MIT). +//! A trailer, `\n---- Bun! ----\n`, ends the graph. The 32 bytes before it +//! are an `Offsets` value: a 64-bit byte count, then the module-record +//! span, the entry-point id, the compile-time argv span, and a flag word, +//! all little-endian. Every span is an offset and a length into the bytes +//! that precede `Offsets`. Bun reads the last trailer in the file; an +//! earlier copy of the marker, inside a script or an unrelated payload, is +//! not the graph. +//! +//! Two record sizes have shipped. The earlier is 36 bytes: name, contents, +//! sourcemap and bytecode spans, then encoding, loader, module format and +//! side. The later is 52 bytes, with `module_info` and +//! `bytecode_origin_path` spans before those four bytes. A size is accepted +//! only when every span it implies lands inside the graph. + +use e5r_core::{Error, Reader, Result}; + +/// The marker Bun writes after `Offsets`. Sixteen bytes, newline on both ends. +pub const TRAILER: &[u8] = b"\n---- Bun! ----\n"; + +/// `Offsets` is a 64-bit count plus six 32-bit fields. +const OFFSETS_LEN: usize = 32; + +/// Record size before `module_info` and `bytecode_origin_path` existed. +const RECORD_V1: u64 = 36; + +/// Record size Bun writes now. +const RECORD_V2: u64 = 52; + +/// How many non-graphs to step over before giving up. A file that repeats +/// the trailer and nothing else should fail, not scan forever. +const MAX_SKIPS: u32 = 32; + +/// A path longer than this is still located; the listing keeps the head. +const MAX_NAME: usize = 1 << 12; + +/// Bounds for one parse. The default refuses a file that claims a million +/// modules rather than allocating them. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Options { + /// Which graph to return. `0` is the last in the file, which is the one + /// Bun itself reads. + pub which: usize, + /// Most modules one graph may declare. + pub max_modules: u64, +} + +impl Default for Options { + fn default() -> Self { + Options { + which: 0, + max_modules: 1 << 16, + } + } +} + +/// One embedded file. `contents` is borrowed from the input, so a listing +/// does not copy the source. +#[derive(Clone)] +pub struct Module<'a> { + /// Position in the graph, which is what the entry-point id names. + pub index: u32, + /// Path as the graph spells it, usually under `/$bunfs/`. + pub name: String, + /// The file's bytes, exactly the span the record names. + pub contents: &'a [u8], + /// File offset of [`Module::contents`]. + pub contents_offset: u64, + /// Sourcemap span length. Zero when the module has none. + pub sourcemap_len: u32, + /// Bytecode span length. Zero when the module was not precompiled. + pub bytecode_len: u32, + /// Bun's `Encoding`: 0 binary, 1 latin1, 2 utf8. + pub encoding: u8, + /// Bun's `Loader` discriminant. See [`Module::loader_name`]. + pub loader: u8, + /// Bun's `ModuleFormat`: 0 none, 1 esm, 2 cjs. + pub format: u8, + /// Bun's `FileSide`: 0 server, 1 client. + pub side: u8, + /// True when this module is the graph's entry point. + pub entry: bool, +} + +impl std::fmt::Debug for Module<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Module") + .field("index", &self.index) + .field("name", &self.name) + .field("contents_len", &self.contents.len()) + .field("loader", &self.loader) + .field("entry", &self.entry) + .finish() + } +} + +impl Module<'_> { + /// The loader's name, or `loader-N` when this build of Bun grew a + /// discriminant the table does not list yet. The number is Bun's + /// `Loader` enum (`src/options.zig`): `jsx` is 0. + pub fn loader_name(&self) -> String { + named( + self.loader, + &[ + "jsx", + "js", + "ts", + "tsx", + "css", + "file", + "json", + "jsonc", + "toml", + "wasm", + "napi", + "base64", + "dataurl", + "text", + "bunsh", + "sqlite", + "sqlite_embedded", + "html", + "yaml", + "json5", + "md", + ], + "loader", + ) + } + + /// `binary`, `latin1`, `utf8`, or `encoding-N`. + pub fn encoding_name(&self) -> String { + named(self.encoding, &["binary", "latin1", "utf8"], "encoding") + } + + /// `none`, `esm`, `cjs`, or `format-N`. + pub fn format_name(&self) -> String { + named(self.format, &["none", "esm", "cjs"], "format") + } + + /// `server`, `client`, or `side-N`. + pub fn side_name(&self) -> String { + named(self.side, &["server", "client"], "side") + } +} + +/// A parsed graph. +#[derive(Debug, Clone)] +pub struct Graph<'a> { + /// File offset of the first byte the offsets describe. + pub offset: u64, + /// How many bytes precede the `Offsets` struct. Spans are relative to + /// [`Graph::offset`] and must land inside this count. + pub byte_count: u64, + /// File offset of the trailer that ended this graph. + pub trailer_offset: u64, + /// Which record size was consistent. Zero when the graph has no modules. + pub record_size: u64, + /// The entry-point id as stored, even when it names no module. + pub entry_point_id: u32, + /// Arguments baked in at compile time. Empty when there were none. + pub argv: &'a [u8], + /// The flag word. The low four bits are the ones Bun named first: + /// `disable_default_env_files`, `disable_autoload_bunfig`, + /// `disable_autoload_tsconfig`, `disable_autoload_package_json`. + /// Later builds set further bits; those stay in the word and are not + /// given names here. + pub flags: u32, + /// Modules in graph order. + pub modules: Vec>, + /// Problems that did not stop the parse. + pub warnings: Vec, +} + +impl<'a> Graph<'a> { + /// The module a query names. + /// + /// An exact path wins. Otherwise an index, written in decimal. Otherwise + /// a unique path suffix that begins on a `/`: `a.js` matches + /// `/$bunfs/root/a.js` and does not match `/$bunfs/root/ba.js`. + pub fn find(&self, query: &str) -> Result { + if query.is_empty() { + return Err(Error::BadField { + field: "bun module", + value: 0, + reason: "is an empty name", + }); + } + if let Some(i) = self.modules.iter().position(|m| m.name == query) { + return Ok(i); + } + if query.bytes().all(|b| b.is_ascii_digit()) + && let Ok(index) = query.parse::() + && self.modules.get(index as usize).is_some() + { + return Ok(index as usize); + } + let mut hits: Vec = self + .modules + .iter() + .enumerate() + .filter(|(_, m)| is_path_suffix(&m.name, query)) + .map(|(i, _)| i) + .collect(); + match hits.len() { + 1 => Ok(hits.remove(0)), + 0 => Err(Error::NotRecognized { + expected: "module in this bun graph", + }), + _ => { + let shown: Vec<&str> = hits + .iter() + .take(4) + .map(|&i| self.modules[i].name.as_str()) + .collect(); + Err(Error::inconsistent(format!( + "module query {query:?} matches {} modules, including {}", + hits.len(), + shown.join(", ") + ))) + } + } + } +} + +/// Parse the last Bun graph in `data`. +pub fn open(data: &[u8]) -> Result> { + open_with(data, &Options::default()) +} + +/// Parse one graph, bounding the module count by [`Options::max_modules`]. +pub fn open_with<'a>(data: &'a [u8], opts: &Options) -> Result> { + let mut end = data.len(); + let mut seen = 0usize; + let mut skipped = 0u32; + let mut looked = 0u32; + while let Some(at) = last_trailer(data, end) { + looked += 1; + // Each attempt walks the file again. A handful of trailers is every + // real executable; a caller asking for graph 10_000 must not turn + // that into 10_000 scans. + if looked > MAX_SKIPS.saturating_mul(2) { + break; + } + match parse_at(data, at, opts.max_modules) { + Ok(mut graph) => { + if seen == opts.which { + if skipped > 0 { + graph.warnings.insert( + 0, + format!( + "skipped {skipped} Bun trailer(s) whose offsets do not describe a graph" + ), + ); + } + return Ok(graph); + } + seen += 1; + } + Err(Fail::Skip) => { + skipped += 1; + if skipped > MAX_SKIPS && seen == 0 { + break; + } + } + Err(Fail::Bad(error)) => return Err(error), + } + end = at; + } + if seen > 0 { + return Err(Error::inconsistent(format!( + "bun graph index {} is past the {} graph(s) in the file", + opts.which, seen + ))); + } + Err(Error::NotRecognized { + expected: "bun standalone module graph", + }) +} + +/// A trailer that is not a graph, or a graph that is damaged. +enum Fail { + /// The marker is here, but the 32 bytes before it do not describe a + /// region that ends at those bytes. Look further back. + Skip, + /// They do, and then a span runs outside that region. This is the graph + /// Bun would read, so an earlier marker must not hide the error. + Bad(Error), +} + +fn last_trailer(data: &[u8], end: usize) -> Option { + let end = end.min(data.len()); + data[..end] + .windows(TRAILER.len()) + .rposition(|w| w == TRAILER) +} + +fn parse_at( + data: &[u8], + trailer_at: usize, + max_modules: u64, +) -> std::result::Result, Fail> { + if trailer_at < OFFSETS_LEN { + return Err(Fail::Skip); + } + let offsets_at = trailer_at - OFFSETS_LEN; + let mut header = Reader::le(&data[offsets_at..trailer_at]); + let byte_count = header.u64("bun.offsets.byte_count").map_err(Fail::Bad)?; + let modules_off = header + .u32("bun.offsets.modules.offset") + .map_err(Fail::Bad)?; + let modules_len = header + .u32("bun.offsets.modules.length") + .map_err(Fail::Bad)?; + let entry_point_id = header + .u32("bun.offsets.entry_point_id") + .map_err(Fail::Bad)?; + let argv_off = header.u32("bun.offsets.argv.offset").map_err(Fail::Bad)?; + let argv_len = header.u32("bun.offsets.argv.length").map_err(Fail::Bad)?; + let flags = header.u32("bun.offsets.flags").map_err(Fail::Bad)?; + + // A random marker almost never has a count that lands on itself. A + // zero count is what thirty-two zero bytes look like, and Bun never + // writes a graph of length zero: it returns no bytes instead. + if byte_count == 0 || byte_count > offsets_at as u64 { + return Err(Fail::Skip); + } + let base = offsets_at as u64 - byte_count; + let graph = &data[base as usize..offsets_at]; + + let modules_end = span_end(modules_off, modules_len).map_err(Fail::Bad)?; + let argv_end = span_end(argv_off, argv_len).map_err(Fail::Bad)?; + if modules_end > byte_count || argv_end > byte_count { + return Err(Fail::Bad(Error::BadField { + field: "bun.offsets", + value: byte_count, + reason: "does not cover the module records or the argv it names", + })); + } + + let record_size = record_size(graph, modules_off, modules_len)?; + // `record_size` is 0 only when the table is empty, and otherwise it + // divides `modules_len`. `checked_div` is what keeps the empty case + // from being a special division. + let count = u64::from(modules_len).checked_div(record_size).unwrap_or(0); + if count > max_modules { + return Err(Fail::Bad(Error::CapExceeded { + what: "bun modules", + requested: count, + limit: max_modules, + })); + } + // Pointers are checked before any name is copied, so a damaged record + // does not allocate the modules that were fine. + check_records(graph, modules_off, count, record_size).map_err(Fail::Bad)?; + + let mut warnings = Vec::new(); + let mut modules = Vec::with_capacity(count as usize); + for index in 0..count { + let record = record_bytes(graph, modules_off, record_size, index).map_err(Fail::Bad)?; + let mut cursor = Reader::le(record); + let name_span = read_span(&mut cursor).map_err(Fail::Bad)?; + let contents_span = read_span(&mut cursor).map_err(Fail::Bad)?; + let sourcemap = read_span(&mut cursor).map_err(Fail::Bad)?; + let bytecode = read_span(&mut cursor).map_err(Fail::Bad)?; + if record_size == RECORD_V2 { + let _module_info = read_span(&mut cursor).map_err(Fail::Bad)?; + let _origin = read_span(&mut cursor).map_err(Fail::Bad)?; + } + let encoding = cursor.u8("bun.module.encoding").map_err(Fail::Bad)?; + let loader = cursor.u8("bun.module.loader").map_err(Fail::Bad)?; + let format = cursor.u8("bun.module.format").map_err(Fail::Bad)?; + let side = cursor.u8("bun.module.side").map_err(Fail::Bad)?; + + let name_bytes = slice_span(graph, name_span, "bun.module.name").map_err(Fail::Bad)?; + let contents = + slice_span(graph, contents_span, "bun.module.contents").map_err(Fail::Bad)?; + let name = owned_name(name_bytes, index as u32, &mut warnings); + let entry = entry_point_id == index as u32; + modules.push(Module { + index: index as u32, + name, + contents, + contents_offset: base + u64::from(contents_span.offset), + sourcemap_len: sourcemap.length, + bytecode_len: bytecode.length, + encoding, + loader, + format, + side, + entry, + }); + } + + if count > 0 && u64::from(entry_point_id) >= count { + warnings.push(format!( + "entry point id {entry_point_id} is past the {count} module(s)" + )); + } + warn_duplicates(&modules, &mut warnings); + + let argv = slice_span( + graph, + Span { + offset: argv_off, + length: argv_len, + }, + "bun.argv", + ) + .map_err(Fail::Bad)?; + + Ok(Graph { + offset: base, + byte_count, + trailer_offset: trailer_at as u64, + record_size, + entry_point_id, + argv, + flags, + modules, + warnings, + }) +} + +/// A span Bun stores as two little-endian `u32`s, offset then length. +#[derive(Clone, Copy)] +struct Span { + offset: u32, + length: u32, +} + +fn read_span(cursor: &mut Reader<'_>) -> Result { + Ok(Span { + offset: cursor.u32("bun.span.offset")?, + length: cursor.u32("bun.span.length")?, + }) +} + +fn span_end(offset: u32, length: u32) -> Result { + u64::from(offset) + .checked_add(u64::from(length)) + .ok_or(Error::BadField { + field: "bun.span", + value: u64::from(offset), + reason: "overflows when added to its length", + }) +} + +fn slice_span<'a>(graph: &'a [u8], span: Span, field: &'static str) -> Result<&'a [u8]> { + if span.length == 0 { + return Ok(&[]); + } + let start = span.offset as usize; + let end = start + .checked_add(span.length as usize) + .ok_or(Error::BadField { + field, + value: u64::from(span.offset), + reason: "overflows when added to its length", + })?; + if end > graph.len() { + return Err(Error::BadField { + field, + value: u64::from(span.offset), + reason: "points outside the module graph", + }); + } + Ok(&graph[start..end]) +} + +/// 52 when that reading is consistent, otherwise 36, otherwise an error +/// when a known size divides the table but its spans do not fit. A length +/// that divides by neither is not a graph. +fn record_size(graph: &[u8], modules_off: u32, modules_len: u32) -> std::result::Result { + if modules_len == 0 { + return Ok(0); + } + let v2 = u64::from(modules_len) % RECORD_V2 == 0; + let v1 = u64::from(modules_len) % RECORD_V1 == 0; + if !v1 && !v2 { + return Err(Fail::Skip); + } + let v2_count = u64::from(modules_len) / RECORD_V2; + let v1_count = u64::from(modules_len) / RECORD_V1; + let v2_ok = v2 && check_records(graph, modules_off, v2_count, RECORD_V2).is_ok(); + let v1_ok = v1 && check_records(graph, modules_off, v1_count, RECORD_V1).is_ok(); + match (v2_ok, v1_ok) { + (true, false) => Ok(RECORD_V2), + (false, true) => Ok(RECORD_V1), + (true, true) => { + // A length that divides by both still has one reading whose + // names are paths. Prefer that one. A tie keeps the size Bun + // writes today. + let newer = name_score(graph, modules_off, v2_count, RECORD_V2); + let older = name_score(graph, modules_off, v1_count, RECORD_V1); + Ok(if older > newer { RECORD_V1 } else { RECORD_V2 }) + } + (false, false) => { + // The table is a whole number of records, so this is a graph + // with a span outside it. Report the size a current executable + // uses when that size divides. + let size = if v2 { RECORD_V2 } else { RECORD_V1 }; + let count = u64::from(modules_len) / size; + Err(Fail::Bad( + check_records(graph, modules_off, count, size).unwrap_err(), + )) + } + } +} + +/// How many module names look like paths. Used only to separate the two +/// record sizes when a byte length is a multiple of both. +fn name_score(graph: &[u8], modules_off: u32, count: u64, record_size: u64) -> u32 { + let mut score = 0; + for index in 0..count { + let Ok(record) = record_bytes(graph, modules_off, record_size, index) else { + return score; + }; + let mut cursor = Reader::le(record); + let Ok(span) = read_span(&mut cursor) else { + return score; + }; + let Ok(bytes) = slice_span(graph, span, "bun.module.name") else { + return score; + }; + if bytes.is_empty() || !bytes.iter().all(|b| b.is_ascii_graphic()) { + continue; + } + score += 1; + if bytes.contains(&b'/') { + score += 1; + } + } + score +} + +fn check_records(graph: &[u8], modules_off: u32, count: u64, record_size: u64) -> Result<()> { + let spans = if record_size == RECORD_V2 { 6 } else { 4 }; + for index in 0..count { + let record = record_bytes(graph, modules_off, record_size, index)?; + let mut cursor = Reader::le(record); + for _ in 0..spans { + let span = read_span(&mut cursor)?; + let _ = slice_span(graph, span, "bun.module")?; + } + } + Ok(()) +} + +fn record_bytes(graph: &[u8], modules_off: u32, record_size: u64, index: u64) -> Result<&[u8]> { + let start = u64::from(modules_off) + .checked_add(index.checked_mul(record_size).ok_or(Error::BadField { + field: "bun.module", + value: index, + reason: "overflows when multiplied by the record size", + })?) + .ok_or(Error::BadField { + field: "bun.module", + value: index, + reason: "overflows when located in the module table", + })?; + let end = start.checked_add(record_size).ok_or(Error::BadField { + field: "bun.module", + value: start, + reason: "overflows when the record size is added", + })?; + if end > graph.len() as u64 { + return Err(Error::OutOfBounds { + what: "bun.module", + offset: start, + len: record_size, + available: graph.len() as u64, + }); + } + Ok(&graph[start as usize..end as usize]) +} + +fn owned_name(bytes: &[u8], index: u32, warnings: &mut Vec) -> String { + let bytes = if bytes.len() > MAX_NAME { + warnings.push(format!( + "module {index} name is {} bytes; the listing keeps the first {MAX_NAME}", + bytes.len() + )); + &bytes[..MAX_NAME] + } else { + bytes + }; + match std::str::from_utf8(bytes) { + Ok(text) => text.to_string(), + Err(_) => { + warnings.push(format!("module {index} name is not utf-8")); + String::from_utf8_lossy(bytes).into_owned() + } + } +} + +fn warn_duplicates(modules: &[Module<'_>], warnings: &mut Vec) { + let mut seen = std::collections::BTreeSet::new(); + for module in modules { + if !seen.insert(module.name.as_str()) { + warnings.push(format!( + "module {} reuses the name {}", + module.index, module.name + )); + } + } +} + +fn is_path_suffix(name: &str, query: &str) -> bool { + name.len() > query.len() + && name.ends_with(query) + && name.as_bytes()[name.len() - query.len() - 1] == b'/' +} + +fn named(value: u8, table: &[&str], prefix: &str) -> String { + table + .get(usize::from(value)) + .map(|s| (*s).to_string()) + .unwrap_or_else(|| format!("{prefix}-{value}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// One module in a synthetic graph. + struct File { + name: &'static str, + contents: &'static [u8], + encoding: u8, + loader: u8, + format: u8, + side: u8, + } + + /// Build a graph the way Bun does: strings, then records, then argv, + /// then offsets, then the trailer. A trailing NUL sits after each + /// string and is not part of its length. + fn build(record_size: u64, files: &[File], entry: u32, argv: &[u8], flags: u32) -> Vec { + let mut blob = Vec::new(); + let mut spans: Vec<(u32, u32, u32, u32)> = Vec::new(); + for file in files { + let name_at = put_z(&mut blob, file.name.as_bytes()); + let body_at = put_z(&mut blob, file.contents); + spans.push(( + name_at, + file.name.len() as u32, + body_at, + file.contents.len() as u32, + )); + } + let modules_off = blob.len() as u32; + for (file, (name_off, name_len, body_off, body_len)) in files.iter().zip(&spans) { + put_u32(&mut blob, *name_off); + put_u32(&mut blob, *name_len); + put_u32(&mut blob, *body_off); + put_u32(&mut blob, *body_len); + put_u32(&mut blob, 0); + put_u32(&mut blob, 0); + put_u32(&mut blob, 0); + put_u32(&mut blob, 0); + if record_size == RECORD_V2 { + put_u32(&mut blob, 0); + put_u32(&mut blob, 0); + put_u32(&mut blob, 0); + put_u32(&mut blob, 0); + } + blob.extend_from_slice(&[file.encoding, file.loader, file.format, file.side]); + } + let modules_len = blob.len() as u32 - modules_off; + let argv_off = blob.len() as u32; + blob.extend_from_slice(argv); + if !argv.is_empty() { + blob.push(0); + } + let byte_count = blob.len() as u64; + put_u64(&mut blob, byte_count); + put_u32(&mut blob, modules_off); + put_u32(&mut blob, modules_len); + put_u32(&mut blob, entry); + put_u32(&mut blob, argv_off); + put_u32(&mut blob, argv.len() as u32); + put_u32(&mut blob, flags); + blob.extend_from_slice(TRAILER); + blob + } + + fn put_z(out: &mut Vec, bytes: &[u8]) -> u32 { + let at = out.len() as u32; + out.extend_from_slice(bytes); + out.push(0); + at + } + + fn put_u32(out: &mut Vec, value: u32) { + out.extend_from_slice(&value.to_le_bytes()); + } + + fn put_u64(out: &mut Vec, value: u64) { + out.extend_from_slice(&value.to_le_bytes()); + } + + fn sample(record_size: u64) -> Vec { + build( + record_size, + &[ + File { + name: "/$bunfs/root/a.js", + contents: b"AAA", + encoding: 1, + loader: 0, + format: 1, + side: 0, + }, + File { + name: "/$bunfs/root/b.txt", + contents: b"BBB", + encoding: 0, + loader: 13, + format: 0, + side: 1, + }, + ], + 0, + b"--flag", + 0b0001, + ) + } + + #[test] + fn a_current_graph_lists_both_modules_and_names_the_entry() { + let bytes = sample(RECORD_V2); + let graph = open(&bytes).expect("52-byte graph"); + assert_eq!(graph.record_size, RECORD_V2); + assert_eq!(graph.modules.len(), 2); + assert_eq!(graph.entry_point_id, 0); + assert!(graph.modules[0].entry); + assert!(!graph.modules[1].entry); + assert_eq!(graph.modules[0].name, "/$bunfs/root/a.js"); + assert_eq!(graph.modules[0].contents, b"AAA"); + assert_eq!(graph.modules[0].loader_name(), "jsx"); + assert_eq!(graph.modules[0].encoding_name(), "latin1"); + assert_eq!(graph.modules[0].format_name(), "esm"); + assert_eq!(graph.modules[1].loader_name(), "text"); + assert_eq!(graph.modules[1].side_name(), "client"); + assert_eq!(graph.argv, b"--flag"); + assert_eq!(graph.flags, 1); + assert!(graph.warnings.is_empty(), "{:?}", graph.warnings); + // The NUL Bun writes after a string is not part of the span. + assert!(!graph.modules[0].contents.contains(&0)); + let at = graph.modules[0].contents_offset as usize; + assert_eq!(&bytes[at..at + 3], b"AAA"); + } + + #[test] + fn an_older_record_is_not_read_as_the_new_one() { + // Thirteen 36-byte records are also nine 52-byte records. The spans + // only fit the size they were written with. Names differ so a + // duplicate warning is not what this test is looking at. + let files: Vec = (0..13) + .map(|i| File { + name: match i { + 0 => "/$bunfs/root/m0.js", + 1 => "/$bunfs/root/m1.js", + 2 => "/$bunfs/root/m2.js", + 3 => "/$bunfs/root/m3.js", + 4 => "/$bunfs/root/m4.js", + 5 => "/$bunfs/root/m5.js", + 6 => "/$bunfs/root/m6.js", + 7 => "/$bunfs/root/m7.js", + 8 => "/$bunfs/root/m8.js", + 9 => "/$bunfs/root/m9.js", + 10 => "/$bunfs/root/m10.js", + 11 => "/$bunfs/root/m11.js", + _ => "/$bunfs/root/m12.js", + }, + contents: b"x", + encoding: 1, + loader: 1, + format: 2, + side: 0, + }) + .collect(); + let bytes = build(RECORD_V1, &files, 3, b"", 0); + let modules_len = 13 * RECORD_V1; + assert_eq!( + modules_len % RECORD_V2, + 0, + "the fixture must be ambiguous on length alone" + ); + let graph = open(&bytes).expect("36-byte graph"); + assert_eq!(graph.record_size, RECORD_V1); + assert_eq!(graph.modules.len(), 13); + assert!(graph.modules[3].entry); + assert_eq!(graph.modules[3].name, "/$bunfs/root/m3.js"); + assert_eq!(graph.modules[10].loader_name(), "js"); + assert_eq!(graph.modules[0].format_name(), "cjs"); + } + + #[test] + fn a_current_graph_whose_length_also_divides_by_the_old_size_stays_current() { + // Nine 52-byte records are thirteen 36-byte records. The names are + // paths only in the size they were written with. + let files: Vec = (0..9) + .map(|i| File { + name: match i { + 0 => "/$bunfs/root/n0.js", + 1 => "/$bunfs/root/n1.js", + 2 => "/$bunfs/root/n2.js", + 3 => "/$bunfs/root/n3.js", + 4 => "/$bunfs/root/n4.js", + 5 => "/$bunfs/root/n5.js", + 6 => "/$bunfs/root/n6.js", + 7 => "/$bunfs/root/n7.js", + _ => "/$bunfs/root/n8.js", + }, + contents: b"y", + encoding: 1, + loader: 0, + format: 1, + side: 0, + }) + .collect(); + let bytes = build(RECORD_V2, &files, 1, b"", 0); + assert_eq!((9 * RECORD_V2) % RECORD_V1, 0); + let graph = open(&bytes).expect("52-byte graph"); + assert_eq!(graph.record_size, RECORD_V2); + assert_eq!(graph.modules.len(), 9); + assert!(graph.modules[1].entry); + assert_eq!(graph.modules[8].name, "/$bunfs/root/n8.js"); + assert_eq!(graph.modules[0].loader_name(), "jsx"); + } + + #[test] + fn the_last_trailer_wins_and_a_marker_inside_a_module_does_not() { + let mut bytes = b"noise \n---- Bun! ----\n more noise".to_vec(); + let shift = bytes.len() as u64; + bytes.extend(sample(RECORD_V2)); + // The module body itself contains the marker. It is data. + let body_at = bytes + .windows(3) + .position(|w| w == b"AAA") + .expect("contents"); + bytes[body_at..body_at + 3].copy_from_slice(b"---"); + // Put a real marker sequence into the contents by rebuilding is + // harder once we overwrote; instead prefix is enough, and a second + // copy is planted in front of the graph below. + let graph = open(&bytes).expect("real graph"); + assert_eq!(graph.offset, shift); + assert_eq!(graph.modules[0].contents, b"---"); + assert!(graph.warnings.is_empty(), "{:?}", graph.warnings); + } + + #[test] + fn a_trailing_marker_that_is_not_a_graph_does_not_hide_the_real_one() { + let mut bytes = sample(RECORD_V2); + bytes.extend_from_slice(&[0xff; OFFSETS_LEN]); + bytes.extend_from_slice(TRAILER); + let graph = open(&bytes).expect("skipped the trailing marker"); + assert_eq!(graph.modules.len(), 2); + assert_eq!(graph.warnings.len(), 1); + assert!(graph.warnings[0].contains("skipped 1")); + } + + #[test] + fn a_span_past_the_graph_is_an_error_rather_than_an_earlier_graph() { + let earlier = sample(RECORD_V2); + let mut bytes = earlier.clone(); + // A second graph whose contents span claims to start inside the + // graph and run past it: byte_count still lands on the offsets, so + // this is the graph, and it is damaged. + let mut broken = sample(RECORD_V2); + let trailer = broken + .windows(TRAILER.len()) + .rposition(|w| w == TRAILER) + .unwrap(); + let offsets = trailer - OFFSETS_LEN; + // contents length of the first module sits at: after its name span. + // Easier: set modules length's first record contents length to 0xffffffff + // by patching the record. Find "AAA" span length, the u32 before the + // bytes... The length is 3. Replace a 3 that is a u32 le inside the + // record area. There may be several. Set byte 0 of argv length? + // Patch entry is not a span. Patch the first contents length: search + // the record for the pattern name_len then later. + // The first module contents length is the u32 value 3 at a known + // place relative to modules. We'll overwrite modules_len's region + // by setting the contents length field. In `build`, contents length + // is the second span's length. Search for 03 00 00 00 that is + // followed later... Simply set the argv length, which is inside + // offsets, to a huge value while keeping byte_count fitting. + let argv_len_at = offsets + 8 + 4 + 4 + 4 + 4; + // offsets: byte_count u64, modules off u32, modules len u32, + // entry u32, argv off u32, argv len u32, flags u32. + // argv len is at offset 24. + assert_eq!(argv_len_at, offsets + 24); + broken[argv_len_at..argv_len_at + 4].copy_from_slice(&u32::MAX.to_le_bytes()); + bytes.extend(broken); + let error = open(&bytes).expect_err("damaged last graph"); + assert!( + matches!( + error, + Error::BadField { + field: "bun.offsets", + .. + } + ), + "{error}" + ); + } + + #[test] + fn no_marker_is_not_a_graph() { + let error = open(b"hello").unwrap_err(); + assert!(error.is_not_recognized()); + } + + #[test] + fn the_module_cap_is_enforced_before_the_names_are_kept() { + let bytes = sample(RECORD_V2); + let opts = Options { + which: 0, + max_modules: 1, + }; + let error = open_with(&bytes, &opts).unwrap_err(); + assert!( + matches!( + error, + Error::CapExceeded { + what: "bun modules", + .. + } + ), + "{error}" + ); + } + + #[test] + fn find_takes_a_path_an_index_or_a_unique_suffix() { + let bytes = sample(RECORD_V2); + let graph = open(&bytes).unwrap(); + assert_eq!(graph.find("/$bunfs/root/b.txt").unwrap(), 1); + assert_eq!(graph.find("1").unwrap(), 1); + assert_eq!(graph.find("b.txt").unwrap(), 1); + assert_eq!(graph.find("root/a.js").unwrap(), 0); + assert!(graph.find("nope").unwrap_err().is_not_recognized()); + assert!(graph.find("").is_err()); + } + + #[test] + fn a_suffix_that_matches_two_modules_says_so() { + let bytes = build( + RECORD_V2, + &[ + File { + name: "/$bunfs/root/a.js", + contents: b"1", + encoding: 1, + loader: 1, + format: 1, + side: 0, + }, + File { + name: "/$bunfs/other/a.js", + contents: b"2", + encoding: 1, + loader: 1, + format: 1, + side: 0, + }, + ], + 0, + b"", + 0, + ); + let graph = open(&bytes).unwrap(); + let error = graph.find("a.js").unwrap_err(); + let Error::Inconsistent { detail } = error else { + panic!("{error}"); + }; + assert!(detail.contains("2 modules"), "{detail}"); + } + + #[test] + fn an_entry_past_the_end_is_reported_and_the_modules_remain() { + let bytes = build( + RECORD_V2, + &[File { + name: "/$bunfs/root/only.js", + contents: b"z", + encoding: 1, + loader: 1, + format: 0, + side: 0, + }], + 4, + b"", + 0, + ); + let graph = open(&bytes).unwrap(); + assert_eq!(graph.modules.len(), 1); + assert!(!graph.modules[0].entry); + assert!( + graph + .warnings + .iter() + .any(|w| w.contains("entry point id 4")) + ); + } +} diff --git a/crates/e5r-format/src/lib.rs b/crates/e5r-format/src/lib.rs index 4072465..2a2b47f 100644 --- a/crates/e5r-format/src/lib.rs +++ b/crates/e5r-format/src/lib.rs @@ -12,6 +12,7 @@ #![warn(missing_docs)] pub mod archive; +pub mod bun; pub mod dwarf; pub mod ehframe; pub mod elf; diff --git a/crates/e5r-ir/src/opt.rs b/crates/e5r-ir/src/opt.rs index 32928ed..f3a761e 100644 --- a/crates/e5r-ir/src/opt.rs +++ b/crates/e5r-ir/src/opt.rs @@ -317,6 +317,7 @@ pub fn remove_dead(f: &mut SsaFunction) -> Changes { // A call reads its arguments out of registers the IR does not name as its // inputs, so without this every instruction that sets one up looks dead. // Deleting them does not just lose readability: it loses the argument. + // A branch leaving the function passes arguments to its tail callee too. let argument_locations: Vec = abi .integer_arguments .iter() @@ -329,10 +330,20 @@ pub fn remove_dead(f: &mut SsaFunction) -> Changes { .collect(); for b in f.blocks.values() { for (n, op) in b.ops.iter().enumerate() { + let tail_call = match op.kind { + SsaKind::Op(crate::op::Op::Branch) => op + .inputs + .first() + .and_then(|input| input.as_const()) + .is_some_and(|target| !f.blocks.contains_key(&e5r_core::Addr(target))), + SsaKind::Op(crate::op::Op::BranchInd) => true, + _ => false, + }; if !matches!( op.kind, SsaKind::Op(crate::op::Op::Call) | SsaKind::Op(crate::op::Op::CallInd) - ) { + ) && !tail_call + { continue; } for location in &argument_locations { @@ -410,6 +421,118 @@ fn has_effect(op: &crate::ssa::SsaOp) -> bool { mod tests { use super::*; + #[test] + fn independent_clobbers_are_not_common_expressions() { + use crate::ssa::{SsaBlock, SsaOp}; + let entry = e5r_core::Addr(0x1000); + let ops = [0, 8] + .into_iter() + .map(|offset| SsaOp { + addr: entry, + kind: SsaKind::Op(Op::Undefine), + out: Some(Value { + location: Location { + space: Space::Register, + offset, + size: 8, + }, + version: 0, + }), + inputs: vec![], + size: 8, + }) + .collect(); + let mut f = SsaFunction { + arch: e5r_core::Arch::X86_64, + entry, + blocks: BTreeMap::from([( + entry, + SsaBlock { + ops, + ..Default::default() + }, + )]), + }; + assert_eq!(cse(&mut f).folded, 0); + assert!( + f.blocks[&entry] + .ops + .iter() + .all(|op| op.kind == SsaKind::Op(Op::Undefine)) + ); + } + + #[test] + fn tail_calls_keep_argument_setup_live() { + use crate::ssa::{SsaBlock, SsaOp}; + let entry = e5r_core::Addr(0x1000); + let abi = crate::abi::of(&e5r_core::Arch::AArch64); + let argument = Value { + location: Location { + space: Space::Register, + offset: abi.integer_arguments[0], + size: 8, + }, + version: 0, + }; + let ops = vec![ + SsaOp { + addr: entry, + kind: SsaKind::Op(Op::IntAdd), + out: Some(argument), + inputs: vec![ + Operand::Undefined(Location { + space: Space::Register, + offset: abi.integer_arguments[1], + size: 8, + }), + Operand::Const(1, 8), + ], + size: 8, + }, + SsaOp { + addr: entry, + kind: SsaKind::Op(Op::Branch), + out: None, + inputs: vec![Operand::Const(0x2000, 8)], + size: 8, + }, + ]; + let mut f = SsaFunction { + arch: e5r_core::Arch::AArch64, + entry, + blocks: BTreeMap::from([( + entry, + SsaBlock { + ops, + ..Default::default() + }, + )]), + }; + remove_dead(&mut f); + assert!( + f.blocks[&entry] + .ops + .iter() + .any(|op| op.out == Some(argument)) + ); + // An internal branch does not implicitly consume call arguments. + f.blocks + .get_mut(&entry) + .unwrap() + .ops + .last_mut() + .unwrap() + .inputs = vec![Operand::Const(entry.get(), 8)]; + remove_dead(&mut f); + assert!( + !f.blocks[&entry] + .ops + .iter() + .any(|op| op.out == Some(argument)) + ); + } + #[test] fn constant_arithmetic_folds() { assert_eq!(evaluate(Op::IntAdd, &[2, 3], 8, &[]), Some(5)); @@ -522,8 +645,8 @@ fn mask_of_op( } }; let _ = (f, defs); - let a = op.inputs.first().map(&get).unwrap_or(u64::MAX); - let b = op.inputs.get(1).map(&get).unwrap_or(u64::MAX); + let a = op.inputs.first().map(get).unwrap_or(u64::MAX); + let b = op.inputs.get(1).map(get).unwrap_or(u64::MAX); match op.kind { SsaKind::Phi => op.inputs.iter().map(&get).fold(0, |acc, m| acc | m), SsaKind::Op(o) => match o { @@ -1910,6 +2033,7 @@ fn pure(o: Op) -> bool { | Op::BranchInd | Op::Return | Op::Unimplemented + | Op::Undefine | Op::Copy ) } diff --git a/crates/e5r-ir/tests/call_sites.rs b/crates/e5r-ir/tests/call_sites.rs index 1abcfbe..53463c8 100644 --- a/crates/e5r-ir/tests/call_sites.rs +++ b/crates/e5r-ir/tests/call_sites.rs @@ -38,16 +38,8 @@ use e5r_ir::ssa::SsaFunction; /// Floor on the share of recorded call-site arguments the detection also finds. /// Only raised. /// -/// Measured at 33 of 37 over the fixtures below. The four it misses are one -/// function, `pick` in `em-paths.{a64,x64}.{O1,O2}`, and they are one defect -/// rather than four: `pick` copies its second argument into the first argument -/// register and then tail calls, a tail call lifts to `Op::Branch` and not to -/// `Op::Return`, and `opt`'s liveness treats the convention's registers as live -/// only at a `Return`. So the copy is dead by the optimizer's rules, dead code -/// elimination deletes it, and the only read of that register is gone before -/// detection sees the function. The fix belongs in `opt`, which must treat a -/// branch leaving the function as a call and hold the argument registers live -/// across it; the floor comes back up when it lands. +/// Tail-call argument setup is live even when the exit is a branch, so +/// optimizing it away cannot hide a recorded incoming argument. const MIN_AGREEMENT: f64 = 0.89; fn corpus() -> Option { diff --git a/MANUAL.md b/docs/MANUAL.md similarity index 91% rename from MANUAL.md rename to docs/MANUAL.md index aabf042..dc86b16 100644 --- a/MANUAL.md +++ b/docs/MANUAL.md @@ -376,6 +376,21 @@ e5r archive libfoo.a --symbols # and what each one defines GNU and BSD archives, long names, both symbol index forms, and thin archives. +A Bun standalone executable (`bun build --compile`) carries its scripts in a +module graph after the native image. The graph is source and bytecode, so it +is listed rather than disassembled. Bun's own layout is what is read: the +last `\n---- Bun! ----\n` marker, the offsets just before it, and either +record size that layout has shipped. + +``` +e5r bun prog # modules, loaders, sizes +e5r bun prog --extract a.js # that module's bytes on stdout +``` + +`--extract` takes the path the graph stores, a decimal index, or a unique +suffix that begins at a `/`. It writes the bytes and nothing else, so it is +not combined with `--json`. + ``` e5r overlay firmware.exe ``` @@ -433,6 +448,27 @@ A project names its binary by content as well as by path. Opening it against a rebuilt binary says so rather than answering about bytes that are not there, and a binary that only moved is still the right one. +## Project dashboard and tasks + +```bash +e5r project new prog --name investigation # global project, absolute references +e5r project list --json +e5r ui # every registered project +e5r project import prog.e5r-proj --name imported +e5r project task investigation list --json +e5r project dashboard prog.e5r-proj # local overview + decompiler pane +e5r project dashboard prog.e5r-proj --port 0 # choose a free loopback port +e5r project task prog.e5r-proj add "Trace parser" --owner agent-a --next "Inspect main" +e5r project task prog.e5r-proj list --json +e5r project task prog.e5r-proj update T-0001 --revision 1 --input task.json +``` + +The browser and CLI share durable tasks with revision checks, owners, blockers, +prerequisites, next actions and evidence. Analysis loads on demand; tasks refresh live across all projects. Global +projects live under `E5R_PROJECT_HOME`, `$XDG_DATA_HOME/e5r/projects`, or +`~/.local/share/e5r/projects`. See [docs/dashboard.md](dashboard.md) +for the content format and supported project options. + ## Driving it from a program Every command takes `--json`. The schema is named in every document diff --git a/ROADMAP.md b/docs/ROADMAP.md similarity index 97% rename from ROADMAP.md rename to docs/ROADMAP.md index 380abf4..7033cbe 100644 --- a/ROADMAP.md +++ b/docs/ROADMAP.md @@ -27,18 +27,19 @@ rather than argued about again. | benchmarks and scorecard | built (M11) | | fuzzing, coverage and mutation tooling | built (M12); packaging not started | -The measured numbers live in [`docs/scorecard.md`](docs/scorecard.md), +The measured numbers live in [`docs/scorecard.md`](scorecard.md), including the ones that go against us. In short: `libc.so.6` analyzes in 0.10s and 58 MB, finding 3,517 functions with 95.3% analyzed completely, where `objdump -d` takes 0.26s and only disassembles; both decoders are at zero disagreements with their oracle; memory use is about ten times objdump's, which is the axis e5r is worse on. 164 tests, clippy clean. -e5r is a reverse engineering toolkit with a command line as its only front end. +e5r is a reverse engineering toolkit with a command line and an optional local +project workspace. It loads a binary, recovers functions, disassembles, lifts to an IR, decompiles to C, diffs two builds, and writes an analyst's findings to a text log that git -can merge. One static Rust binary, no JVM, no project server, no proprietary -database. +can merge. One static Rust binary, no JVM, no required project server, no +proprietary database. The previous contents of this repository were a C++ teaching disassembler for x86-64. It is gone from the tree as of M0 and stays readable in git history. The @@ -179,6 +180,11 @@ Depth-first: ELF and PE carry the workload, Mach-O follows, everything else wait `nm -s`. An archive is opened rather than loaded: it has no architecture, entry point or memory map, so making one an `Object` would mean picking a member and making every later answer about bytes the caller never chose. +- [x] Bun standalone module graphs. `bun build --compile` appends source and + bytecode the native image does not describe. Listed rather than loaded, + from the layout Bun's own source writes: the last trailer, the offsets + before it, and both record sizes that layout has shipped. `module_info` + and bytecode are reported by size and not interpreted. - [x] Overlay detection, section entropy map, and a packer heuristic that reports a suspicion with its evidence rather than a verdict. Nine finding kinds, each with a strength; a packer is named only where a section carries that @@ -219,7 +225,7 @@ Depth-first: ELF and PE carry the workload, Mach-O follows, everything else wait declines because it faults on hardware, and what it decodes where llvm declines is listed with the reason. - [x] SLEIGH runtime. The `.sla` format is worked out in - [`docs/sla-format.md`](../docs/sla-format.md) and its reader consumes all + [`docs/sla-format.md`](sla-format.md) and its reader consumes all 137 files Ghidra ships. The decode engine measures 0 wrong against objdump on AArch64 (19,710), x86-64 (5,688) and RISC-V 64 (2,567), the last being an architecture this tool could not decode at all and for @@ -495,7 +501,7 @@ Depth-first: ELF and PE carry the workload, Mach-O follows, everything else wait This is the feature that distinguishes e5r from every incumbent, so it gets designed before it gets coded, and the design lives in -[`docs/design/db.md`](../docs/design/db.md). +[`docs/design/db.md`](design/db.md). - [x] Content anchors. A function is identified by a fingerprint of its instruction-shape stream with branch targets excluded, so the identity @@ -697,14 +703,14 @@ designed before it gets coded, and the design lives in read by indexing the slice instead of through the reader. A `cargo-fuzz` setup for longer runs is still to do. - [x] Resource caps on every attacker-controlled count, with a documented - policy in [`docs/design/limits.md`](../docs/design/limits.md): bound by + policy in [`docs/design/limits.md`](design/limits.md): bound by the file first, then by the cap, check the cursor advanced, and read through the reader. Checked by the mutation fuzzer in the ordinary test suite rather than by review. - [x] `#![forbid(unsafe_code)]` on every crate except `e5r-cli`, which is `#![deny(unsafe_code)]` so one audited call can opt in. There is exactly one exception, the memory map, and its justification is in - [`docs/design/limits.md`](../docs/design/limits.md). + [`docs/design/limits.md`](design/limits.md). - [x] A no-panic gate on every path that reads foreign bytes, 1.26 million cases inside a 12-second budget so it runs every time rather than nightly. It asserts three things and not one: no panic, a per-case ceiling, and that @@ -942,7 +948,7 @@ Tasks: ground-truth functions: 1.000 and 1.000 stripped, and 0.766 and 0.937 with `.eh_frame` removed as well, which is the number that says what the analysis can do with nothing but code. See - [`docs/boundaries.md`](../docs/boundaries.md). + [`docs/boundaries.md`](boundaries.md). - [x] A DecBench backend for e5r, as `scripts/decbench_e5r.py`. Out of tree, which DecBench's own documentation permits, so the benchmark checkout stays untouched. Driven by `scripts/decbench.sh`. @@ -952,15 +958,18 @@ Tasks: - [x] Record the DecBench numbers in `docs/scorecard.md` per release, including the runs where we lose. The first run is one we lose: 23.4 union against angr's 37.0 on the same slice, with the method and the defect analysis in - [`docs/decbench.md`](../docs/decbench.md). + [`docs/decbench.md`](decbench.md). ## Not building Each of these is a decision. Reopening one needs a reason that did not exist when it was closed. -- **A GUI:** Not in the first year, possibly not ever from this repository. The - library API and the JSON schema are designed so someone else can build one. +- **A full desktop GUI:** The 2026-10-08 request reopens the earlier UI exclusion + for a local dashboard, durable project tasks and a decompiler pane. This + workspace uses existing library analysis and keeps the CLI complete; it does + not add a mandatory project service or desktop framework. See + [`docs/dashboard.md`](dashboard.md). - **A debugger:** rizin and gdb do this well and it is a different product. The emulator in M10 runs code paths for analysis; it does not attach to a process. - **Ghidra script compatibility:** Running Java is the thing this project exists diff --git a/docs/dashboard.md b/docs/dashboard.md new file mode 100644 index 0000000..a24d41b --- /dev/null +++ b/docs/dashboard.md @@ -0,0 +1,294 @@ +# A workspace for people and agents + +Start the global collection from any directory: + +```sh +e5r project new ./a.out --name parser +e5r ui +``` + +In this checkout, use `./target/release/e5r` if e5r is not installed. Open the +printed `http://127.0.0.1:7879` address. The collection shows every project's +live tasks, blockers, review requests and binary availability. Select a project +to open its task dashboard and decompiler; **All projects** returns to the +collection. The UI is read-only. Ask an agent to register projects and edit tasks through +the CLI. An unavailable binary does not prevent reading that project's tasks. + +Project storage follows h5i's convention, in this order: + +1. `E5R_PROJECT_HOME` +2. `$XDG_DATA_HOME/e5r/projects` (absolute XDG paths only) +3. `~/.local/share/e5r/projects` + +Each name owns `//project.e5rproj` and +`project.e5rproj.work/`. Binaries and existing annotation logs remain at their +recorded absolute paths. Creation refuses existing names. Omitting `--name` +uses the binary filename; `--out FILE` keeps the explicit local-file workflow. +Named project commands work from any directory. A registered name takes +precedence over a same-named local file; use `./FILE` to select a local manifest: + +```sh +e5r project list --json +e5r project task parser list --json +e5r project task parser add 'Trace input validation' --owner agent-a +e5r project import ./old.e5rproj --name older-investigation +e5r project relocate parser /new/path/to/a.out +e5r project dashboard parser # optional single-project mode +``` + +Import copies the manifest and task history, preserving the original files. +Legacy relative references resolve against the original manifest's directory; +use `--base-dir ORIGINAL_CWD` if they were recorded relative to a different +working directory. New manifests always use absolute references. Relocate +checks content before changing a path. The collection checks existence and +size only; the content digest is verified before loading a binary. + +Both UI modes support `--port 0` for a free port. Single-project mode also +accepts `--binary PATH` as a temporary path override. Ctrl-C stops the server. +Assets ship inside the Rust executable; no frontend build step, CDN, font +download, or Node runtime is required. + +The overview answers “what needs me?” before showing the rest of the work. +Blocked work always carries a reason, review requests carry their handoff, and +ready tasks have no unfinished prerequisites. High priority sorts first within +these groups. Counts have literal meanings; there is no inferred health score. +Search and state filters keep completed work available without crowding the +initial view. Tasks refresh every five seconds, including changes from agents. +Open task details follow agent updates. **Full view** expands the popup; +**Back to popup** restores it. Evidence occupies its own full-width section. + +The decompiler gives code the center of attention: project navigation occupies a +compact top strip, the searchable function browser sits on the left, and the +**Evidence & tasks** panel sits on the right on wide screens. Layout buttons +let you hide either side to give long expressions more space. On narrow screens, +functions and evidence open as dismissible overlays; selecting a function closes +the function browser. Evidence remains accessible at every width. + +Use `/` to focus function search, and **Back** / **Forward** or `Alt+Left` / +`Alt+Right` to retrace function navigation. Following a known function name or +address keeps the browser selection visible, clearing a filter that excludes it. +Pseudocode, disassembly, references, call graph, control flow, records and +annotations share the same selected function. Arrow keys move between focused view tabs. **A− / A+** adjust code +text from 12 to 22 pixels (14 by default), with the preference retained in this +browser; **Wrap** also persists. Copy code or its CLI command, and ask an agent +to retain the function address as evidence in a task. + +**Call graph** groups unique callers on the left, the selected function in the +center, and unique callees on the right. Click a caller or callee to navigate. +Unresolved indirect call targets are not included. Large function lists are +loaded in display batches; search always searches the complete list. Variables +are shown for pseudocode only. + +**Disassembly** shows each instruction's address, raw encoding, length, control +flow kind and known target name. Saved comments appear at their instruction +address, with author and anchor match. **Annotations** shows saved names, types +and comments together; low-confidence anchor matches are marked explicitly. +Comments are also readable in the context panel while viewing pseudocode; e5r +keeps their instruction addresses rather than inventing source-line positions. + +**References** defaults to incoming references to the selected entry address. +Its selector also offers **All outgoing references**, covering every recovered +block, and **Strings & data**, showing data/read/write references with section, +symbol, extracted string and encoding, or up to 32 mapped bytes. References into +a string retain its start address and byte offset. Click an instruction address +to open it in disassembly, or a known function target to navigate to that function. +A split function's unanalysed gaps do not contribute references. + +**Control flow** draws the function's basic blocks and recovered successor edges, +with instruction counts, block endings and unresolved states. **View instructions** +opens the block's disassembly. A table retains every block, successor and target +outside the recovered blocks. Graphs with more than 160 blocks use the table to +keep the browser responsive; the table loads 200 blocks at a time with a button +to reveal the next batch. This graph describes control inside one function; +**Call graph** describes calls between functions. + +The overview's **Binary information** section shows format, architecture, bitness, +endianness, entry point, image base, counts, container metadata and loader warnings. +Unavailable binaries leave the project records and tasks readable. + +The visual hierarchy follows a repeatable reading path: project and mode at the +top, function selection at the left, function identity and output in the center, +then supporting evidence at the right. Body and code text carry the findings; +metadata is quieter but readable. Blue marks selection and navigation; keyboard +focus uses a bright neutral outline. +Clickable code names have persistent underlines; plain addresses and encodings +use quieter neutral text. Comments share one color across code views, and CFG +edges use a neutral tone rather than the navigation color; +amber marks attention and incomplete analysis. Status and provenance always +remain words, so color alone never carries an analysis claim. Project work and +records use the same surfaces and type hierarchy. + +Design references: h5i's local console uses restrained surface steps and reserves +color for meaningful signals. [IDA's subviews](https://docs.hex-rays.com/ida-9.2/user-guide/user-interface/subviews) +keep function selection synchronized with the active view and retain navigation +history. [Ghidra's CodeBrowser](https://ghidra.re/ghidra_docs/GhidraClass/Beginner/Introduction_to_Ghidra_Student_Guide.html) +provides a stable function/listing/decompiler context. e5r uses these navigation +principles at function granularity; its analysis does not supply instruction to +pseudocode correspondence. + +The library owns all analysis facts and all task readiness rules. The pane +shows boundary strength, source evidence, incomplete control flow, unmodelled +operations and signature conflicts. It does not imply that pseudocode is +verified source or fabricate a source-to-instruction mapping. Binary analysis +and annotations are a snapshot taken when the decompiler first loads. Restart +the server after changing binary bytes or annotations. Manifest changes and +relocated paths invalidate the cached analysis. Task data stays live. Signature-library references and patch-set references +remain in the project file; they are not automatically applied to the loaded +binary. Unknown analysis settings and custom readers are refused. Supported +analysis settings are `scan_gaps`, `follow_calls`, `strings`, `xrefs`, `noreturn`, +`data` (boolean) and `threads` (integer). + +## The agent interface + +The CLI writes the task store that the browser reads: + +```sh +e5r project task investigation.e5rproj add 'Trace input validation' \ + --owner agent-a --next 'Inspect parse_header and its callers' \ + --priority high --evidence function:0x401000 --author agent-a +e5r project task investigation.e5rproj list --json +e5r project task investigation.e5rproj update T-0001 \ + --revision 1 --input task.json --author agent-a +``` + +`add` and `update` return the saved task as JSON. `list --json` returns +`e5r.work.v1`, with each saved task, `ready`, `waiting_on` and `attention`. +`update` takes the last observed revision and a complete content document: + +```json +{ + "title": "Trace input validation", + "description": "Determine whether the parser validates length before copying.", + "state": "active", + "priority": "high", + "owner": "agent-a", + "next": "Inspect the copy call and its guard", + "blocker": "", + "evidence": ["function:0x401000", "notes/input-validation.md"], + "depends_on": [] +} +``` + +States: `planned`, `active`, `blocked`, `review`, `done`. Priority: `high`, +`normal`, `low`. Active work requires an owner; blocked work requires a reason. +Clear `blocker` when leaving the blocked state. Prerequisites must exist, be +unique, and not form a cycle. Active, review and done tasks require completed +prerequisites. Reopening a prerequisite is refused while a dependent is active, +in review or done. Next actions and evidence are free text; their correctness +remains the writer's responsibility. Link a task to a function with the exact +`function:0xADDRESS` evidence string shown in the function pane. + +Task content is limited to 64 KiB. Writer names contain 1–200 bytes. `--input` +can also create a task from a full content document. Content flags +apply when supplying a title and cannot be combined with `--input`, which +supplies the whole content instead. +`--author` defaults to `E5R_AUTHOR`, then the OS user. The browser has no write controls. + +## Durability and conflicts + +Coordination lives in `.work/T-0001.json`, one file per task, separate +from the existing binary project and annotation log. Each task retains previous +contents, writer names and timestamps. Commit the directory with the project. +Different task files merge normally; concurrent edits to the same task, or two +branches allocating the same new id, require a git conflict review. This store +does not claim the annotation log's order-independent merge semantics. + +Local writes take a directory lock, check the revision and prerequisites, write +a synced temporary file, and rename it over the task. A stale update is refused +instead of overwriting the human or agent who edited first. Agents must reread and reconcile rejected updates. The lock and +scratch files are ignored by git. If a process dies during a write, inspect the +PID in `.work/.lock`; remove that lock only after confirming the writer +is no longer running. Corrupt JSON is an error, never an invisible missing task. + +## Local HTTP surface + +The workspace binds IPv4 loopback only. Host and Origin checks reject cross-site +and rebound-host requests. All non-GET requests are refused with 405. Edits use the CLI. Analysis endpoints accept only a recovered function's address. + +| Route | Meaning | +| --- | --- | +| `GET /api/projects` | all registered projects and live task signals | +| `GET /api/project` | project identity and container information | +| `GET /api/tasks` | shared task snapshot | +| `GET /api/functions` | recovered functions, using the existing `e5r/1` JSON | +| `GET /api/decompile/0xADDRESS` | one function's pseudocode | +| `GET /api/disas/0xADDRESS` | one function's instructions | +| `GET /api/xrefs/0xADDRESS` | incoming references | +| `GET /api/callgraph/0xADDRESS` | incoming and outgoing direct-call edges | +| `GET /api/context/0xADDRESS` | function-local annotations, outgoing references with target context, and CFG blocks (`e5r.context.v1`) | +| `GET /api/records` | findings, notes and reports | + +In collection mode, workspace routes are prefixed with `/p/NAME` (for example, +`/p/parser/api/tasks`); `/api/projects` remains global. + +Responses are JSON; task conflicts return 409, missing routes/functions return +404, invalid content returns 400. Analysis runs on a separate worker with a bounded queue, so project monitoring +and task viewing remain responsive during decompilation. Only one analyzed +program is resident at a time; switching projects evicts it. The existing budget is +checked between functions, so it is not a hard preemptive timeout for one very +large function. The CLI remains available independently during analysis. + +## Checking changes + +Run `cargo test --release --workspace` for Rust checks. For the browser workflow, +install Playwright and its Chromium browser outside the checkout, then run: + +```sh +cargo build --release -p e5r-cli +E5R_PLAYWRIGHT_MODULE=/path/to/playwright/index.mjs node scripts/test-dashboard.mjs +E5R_PLAYWRIGHT_MODULE=/path/to/playwright/index.mjs node scripts/test-projects.mjs +``` + +The integration check compiles a small C binary with `cc`, starts an isolated +workspace on a free port, compares decompiler output with the CLI, tests live agent updates, read-only HTTP routes, records, call graphs and task full view, checks function navigation, comments, encodings, function-local outgoing/data references, CFG edges and linked evidence, +and verifies cross-origin rejection and mobile layout. It removes its temporary +project and stops the server when finished. Screenshots are written under `/tmp`. + +The collection check verifies global registration/import, working-directory +independence, project isolation, CLI creation and read-only collection, decompiler access and binary +relocation with a temporary `E5R_PROJECT_HOME`. + +## Findings, notes and reports + +Agents write records; humans read them in the overview and function workspace. Each kind has a separate +revisioned store under `.work/{finding,note,report}/`, using the same +content and history format as tasks. IDs and prerequisites are local to a kind. +Records do not contribute to task readiness or task counts. Imports preserve +all three stores along with tasks. + +```sh +e5r project record parser finding add 'Length check before copy' \ + --description 'The recovered guard rejects lengths above 32 before the copy.' \ + --evidence function:0x401000 --author agent-a +e5r project record parser note add 'Open questions' --description 'Check indirect callers.' +e5r project record parser report add 'Parser investigation' --description 'Investigation text' +e5r project record parser finding list --json +e5r project record parser finding update T-0001 --revision 1 --input finding.json +``` + +Attach a note to a recovered function using its exact address as evidence: + +```sh +e5r project record parser note add 'Input length checks' \ + --description 'The parser rejects lengths above 32 before the copy.' \ + --evidence function:0x401000 --author agent-a +``` + +The decompiler's **Function records** section shows notes, findings and reports +linked to the selected function. Click a record or open the **Records** tab to +read full text beside the function browser. Writer and revision are displayed; updates refresh every five +seconds without restarting analysis. Records linked to other functions and records +without a function link stay in the project overview. Selecting another function +changes the displayed records. The Records tab is also accessible on narrow screens. +The CLI command panel supplies a note creation command for the selected function. +Existing `annotate comment` entries remain analysis annotations, separate from +these live project records; comments are shown in disassembly and Annotations. + +Full record text lives in `description`; use `--input FILE` for large content. +The UI renders text literally, including Markdown, without executing HTML. + +Disassembly JSON (`e5r/1`) includes additive `bytes` and `target_name` fields for +each instruction. `bytes` is the mapped encoding in hex, or null if unavailable; +`target_name` is a known name for a direct flow target, or null. The JSON includes +encodings regardless of the CLI's text-only `--bytes` switch. Other CLI consumers +continue to receive the same fields as before. diff --git a/docs/design/loading.md b/docs/design/loading.md index 5652b6b..7575bf1 100644 --- a/docs/design/loading.md +++ b/docs/design/loading.md @@ -41,6 +41,8 @@ table, and a relocation count of `sh.size / 24` where `sh.size` was 7x10^17. - Language runtime metadata: Go's `pclntab`, Rust's symbol conventions, Objective-C's class and method lists. - `ar` archives, which are listed rather than loaded, for the reason below. +- Bun standalone module graphs, listed the same way: the bytes are source + and bytecode, read from the layout Bun publishes, and not mapped as code. - Overlays and entropy, which are measured rather than judged. ## What it deliberately does not do @@ -51,6 +53,10 @@ one member and making every later answer about bytes the caller did not choose. `archive::open` is separate, and `load` refuses an archive with a message saying what to do instead. +**It does not load a Bun standalone graph as code.** `bun::open` lists the +modules and, when asked, returns one module's bytes. It does not decode the +bytecode cache and it does not make the graph an `Object`. + **It does not render a verdict about packing.** `overlay::analyze` reports that a section is writable and executable, that its file bytes are far fewer than the memory it claims, that a 4 KB window has entropy 7.9. Those are diff --git a/scripts/build-release.sh b/scripts/build-release.sh index 092107a..dc9c386 100755 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -91,7 +91,9 @@ for target in "${targets[@]}"; do rm -rf "$stage" mkdir -p "$stage" cp "$bin" "$stage/e5r" - cp README.md LICENSE MANUAL.md "$stage/" + cp README.md LICENSE "$stage/" + mkdir -p "$stage/docs" + cp docs/MANUAL.md docs/ROADMAP.md docs/dashboard.md "$stage/docs/" tar -C "$out" "${tarflags[@]}" -czf "$out/$name.tar.gz" "$name" rm -rf "$stage" built+=("$name.tar.gz") diff --git a/scripts/test-dashboard.mjs b/scripts/test-dashboard.mjs new file mode 100644 index 0000000..2c90da2 --- /dev/null +++ b/scripts/test-dashboard.mjs @@ -0,0 +1,366 @@ +// Optional browser integration check. Requires Playwright + Chromium, and cc. +// E5R_PLAYWRIGHT_MODULE may point to an out-of-tree Playwright installation. +import assert from "node:assert/strict"; +import { execFileSync, spawn } from "node:child_process"; +import { mkdtempSync, writeFileSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { get } from "node:http"; +const { chromium } = await import( + process.env.E5R_PLAYWRIGHT_MODULE || "playwright" +); +const root = mkdtempSync(join(tmpdir(), "e5r-dashboard-")); +const executable = resolve(process.env.E5R_BINARY || "target/release/e5r"); +const binary = join(root, "sample"), + project = join(root, "sample.e5rproj"); +let server, browser; +function cli(...args) { + return execFileSync(executable, args, { encoding: "utf8" }); +} +try { + writeFileSync( + join(root, "sample.c"), + `#include \n__attribute__((noinline)) int parse_header(int length) { if(length < 0 || length > 32) return -1; return length + 1; }\nint main(void) { puts("function string "); return parse_header(4) == 5 ? 0 : 1; }\n__attribute__((noinline)) int large_cfg(int length) { ${Array.from({length:110}, (_,i) => `if (length == ${i}) length += ${i+1};`).join(" ")} return length; }\n`, + ); + execFileSync("cc", ["-g", "-O0", join(root, "sample.c"), "-o", binary]); + const initialFunctions = JSON.parse(cli("funcs", binary, "--json")).items; + const initialMain = initialFunctions.find(f => f.name === "main"); + const initialParser = initialFunctions.find(f => f.name === "parse_header"); + const initialDisas = JSON.parse(cli("disas", binary, initialMain.addr, "--json")); + const nonEntry = initialDisas.items[0].insns[1].addr; + cli("annotate", binary, "comment", initialMain.addr, "Entry comment "); + cli("annotate", binary, "comment", nonEntry, "Instruction comment"); + cli("annotate", binary, "comment", initialParser.addr, "Parser-only annotation"); + cli("project", "new", binary, "--out", project); + const task = JSON.parse( + cli( + "project", + "task", + project, + "add", + "Trace input validation", + "--owner", + "agent-a", + "--next", + "Inspect parser guard", + "--priority", + "high", + ), + ); + const url = await new Promise((ok, fail) => { + server = spawn( + executable, + ["project", "dashboard", project, "--port", "0"], + { stdio: ["ignore", "pipe", "pipe"] }, + ); + let log = ""; + const timer = setTimeout( + () => fail(new Error("server startup timed out: " + log)), + 30000, + ); + server.stderr.on("data", (d) => { + log += d; + const m = log.match(/Project workspace: (http:\/\/127\.0\.0\.1:\d+)/); + if (m) { + clearTimeout(timer); + ok(m[1]); + } + }); + server.on("exit", (code) => { + clearTimeout(timer); + fail(new Error(`server exited ${code}: ${log}`)); + }); + }); + const functions = await (await fetch(url + "/api/functions")).json(); + const main = functions.items.find((f) => f.name === "main"); + assert(main); + const parser = functions.items.find(f => f.name === "parse_header"); + assert(parser); + const decompiled = await ( + await fetch(url + `/api/decompile/${main.addr}`) + ).json(); + const cliDecompiled = JSON.parse( + cli("decompile", binary, main.addr, "--json"), + ); + assert.deepEqual(decompiled, cliDecompiled, "pane output must equal the CLI"); + assert.equal( + ( + await fetch(url + "/api/tasks", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }) + ).status, + 405, + ); + assert.equal( + ( + await fetch(url + "/api/tasks", { + headers: { Origin: "https://attacker.example" }, + }) + ).status, + 403, + ); + assert.equal( + await new Promise((ok, fail) => { + get( + url + "/api/tasks", + { headers: { Host: "attacker.example" } }, + (response) => { + response.resume(); + ok(response.statusCode); + }, + ).on("error", fail); + }), + 403, + ); + assert.equal((await fetch(url + "/api/decompile/all")).status, 400); + assert.equal((await fetch(url + "/api/context/all")).status, 400); + assert.equal((await fetch(url + "/api/context/0xffffffffffffffff")).status, 404); + assert.equal( + ( + await fetch(url + "/api/tasks", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-E5R-Client": "dashboard", + }, + body: "x".repeat(128 * 1024 + 1), + }) + ).status, + 405, + ); + browser = await chromium.launch({ headless: true, args: ["--no-sandbox"] }); + const page = await browser.newPage({ + viewport: { width: 1440, height: 1050 }, + }); + const errors = []; + page.on("pageerror", (e) => errors.push(e.message)); + // Exercise warning rendering even when this well-formed fixture has no loader warnings. + await page.route("**/api/project", async route => { + const response = await route.fetch(); + const metadata = await response.json(); + metadata.info.warnings.push("Loader warning "); + await route.fulfill({response, json:metadata}); + }); + await page.goto(url); + await page + .locator("#headline") + .filter({ hasText: "The next step is clear." }) + .waitFor(); + assert((await page.locator("#binary-info").textContent()).includes("Entry point")); + assert((await page.locator("#loader-warnings").textContent()).includes("Loader warning e.classList.contains("full-view"))); + await page.screenshot({path:"/tmp/e5r-dashboard-task-full.png", fullPage:true}); + await page.locator("#expand-task").click(); + assert(!(await page.locator("#task-dialog").evaluate(e => e.classList.contains("full-view")))); + const draft = { ...task.content, next: "Agent update", evidence: ["Evidence line\n".repeat(100)] }; + const input = join(root, "edit.json"); + writeFileSync(input, JSON.stringify(draft)); + cli("project", "task", project, "update", task.id, "--revision", "1", "--input", input, "--author", "agent-b"); + await page.locator("#task-detail").filter({hasText: "Agent update"}).waitFor(); + assert(await page.locator(".task-evidence").evaluate(e => e.getBoundingClientRect().height >= 150)); + await page.locator("#close-dialog").click(); + for (const kind of ["finding", "note", "report"]) { + cli("project", "record", project, kind, "add", `${kind} title`, "--description", '', "--evidence", `function:${main.addr}`, "--author", "agent-b"); + } + cli("project", "record", project, "note", "add", "Parser-only note", "--description", "Parser investigation", "--evidence", `function:${parser.addr}`); + cli("project", "record", project, "note", "add", "Unlinked project note", "--description", "General project context"); + const record = JSON.parse(cli("project", "record", project, "finding", "list", "--json")).tasks[0].task; + writeFileSync(input, JSON.stringify({...record.content, description:"Updated finding"})); + cli("project", "record", project, "finding", "update", record.id, "--revision", "1", "--input", input); + assert.throws(() => cli("project", "record", project, "finding", "update", record.id, "--revision", "1", "--input", input), /revision conflict/); + assert.equal(JSON.parse(cli("project", "record", project, "finding", "list", "--json")).tasks[0].task.history.length, 1); + await page.locator("#refresh").click(); + await page.locator("#records summary").filter({hasText:"report title"}).waitFor(); + assert.equal(await page.locator("#records img").count(), 0); + await page.locator("#records summary").filter({hasText:"report title"}).click(); + await page.locator("#refresh").click(); + assert(await page.locator("#records details").filter({hasText:"report title"}).evaluate(e => e.open)); + await page.screenshot({ + path: "/tmp/e5r-dashboard-overview.png", + fullPage: true, + }); + await page.locator("#nav-workspace").click(); + await page.locator("#function-search").fill("main"); + await page.locator('.function-row[title="main"]').click(); + await page.locator(".line-source").first().waitFor(); + assert.equal(await page.locator("#function-name").textContent(), "main"); + assert(await page.locator("#code").textContent()); + assert.equal(await page.locator("#function-note-count").textContent(), "3"); + assert.equal(await page.locator("#function-notes strong").count(), 3); + assert((await page.locator("#function-notes").textContent()).includes("finding · finding title")); + assert((await page.locator("#function-notes").textContent()).includes("report · report title")); + await page.locator("#function-notes button").filter({hasText:"note · note title"}).click(); + assert.equal(await page.getByRole("tab", {name:"Records", exact:true}).getAttribute("aria-selected"), "true"); + assert.equal(await page.locator("#code .function-note").count(), 3); + assert((await page.locator("#code").textContent()).includes('')); + assert.equal(await page.locator("#code img").count(), 0); + const note = JSON.parse(cli("project", "record", project, "note", "list", "--json")).tasks.find(r => r.task.content.title === "note title").task; + writeFileSync(input, JSON.stringify({...note.content, description:"Updated function note\nSecond line"})); + cli("project", "record", project, "note", "update", note.id, "--revision", "1", "--input", input, "--author", "agent-c"); + await page.locator("#refresh").click(); + await page.locator("#code .record-body").filter({hasText:"Updated function note"}).waitFor(); + assert((await page.locator("#function-notes").textContent()).includes("Updated function note")); + assert((await page.locator("#code").textContent()).includes("agent-c")); + await page.screenshot({path:"/tmp/e5r-dashboard-function-notes.png", fullPage:true}); + await page.locator("#function-search").fill("parse_header"); + await page.locator('.function-row[title="parse_header"]').click(); + assert.equal(await page.locator("#code .function-note h3").textContent(), "note · Parser-only note"); + assert(!(await page.locator("#code").textContent()).includes("Updated function note")); + await page.locator("#back").click(); + await page.locator("#code .record-body").filter({hasText:"Updated function note"}).waitFor(); + await page.getByRole("tab", {name:"Pseudocode"}).click(); + await page.locator(".line-source").first().waitFor(); + await page.screenshot({ + path: "/tmp/e5r-dashboard-decompiler.png", + fullPage: true, + }); + await page + .locator("#code a.call") + .filter({ hasText: "parse_header" }) + .first() + .click(); + await page + .locator("#function-name") + .filter({ hasText: "parse_header" }) + .waitFor(); + // Following a call keeps the function browser synchronized, even after filtering. + assert.equal(await page.locator("#function-search").inputValue(), ""); + assert.equal(await page.locator(".function-row.selected strong").textContent(), "parse_header"); + await page.locator("#back").click(); + await page.locator("#function-name").filter({ hasText: "main" }).waitFor(); + await page.locator("#forward").click(); + await page.locator("#function-name").filter({ hasText: "parse_header" }).waitFor(); + await page.keyboard.press("Alt+ArrowLeft"); + await page.locator("#function-name").filter({ hasText: "main" }).waitFor(); + await page.locator("#font-larger").click(); + assert.equal(await page.locator(".code-line").first().evaluate(e => getComputedStyle(e).fontSize), "15px"); + assert.equal(await page.evaluate(() => localStorage.getItem("e5r.code-size")), "15"); + await page.locator("#toggle-context").click(); + assert.equal(await page.locator("#inspector").isVisible(), false); + await page.locator("#toggle-functions").click(); + assert.equal(await page.locator("#function-pane").isVisible(), false); + await page.locator("#toggle-functions").click(); + await page.locator("#toggle-context").click(); + await page.getByRole("tab", { name: "Disassembly" }).click(); + await page.locator("#quality").filter({ hasText: "instructions" }).waitFor(); + assert((await page.locator("#code").textContent()).includes(main.addr)); + const disas = await (await fetch(url + `/api/disas/${main.addr}`)).json(); + assert.deepEqual(disas, JSON.parse(cli("disas", binary, main.addr, "--json"))); + assert(disas.items[0].insns.every(i => i.bytes.length === i.len * 2)); + await page.locator(".instruction-comment").filter({hasText:"Instruction comment"}).waitFor(); + assert((await page.locator("#code").textContent()).includes("Entry comment a.addr === nonEntry && a.value === "Instruction comment")); + assert(!context.annotations.some(a => a.value === "Parser-only annotation")); + const outgoing = context.references.find(r => r.to === parser.addr); + assert(outgoing && outgoing.from !== main.addr, "outgoing references must include non-entry instructions"); + const string = context.references.find(r => r.string?.text === "function string "); + assert(string, "function data references must include extracted strings"); + await page.locator("#reference-direction").selectOption("outgoing"); + await page.locator("#quality").filter({hasText:"outgoing references"}).waitFor(); + assert((await page.locator("#code").textContent()).includes(parser.addr)); + await page.locator("#reference-direction").selectOption("data"); + await page.locator("#code").filter({hasText:"function string "}).waitFor(); + assert.equal(await page.locator("#code img").count(), 0); + await page.screenshot({path:"/tmp/e5r-dashboard-data.png", fullPage:true}); + await page.getByRole("tab", {name:"Annotations", exact:true}).click(); + await page.locator("#code .annotation-text").filter({hasText:"Instruction comment"}).waitFor(); + assert((await page.locator("#code").textContent()).includes("match")); + assert.equal(await page.locator("#code img").count(), 0); + await page.getByRole("tab", {name:"Control flow", exact:true}).click(); + await page.locator(".cfg-node").first().waitFor(); + assert.equal(await page.locator(".cfg-node").count(), context.blocks.length); + await page.locator(".cfg-node button").first().click(); + await page.locator(".instruction.selected").waitFor(); + await page.locator("#function-search").fill("parse_header"); + await page.locator('.function-row[title="parse_header"]').click(); + await page.getByRole("tab", {name:"Control flow", exact:true}).click(); + const parserContext = await (await fetch(url + `/api/context/${parser.addr}`)).json(); + await page.locator(".cfg-node").first().waitFor(); + assert(parserContext.blocks.length > 1); + assert.equal(await page.locator(".cfg-canvas svg > path").count(), parserContext.blocks.flatMap(b => b.successors).filter(addr => parserContext.blocks.some(b => b.addr === addr)).length); + await page.screenshot({path:"/tmp/e5r-dashboard-cfg.png", fullPage:true}); + await page.locator("#function-search").fill("large_cfg"); + await page.locator('.function-row[title="large_cfg"]').click(); + const large = functions.items.find(f => f.name === "large_cfg"); + const largeContext = await (await fetch(url + `/api/context/${large.addr}`)).json(); + assert(largeContext.blocks.length > 200); + await page.locator(".cfg-table tbody tr").first().waitFor(); + assert.equal(await page.locator(".cfg-node").count(), 0); + assert.equal(await page.locator(".cfg-table tbody tr").count(), 200); + await page.locator(".cfg-more").click(); + assert.equal(await page.locator(".cfg-table tbody tr").count(), largeContext.blocks.length); + await page.locator("#back").click(); + await page.locator("#back").click(); + await page.getByRole("tab", { name: "Call graph" }).click(); + await page.locator(".call-node").filter({hasText:"parse_header"}).first().waitFor(); + await page.screenshot({path:"/tmp/e5r-dashboard-callgraph.png", fullPage:true}); + await page.locator(".call-node").filter({hasText:"parse_header"}).first().click(); + await page.locator("#function-name").filter({hasText:"parse_header"}).waitFor(); + await page.locator(".call-node").filter({hasText:"main"}).first().waitFor(); + assert.equal(await page.evaluate(() => window.taskXss), undefined); + assert.equal(await page.evaluate(() => window.commentXss), undefined); + assert.equal(await page.evaluate(() => window.warningXss), undefined); + await page.setViewportSize({ width: 390, height: 844 }); + if (await page.locator("#inspector").isVisible()) await page.locator("#toggle-context").click(); + assert.equal(await page.locator("#function-pane").isVisible(), false); + await page.locator("#toggle-context").click(); + assert.equal(await page.locator("#inspector").isVisible(), true, "evidence stays reachable on mobile"); + await page.locator("#toggle-context").click(); + await page.keyboard.press("/"); + assert.equal(await page.locator("#function-pane").isVisible(), true); + await page.locator("#function-search").fill("main"); + await page.locator('.function-row[title="main"]').click(); + assert.equal(await page.locator("#function-pane").isVisible(), false); + await page.getByRole("tab", { name: "Pseudocode" }).click(); + await page.locator(".line-source").first().waitFor(); + await page.screenshot({ + path: "/tmp/e5r-dashboard-mobile.png", + fullPage: true, + }); + assert.equal( + await page.evaluate( + () => document.documentElement.scrollWidth <= innerWidth, + ), + true, + "mobile layout must fit", + ); + assert.deepEqual(errors, [], "no browser runtime errors"); + // Store writes should retain authors and history across a server restart. + const saved = JSON.parse( + readFileSync(join(project + ".work", task.id + ".json"), "utf8"), + ); + assert.equal(saved.revision, 2); + assert.equal(saved.history.length, 1); + console.log( + "Dashboard integration passed: CLI parity, read-only UI, live agent updates, full task view, records, call graph, synchronized function navigation, back/forward history, reading controls, keyboard tabs, mobile evidence, function records and live updates, saved annotations, instruction encodings, outgoing/data references, CFG navigation, XSS, mobile layout, HTTP boundaries.", + ); +} finally { + if (browser) await browser.close(); + if (server) { + server.kill("SIGTERM"); + await new Promise((ok) => + server.exitCode !== null ? ok() : server.once("exit", ok), + ); + } + rmSync(root, { recursive: true, force: true }); +} diff --git a/scripts/test-projects.mjs b/scripts/test-projects.mjs new file mode 100644 index 0000000..4438ee3 --- /dev/null +++ b/scripts/test-projects.mjs @@ -0,0 +1,169 @@ +// Optional global-project integration check; same Playwright setup as test-dashboard.mjs. +import assert from "node:assert/strict"; +import { execFileSync, spawn } from "node:child_process"; +import { + mkdtempSync, + writeFileSync, + readFileSync, + renameSync, + rmSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +const { chromium } = await import( + process.env.E5R_PLAYWRIGHT_MODULE || "playwright" +); +const root = mkdtempSync(join(tmpdir(), "e5r-projects-")); +const executable = resolve(process.env.E5R_BINARY || "target/release/e5r"); +const env = { ...process.env, E5R_PROJECT_HOME: join(root, "registry") }; +const binary = join(root, "sample"), + local = join(root, "local.e5rproj"); +const cli = (...args) => + execFileSync(executable, args, { env, cwd: tmpdir(), encoding: "utf8" }); +let server, browser; +try { + writeFileSync(join(root, "sample.c"), "int main(void) { return 3; }\n"); + execFileSync("cc", ["-g", "-O0", join(root, "sample.c"), "-o", binary]); + cli("project", "new", binary, "--name", "alpha"); + cli("project", "new", binary, "--out", local); + const task = JSON.parse( + cli( + "project", + "task", + local, + "add", + "Inspect entry point", + "--owner", + "agent-a", + "--next", + "Read main", + ), + ); + for (const kind of ["finding", "note", "report"]) { + cli("project", "record", local, kind, "add", `${kind} title`, "--description", "Body retained on import"); + } + cli("project", "import", local, "--name", "beta"); + for (const kind of ["finding", "note", "report"]) { + assert.deepEqual(JSON.parse(cli("project", "record", "beta", kind, "list", "--json")), JSON.parse(cli("project", "record", local, kind, "list", "--json"))); + } + assert.deepEqual( + JSON.parse(cli("project", "task", "beta", "list", "--json")), + JSON.parse(cli("project", "task", local, "list", "--json")), + ); + assert.equal(JSON.parse(cli("project", "list", "--json")).projects.length, 2); + assert.match( + readFileSync( + join(env.E5R_PROJECT_HOME, "alpha", "project.e5rproj"), + "utf8", + ), + new RegExp(binary), + ); + const url = await new Promise((ok, fail) => { + server = spawn(executable, ["ui", "--port", "0"], { + env, + cwd: tmpdir(), + stdio: ["ignore", "pipe", "pipe"], + }); + let log = ""; + const timer = setTimeout(() => fail(new Error(log)), 30000); + server.stderr.on("data", (d) => { + log += d; + const m = log.match(/Project collection: (http:\/\/127\.0\.0\.1:\d+)/); + if (m) { + clearTimeout(timer); + ok(m[1]); + } + }); + server.on("exit", (c) => { + clearTimeout(timer); + fail(new Error(`server exited ${c}: ${log}`)); + }); + }); + assert.equal((await fetch(url + "/p/")).status, 404); + assert.equal((await fetch(url + "/api/projects")).status, 200); + assert.equal( + (await (await fetch(url + "/p/alpha/api/tasks")).json()).tasks.length, + 0, + ); + assert.equal( + (await (await fetch(url + "/p/beta/api/tasks")).json()).tasks[0].task.id, + task.id, + ); + browser = await chromium.launch({ headless: true }); + const page = await browser.newPage({ + viewport: { width: 1440, height: 960 }, + }); + const errors = []; + page.on("pageerror", (e) => errors.push(e.message)); + await page.goto(url); + await page.locator("#project-rows .project-link").first().waitFor(); + assert.equal(await page.locator("#project-rows .project-link").count(), 2); + await page.screenshot({ path: "/tmp/e5r-projects.png", fullPage: true }); + assert.equal(await page.locator("#new-project").count(), 0); + assert.equal((await fetch(url + "/api/projects", {method:"POST", headers:{"X-E5R-Client":"dashboard","Content-Type":"application/json"}, body:JSON.stringify({name:"gamma", binary})})).status, 405); + cli("project", "new", binary, "--name", "gamma"); + await page.locator("#refresh").click(); + await page.waitForFunction(() => document.querySelectorAll("#project-rows .project-link").length === 3); + await page.goto(url + `/p/beta/#task/${task.id}`); + await page.locator("dialog[open]").waitFor(); + assert.equal( + await page.locator("#edit-heading").textContent(), + "Inspect entry point", + ); + const functions = await (await fetch(url + "/p/beta/api/functions")).json(); + const main = functions.items.find((f) => f.name === "main"); + assert(main); + const context = await (await fetch(url + `/p/beta/api/context/${main.addr}`)).json(); + assert.equal(context.function, main.addr); + assert(context.blocks.length > 0); + const pane = await ( + await fetch(url + `/p/beta/api/decompile/${main.addr}`) + ).json(); + assert.deepEqual( + pane, + JSON.parse(cli("decompile", binary, main.addr, "--json")), + ); + cli("project", "new", binary); + const collision = JSON.parse( + execFileSync(executable, ["project", "task", "sample", "list", "--json"], { + env, + cwd: root, + encoding: "utf8", + }), + ); + assert.equal( + collision.tasks.length, + 0, + "registered name must beat a same-named binary", + ); + const moved = join(root, "moved"); + renameSync(binary, moved); + assert.equal( + (await (await fetch(url + "/api/projects")).json()).projects.find( + (p) => p.name === "alpha", + ).availability, + "missing", + ); + assert( + (await (await fetch(url + "/p/alpha/api/project")).json()).binary_error, + ); + assert.equal((await fetch(url + "/p/alpha/api/tasks")).status, 200); + cli("project", "relocate", "alpha", moved); + assert.equal( + (await (await fetch(url + "/api/projects")).json()).projects.find( + (p) => p.name === "alpha", + ).availability, + "available", + ); + assert.equal(errors.length, 0, errors.join("\n")); + console.log( + "Global projects: registration, import, cwd independence, isolated tasks, read-only collection and CLI creation, decompiler and relocation passed.", + ); +} finally { + if (browser) await browser.close(); + if (server && server.exitCode === null) { + server.kill(); + await new Promise((ok) => server.once("exit", ok)); + } + rmSync(root, { recursive: true, force: true }); +} diff --git a/skills/e5r/SKILL.md b/skills/e5r/SKILL.md new file mode 100644 index 0000000..1d5f6a3 --- /dev/null +++ b/skills/e5r/SKILL.md @@ -0,0 +1,60 @@ +--- +name: e5r +description: Analyze binaries with e5r's JSON CLI and coordinate reverse engineering projects, evidence, and human/agent handoffs in its shared project workspace. +--- + +# Driving e5r + +Use `e5r COMMAND --help` before guessing flags. In this checkout the executable +is `./target/release/e5r`; build with `cargo build --release`. + +```sh +e5r project new ./binary --name investigation +e5r project list --json +e5r project task investigation list --json +e5r funcs ./binary --json +e5r decompile ./binary main --json +e5r xrefs ./binary main --json +e5r ui # human view of all global projects +``` + +Projects default to `~/.local/share/e5r/projects`; `E5R_PROJECT_HOME` overrides +that root. `project import FILE --name NAME` brings in an existing manifest and +task history. `--out FILE` retains local projects. + +Claim tasks through `project task NAME update ID --revision N --input task.json +--author AGENT`. The input is the complete previous `content` object with your +changes. On a conflict, read the current revision and reconcile; never overwrite +another writer blindly. Active work requires an owner; blocked work requires a +reason. Record a concrete `next` action and supporting `evidence` before handing +off. `function:0xADDRESS` links a task to the decompiler pane. + +Keep proven, inferred and asserted facts distinct. Incomplete control flow and +unmodelled operations limit what pseudocode establishes. Prefer a single +function and bounded `--limit` / `--budget` requests for large binaries. Exit +codes: 0 success, 1 no match, 2 usage, 3 bad input. + +For details, read [the manual](../../docs/MANUAL.md) and +[project/task formats](../../docs/dashboard.md). For repository development, +read [the roadmap](../../docs/ROADMAP.md) first. + +## Names and investigation records + +The UI is read-only. Make requested edits through the CLI, including tasks and +project registration. When a function's purpose is supported by evidence, give +it a descriptive name with `e5r annotate BINARY name ADDRESS NAME --db LOG`. +Use the project's recorded annotation log, then check `funcs` and `decompile` +with the same `--db LOG`. Record the rationale and uncertainty in a comment. +Restart the UI server to reload annotations. Do not invent meanings from a +single call site. Per-variable name annotations are not currently supported; +record proposed variable names and their storage in a note instead. + +Save investigation output with `e5r project record PROJECT KIND add TITLE +--description BODY --evidence function:0xADDRESS --author AGENT`, where KIND is +`finding`, `note` or `report`. Findings describe a claim and its supporting +evidence; notes retain observations and open questions; reports explain the +investigation and conclusions. These records reuse task content, revision, +history and update semantics in separate stores per kind; record IDs are local +to each kind. Use `record PROJECT KIND list --json` and `update ID --revision N +--input FILE` to read and revise them. Put the full text in `description` (or a +complete JSON input document); evidence is one reference per array entry.