From e382ea8693313e421bd3716f290b73e310402c30 Mon Sep 17 00:00:00 2001 From: Koukyosyumei Date: Thu, 8 Oct 2026 14:10:45 -0400 Subject: [PATCH 01/12] update .gitignore --- .gitignore | 224 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 224 insertions(+) diff --git a/.gitignore b/.gitignore index 1199877..2e24da3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Custom +workspace/ + # Rust /target/ **/*.rs.bk @@ -29,3 +32,224 @@ # 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 From 9b3e52289fcff4de244ad0a684e6ee9f82cee15e Mon Sep 17 00:00:00 2001 From: Koukyosyumei Date: Thu, 8 Oct 2026 14:16:26 -0400 Subject: [PATCH 02/12] A Bun standalone graph is listed rather than loaded. e5r bun reads the trailer Bun writes and both record sizes that layout has shipped. --extract writes one module. Those bytes are source and bytecode, so they are not loaded as code. --- MANUAL.md | 15 + ROADMAP.md | 5 + crates/e5r-cli/src/json.rs | 64 +++ crates/e5r-cli/src/main.rs | 36 ++ crates/e5r-cli/src/print.rs | 111 ++++ crates/e5r-cli/src/repl.rs | 5 +- crates/e5r-format/src/bun.rs | 1016 ++++++++++++++++++++++++++++++++++ crates/e5r-format/src/lib.rs | 1 + docs/design/loading.md | 6 + 9 files changed, 1258 insertions(+), 1 deletion(-) create mode 100644 crates/e5r-format/src/bun.rs diff --git a/MANUAL.md b/MANUAL.md index aabf042..cd304c4 100644 --- a/MANUAL.md +++ b/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 ``` diff --git a/ROADMAP.md b/ROADMAP.md index 380abf4..ae72bd8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -179,6 +179,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 diff --git a/crates/e5r-cli/src/json.rs b/crates/e5r-cli/src/json.rs index 2276ceb..3dc66b4 100644 --- a/crates/e5r-cli/src/json.rs +++ b/crates/e5r-cli/src/json.rs @@ -768,6 +768,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..c36dd86 100644 --- a/crates/e5r-cli/src/main.rs +++ b/crates/e5r-cli/src/main.rs @@ -306,6 +306,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 @@ -563,6 +582,7 @@ impl Command { | Command::Classes { common, .. } | Command::Overlay { common, .. } | Command::Archive { common, .. } + | Command::Bun { common, .. } | Command::Repl { common } | Command::Patch { common, .. } | Command::Emulate { common, .. } @@ -633,6 +653,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 +780,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), _ => {} } 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/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-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/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 From 0d945a84eec4c24c50ee4de058283c0e1bb56866 Mon Sep 17 00:00:00 2001 From: Koukyosyumei Date: Thu, 8 Oct 2026 14:41:09 -0400 Subject: [PATCH 03/12] Add a shared project dashboard and decompiler workspace Put blockers and review requests first in a local, embedded dashboard. Add durable tasks with owners, prerequisites, evidence, handoffs, history, and revision checks shared by browser and JSON CLI clients. Provide searchable functions, highlighted and wrapping pseudocode, disassembly, incoming references, function navigation, variables, and function-linked tasks using existing analysis APIs. Bind only to loopback and reject untrusted hosts, origins, and oversized mutation requests. Document the requested UI scope change, startup analysis snapshot, task store durability, API surface, and optional browser integration check. Validation: release build, full release workspace tests, focused task and CLI tests, formatting, and Playwright integration passed. Changed-crate Clippy passed with the existing needless_borrows_for_generic_args lint excluded. Strict workspace Clippy encounters two existing warnings in crates/e5r-ir/src/opt.rs; cargo-deny has no repository license allow policy. --- .gitignore | 4 + Cargo.lock | 38 + MANUAL.md | 15 + README.md | 23 +- ROADMAP.md | 14 +- crates/e5r-cli/Cargo.toml | 2 + crates/e5r-cli/src/dashboard.rs | 280 ++++++ crates/e5r-cli/src/dashboard/app.js | 722 ++++++++++++++++ crates/e5r-cli/src/dashboard/index.html | 332 ++++++++ crates/e5r-cli/src/dashboard/style.css | 1031 +++++++++++++++++++++++ crates/e5r-cli/src/main.rs | 29 + crates/e5r-cli/src/work.rs | 146 ++++ crates/e5r-db/Cargo.toml | 1 + crates/e5r-db/src/lib.rs | 1 + crates/e5r-db/src/work.rs | 538 ++++++++++++ docs/dashboard.md | 147 ++++ scripts/test-dashboard.mjs | 259 ++++++ 17 files changed, 3574 insertions(+), 8 deletions(-) create mode 100644 crates/e5r-cli/src/dashboard.rs create mode 100644 crates/e5r-cli/src/dashboard/app.js create mode 100644 crates/e5r-cli/src/dashboard/index.html create mode 100644 crates/e5r-cli/src/dashboard/style.css create mode 100644 crates/e5r-cli/src/work.rs create mode 100644 crates/e5r-db/src/work.rs create mode 100644 docs/dashboard.md create mode 100644 scripts/test-dashboard.mjs diff --git a/.gitignore b/.gitignore index 2e24da3..acee0dd 100644 --- a/.gitignore +++ b/.gitignore @@ -253,3 +253,7 @@ __marimo__/ # Streamlit .streamlit/secrets.toml + +# Local task-store write locks and atomic-write scratch files. Task JSON is durable. +*.work/.lock +*.work/*.tmp 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/MANUAL.md b/MANUAL.md index cd304c4..ff8d890 100644 --- a/MANUAL.md +++ b/MANUAL.md @@ -448,6 +448,21 @@ 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 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. Binary analysis and annotations are +a startup snapshot; tasks refresh live. See [docs/dashboard.md](docs/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/README.md b/README.md index 2210283..a4be743 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 @@ -110,6 +110,23 @@ 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, with tasks and evidence shared by humans and agents. + +```bash +e5r project new ./a.out --out investigation.e5rproj +e5r project dashboard investigation.e5rproj +e5r project task investigation.e5rproj list --json +``` + +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). diff --git a/ROADMAP.md b/ROADMAP.md index ae72bd8..3fc4aeb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -34,11 +34,12 @@ and 58 MB, finding 3,517 functions with 95.3% analyzed completely, where 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 @@ -964,8 +965,11 @@ Tasks: 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`](docs/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/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..b945505 --- /dev/null +++ b/crates/e5r-cli/src/dashboard.rs @@ -0,0 +1,280 @@ +//! Loopback dashboard. All analysis and coordination comes from library APIs. +use crate::{annotate, out::Out, patch}; +use e5r_db::work::{Draft, Store}; +use serde::Deserialize; +use serde_json::{Value, json}; +use std::io::Read; +use std::path::Path; +use tiny_http::{Header, Method, Request, Response, Server, StatusCode}; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Edit { + content: Draft, + author: String, + revision: Option, +} + +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}")) +} + +fn edit(request: &mut Request) -> Result { + if field(request, "X-E5R-Client") != Some("dashboard") { + return Err("missing dashboard client header".into()); + } + if field(request, "Content-Type") != Some("application/json") { + return Err("expected application/json".into()); + } + const CAP: usize = 128 * 1024; + if request.body_length().is_some_and(|n| n > CAP) { + return Err("request exceeds 128 KiB".into()); + } + let mut data = Vec::new(); + request + .as_reader() + .take((CAP + 1) as u64) + .read_to_end(&mut data) + .map_err(|e| e.to_string())?; + if data.len() > CAP { + return Err("request exceeds 128 KiB".into()); + } + let change: Edit = serde_json::from_slice(&data).map_err(|e| e.to_string())?; + if change.author.trim().is_empty() { + return Err("a writer name is required".into()); + } + Ok(change) +} + +pub fn serve(project_path: &Path, port: u16, binary: Option<&Path>) -> Result { + let project = patch::project_read(project_path)?; + let binary = binary.unwrap_or_else(|| Path::new(&project.binary.path)); + let file = std::fs::File::open(binary).map_err(|e| format!("{}: {e}", binary.display()))?; + 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()); + } + let mut options = e5r_analysis::Options::default(); + for (key, value) in &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:?}" + )); + } + } + } + let load = e5r_format::LoadOptions { + base: project.load.base, + arch: project.load.arch.clone(), + ..Default::default() + }; + let mut object = e5r_format::load(&data, &load).map_err(|e| e.to_string())?; + if project + .load + .readers + .iter() + .any(|reader| reader != object.format.as_str()) + { + return Err("dashboard does not yet support custom project readers".into()); + } + e5r_api::apply_relative_relocations(&mut object); + annotate::load_pdb(&mut object, binary); + eprintln!("Analyzing {} for the workspace…", binary.display()); + let mut program = e5r_analysis::analyze(object, &options); + let db = project + .log + .as_ref() + .map(std::path::PathBuf::from) + .unwrap_or_else(|| annotate::default_path(binary)); + annotate::load(&db)?; // Refuse a damaged annotation log before opening. + 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 store = Store::new(e5r_db::work::beside(project_path)); + let server = Server::http((std::net::Ipv4Addr::LOCALHOST, port)).map_err(|e| e.to_string())?; + let host = server.server_addr().to_string(); + eprintln!( + "Project workspace: http://{host}\nCtrl-C stops it. Binary and annotations are a startup snapshot." + ); + for mut request in server.incoming_requests() { + if !trusted(&request, &host) { + respond( + request, + 403, + "application/json", + json!({"error":"untrusted host or origin"}).to_string(), + ); + continue; + } + let path = request.url().to_string(); + if request.method() == &Method::Get { + let asset = match path.as_str() { + "/" => Some(( + "text/html; charset=utf-8", + include_str!("dashboard/index.html"), + )), + "/app.js" => Some(( + "text/javascript; charset=utf-8", + include_str!("dashboard/app.js"), + )), + "/style.css" => Some(( + "text/css; charset=utf-8", + include_str!("dashboard/style.css"), + )), + _ => None, + }; + if let Some((kind, body)) = asset { + respond(request, 200, kind, body.into()); + continue; + } + } + let result: Result = (|| { + match (request.method(), path.as_str()) { + (&Method::Get, "/api/project") => Ok( + json!({ "schema":"e5r.workspace.v1", "project":project_path.display().to_string(), "binary":binary.display().to_string(), "hash":format!("{:016x}", project.binary.hash), "info":crate::json::info(&program.object), "author":crate::whoami(), "snapshot":true, "log":db.display().to_string(), "base":project.load.base.map(|a| a.to_string()), "arch":project.load.arch.as_ref().map(|a| a.name()), "no_scan":!options.scan_gaps, "threads":options.threads }), + ), + (&Method::Get, "/api/tasks") => { + serde_json::to_value(store.board()?).map_err(|e| e.to_string()) + } + (&Method::Get, "/api/functions") => { + serde_json::to_value(crate::json::funcs(&program)).map_err(|e| e.to_string()) + } + (&Method::Post, "/api/tasks") => { + let change = edit(&mut request)?; + if change.revision.is_some() { + return Err("new tasks do not take a revision".into()); + } + serde_json::to_value(store.create(change.content, &change.author)?) + .map_err(|e| e.to_string()) + } + _ if request.method() == &Method::Post && path.starts_with("/api/tasks/") => { + let change = edit(&mut request)?; + serde_json::to_value(store.update( + &path[11..], + change.revision.ok_or("revision is required")?, + change.content, + &change.author, + )?) + .map_err(|e| e.to_string()) + } + _ if request.method() == &Method::Get => { + let (kind, target) = path + .strip_prefix("/api/") + .and_then(|s| s.split_once('/')) + .ok_or("route not found")?; + // A pane asks about one recovered function, never an unbounded 'all'. + let at = crate::addr::parse_number(target) + .map(e5r_core::Addr) + .ok_or("expected a function address")?; + if program.function(at).is_none() { + return Err("function not found".into()); + } + let mut output = Out::buffer(); + let limits = crate::print::Limits { + budget: crate::budget::Budget::new(Some(10.0), Some(1)), + progress: false, + declared: &declared, + comments: &comments, + }; + match kind { + "decompile" => { + crate::print::decompile(&mut output, &program, target, true, limits)?; + } + "disas" => { + crate::print::disas(&mut output, &program, target, true, true, limits)?; + } + "xrefs" => { + crate::print::xrefs(&mut output, &program, target, false, true)?; + } + _ => return Err("route not found".into()), + } + serde_json::from_str(&output.take()).map_err(|e| e.to_string()) + } + _ => Err("route not found".into()), + } + })(); + 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(), + ); + } + } + } + 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..59f6f89 --- /dev/null +++ b/crates/e5r-cli/src/dashboard/app.js @@ -0,0 +1,722 @@ +"use strict"; +const $ = (id) => document.getElementById(id); +const state = { + project: null, + board: { tasks: [] }, + functions: [], + selected: null, + pane: "decompile", + editing: null, + request: 0, + history: [], + code: "", + boardStamp: "", + functionLimit: 400, +}; +const labels = { + planned: "Planned", + active: "Active", + blocked: "Blocked", + review: "Review", + done: "Done", +}; +const blank = () => ({ + title: "", + description: "", + state: "planned", + priority: "normal", + owner: "", + next: "", + blocker: "", + evidence: [], + depends_on: [], +}); +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, body) { + const response = await fetch( + path, + body === undefined + ? {} + : { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-E5R-Client": "dashboard", + }, + body: JSON.stringify(body), + }, + ); + 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] === "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 ${tone}`); + e.append(el("b", String(n)), el("span", label)); + $("counts").append(e); + } + $("attention-count").textContent = String(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." + : "Use “New task” 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 (stamp !== state.boardStamp) { + state.boardStamp = stamp; + renderBoard(); + } + $("sync").textContent = "Tasks up to date"; + error(""); + } catch (e) { + $("sync").textContent = "Tasks unavailable"; + error(`Could not refresh tasks: ${e.message}`); + } +} +function openTask(task = null, content = null) { + state.editing = task; + const d = content || task?.content || blank(), + form = $("task-form"); + for (const key of [ + "title", + "description", + "state", + "priority", + "owner", + "next", + "blocker", + ]) + form.elements[key].value = d[key]; + form.elements.evidence.value = d.evidence.join("\n"); + form.elements.depends_on.value = d.depends_on.join(", "); + form.elements.author.value = + localStorage.getItem("e5r.writer") || state.project?.author || "human"; + $("edit-id").textContent = task + ? `${task.id} · REVISION ${task.revision}` + : "NEW TASK"; + $("edit-heading").textContent = task + ? "Keep the next step clear" + : "Define the next outcome"; + $("revision-note").textContent = task + ? "Stale edits are refused. Task history is retained." + : "Saved beside the project; shared with agents."; + $("form-error").hidden = true; + $("reload-task").hidden = true; + $("history").hidden = !task?.history.length; + clear($("history-items")); + for (const h of [...(task?.history || [])].reverse()) { + const div = el("div", undefined, "history-row"); + div.append( + el("strong", `${h.author} · ${new Date(h.at * 1000).toLocaleString()}`), + el("div", `${labels[h.previous.state]} · ${h.previous.title}`), + el("div", h.previous.next || "No next action"), + ); + $("history-items").append(div); + } + blockerToggle(); + if (!$("task-dialog").open) $("task-dialog").showModal(); + form.elements.title.focus(); +} +function blockerToggle() { + const blocked = $("task-form").elements.state.value === "blocked"; + $("blocker-label").hidden = !blocked; + $("task-form").elements.blocker.required = blocked; +} +async function saveTask(event) { + event.preventDefault(); + const form = $("task-form"), + content = blank(); + for (const key of [ + "title", + "description", + "state", + "priority", + "owner", + "next", + "blocker", + ]) + content[key] = form.elements[key].value.trim(); + if (content.state !== "blocked") content.blocker = ""; + content.evidence = form.elements.evidence.value + .split("\n") + .map((s) => s.trim()) + .filter(Boolean); + content.depends_on = form.elements.depends_on.value + .split(/[\s,]+/) + .filter(Boolean); + const author = form.elements.author.value.trim(); + $("save-task").disabled = true; + $("form-error").hidden = true; + try { + await api(state.editing ? `/api/tasks/${state.editing.id}` : "/api/tasks", { + content, + author, + revision: state.editing?.revision ?? null, + }); + localStorage.setItem("e5r.writer", author); + $("task-dialog").close(); + await refreshTasks(); + } catch (e) { + $("form-error").textContent = e.message; + $("form-error").hidden = false; + $("reload-task").hidden = e.status !== 409; + } finally { + $("save-task").disabled = false; + } +} +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.selected = f; + state.code = ""; + $("back").disabled = !state.history.length; + $("function-name").textContent = f.name; + $("function-address").textContent = f.addr; + $("task-function").disabled = false; + $("copy-code").disabled = true; + history.replaceState(null, "", `#workspace/${f.addr}`); + view("workspace"); + renderFunctions(); + renderContext(); + $("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`], + ["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. “Task here” preserves this function as evidence."; +} +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) { + 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")); + 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")); + const rows = data.items || []; + if (!rows.length) { + message( + $("code"), + "No incoming references reported.", + "This is the analysis result for this startup 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; +} +async function loadPane() { + if (!state.selected) return; + 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)), + ); + $("quality").className = "quality"; + $("quality").textContent = + `Loading ${pane === "decompile" ? "pseudocode" : pane === "disas" ? "disassembly" : "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 = false; + 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") { + // CLI disassembly JSON groups instructions under functions. + const groups = data.items || []; + const lines = []; + for (const group of groups) { + for (const insn of group.insns || []) { + lines.push(`${insn.addr} ${insn.text}`); + } + } + if (!lines.length) renderCode(JSON.stringify(data, null, 2)); + else renderCode(lines.join("\n")); + $("quality").textContent = + `${f.insns} instructions · ${f.strength} boundary · ${f.complete ? "Complete" : "Incomplete: " + f.halt}`; + } 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(); + }; +$("new-task").onclick = () => openTask(); +$("refresh").onclick = refreshTasks; +$("close-dialog").onclick = () => $("task-dialog").close(); +$("task-form").onsubmit = saveTask; +$("task-form").elements.state.onchange = blockerToggle; +$("reload-task").onclick = async () => { + const id = state.editing?.id; + await refreshTasks(); + const task = state.board.tasks.find((r) => r.task.id === id)?.task; + if (task) openTask(task); +}; +$("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")); +$("task-function").onclick = () => { + const d = blank(); + d.title = `Investigate ${state.selected.name}`; + d.evidence = [ + `function:${state.selected.addr}`, + $("analysis-command").textContent, + ]; + openTask(null, d); +}; +$("back").onclick = () => { + const addr = state.history.pop(), + f = state.functions.find((f) => f.addr === addr); + if (f) selectFunction(f, false); +}; +window.addEventListener("hashchange", route); +window.addEventListener("keydown", (e) => { + if ( + e.key === "/" && + !$("workspace").hidden && + !["INPUT", "TEXTAREA", "SELECT"].includes(document.activeElement.tagName) && + !$("task-dialog").open + ) { + e.preventDefault(); + $("function-search").focus(); + } +}); +async function boot() { + try { + state.project = await api("/api/project"); + const name = state.project.project.split("/").pop(); + $("project-name").textContent = name; + document.title = `${name} · e5r`; + $("binary-name").textContent = state.project.binary; + $("agent-command").textContent = + `e5r project task ${shellQuote(state.project.project)} list --json`; + renderBoard(); + await refreshTasks(); + route(); + setInterval(refreshTasks, 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..faeafbd --- /dev/null +++ b/crates/e5r-cli/src/dashboard/index.html @@ -0,0 +1,332 @@ + + + + + + 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
+
+ +
+
+
+
AGENT HANDOFF
+

Read the same tasks, claim work, leave evidence.

+ +
+ +
+
+
+
+
+

Functions

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

Explore the binary

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

Follow the code. Keep the context.

+

+ Select a function to see its pseudocode and evidence.
Create + a task here to capture the next investigation. +

+
+
+
+ +
+
+
+ +
+
+
+
NEW TASK
+

Define the next outcome

+
+ +
+ +
+ +
+ +
+ +
+
+ Revision history +
+
+ +
+ Saved beside the project; shared with agents. +
+
+
+ + diff --git a/crates/e5r-cli/src/dashboard/style.css b/crates/e5r-cli/src/dashboard/style.css new file mode 100644 index 0000000..24e8788 --- /dev/null +++ b/crates/e5r-cli/src/dashboard/style.css @@ -0,0 +1,1031 @@ +:root { + color-scheme: dark; + --ground: #0c0e11; + --surface: #12151a; + --raised: #191e25; + --line: #28303a; + --ink: #eef0f4; + --muted: #9ca7b5; + --accent: #d6b478; + --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(--accent); +} +button:disabled { + opacity: 0.45; + cursor: default; +} +button { + color: inherit; +} +a { + color: var(--blue); +} +[hidden] { + display: none !important; +} +.shell { + display: grid; + grid-template-columns: 210px minmax(0, 1fr); + min-height: 100vh; +} +.rail { + border-right: 1px solid var(--line); + padding: 32px 20px; + display: flex; + flex-direction: column; + position: sticky; + top: 0; + height: 100vh; + background: #101318; +} +.brand { + font-size: 32px; + font-weight: 750; + letter-spacing: -2px; + text-decoration: none; + color: var(--ink); +} +.brand span { + display: block; + font-size: 9px; + letter-spacing: 1.7px; + font-weight: 500; + color: var(--muted); + margin-top: 6px; +} +.rail-label, +.eyebrow { + font-size: 10px; + letter-spacing: 1.7px; + color: var(--accent); + font-weight: 650; +} +.rail-label { + margin-top: 44px; + color: var(--muted); +} +.project-name { + margin: 12px 0 28px; + font-weight: 600; + overflow-wrap: anywhere; +} +.nav { + width: 100%; + background: transparent; + border: 0; + display: flex; + gap: 12px; + text-align: left; + padding: 13px 10px; + border-radius: 5px; + margin-bottom: 5px; + color: var(--muted); +} +.nav span { + font-size: 11px; + opacity: 0.6; + align-self: center; +} +.nav.selected { + background: var(--raised); + color: var(--ink); + box-shadow: inset 2px 0 var(--accent); +} +.nav:hover { + background: var(--raised); +} +.rail-foot { + margin-top: auto; + color: var(--muted); + font-size: 10px; + letter-spacing: 1px; + line-height: 1.7; +} +.rail-foot p, +.rail-foot small { + letter-spacing: 0; + font-size: 11px; +} +.local-dot { + display: inline-block; + width: 6px; + height: 6px; + background: var(--green); + border-radius: 50%; + margin-right: 6px; +} +.body { + min-width: 0; +} +.topbar { + height: 76px; + border-bottom: 1px solid var(--line); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 32px; + gap: 20px; +} +.crumb { + display: flex; + flex-direction: column; + gap: 4px; + font-weight: 600; +} +.crumb .muted { + font-size: 11px; + font-weight: 400; + overflow-wrap: anywhere; +} +.top-actions { + display: flex; + align-items: center; + gap: 16px; + flex-shrink: 0; +} +.top-actions .muted { + font-size: 11px; +} +.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: 36px 32px 40px; +} +.hero { + display: flex; + justify-content: space-between; + align-items: center; + gap: 36px; + padding: 10px 0 35px; +} +.hero h1 { + font-size: clamp(24px, 2.5vw, 36px); + 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: 11px; + 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); +} +.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: 11px; + display: block; + margin-top: 5px; +} +.badge { + font-size: 11px; + 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: 10px; + color: var(--muted); + margin-right: 8px; +} +.state { + font-size: 10px; + 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: 12px; + text-align: left; +} +th { + padding: 12px 23px; + color: var(--muted); + font-size: 10px; + 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: 10px; + 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(100vh - 76px); + display: grid; + grid-template-columns: 240px minmax(350px, 1fr) 250px; + 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: 11px; +} +.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: #222730; +} +.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: 10px; + 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: 24px; + 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: 11px; +} +.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: 11px; + 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: + 12px/1.9 ui-monospace, + "SFMono-Regular", + Consolas, + monospace; + min-height: 23px; +} +.line-number { + flex: 0 0 58px; + text-align: right; + padding-right: 20px; + color: #697887; + 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: #171d25; +} +.keyword { + color: #c3a6dd; +} +.number { + color: #dfbc83; +} +.comment { + color: #8da391; +} +.string { + color: #a7c7a1; +} +.call { + color: #9abdec; + text-decoration: none; +} +.call:hover { + text-decoration: underline; +} +.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: 10px; + 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: 11px; + line-height: 1.7; + margin: 15px 0; +} +.inspector .muted { + font-size: 12px; + line-height: 1.7; +} +.references { + width: 100%; + font-size: 11px; +} +#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: 11px; +} +.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); +} +@media (min-width: 1700px) { + .workspace { + grid-template-columns: 280px minmax(350px, 1fr) 290px; + } +} +@media (max-width: 1250px) { + .shell { + grid-template-columns: 170px minmax(0, 1fr); + } + .rail { + padding: 25px 15px; + } + .workspace { + grid-template-columns: 210px minmax(0, 1fr); + } + .inspector { + display: none; + } + .counts { + gap: 18px; + } + .topbar { + padding: 0 22px; + } + .overview { + padding: 28px 22px; + } + .top-actions #sync { + display: none; + } + .filters input { + width: 170px; + } + .code-actions .button { + font-size: 10px; + } +} +@media (max-width: 900px) { + .shell { + grid-template-columns: 1fr; + } + .rail { + height: auto; + position: static; + flex-direction: row; + align-items: center; + justify-content: space-between; + padding: 14px 20px; + border-bottom: 1px solid var(--line); + border-right: 0; + } + .brand { + font-size: 24px; + } + .brand span, + .rail-label, + .rail-foot, + .project-name { + display: none; + } + .rail nav { + display: flex; + gap: 10px; + } + .nav { + margin: 0; + width: auto; + } + .topbar { + height: 70px; + } + .overview-grid { + grid-template-columns: 1fr; + } + .hero { + align-items: flex-start; + flex-direction: column; + gap: 24px; + } + .counts { + gap: 35px; + } + .workspace { + height: calc(100vh - 137px); + } + .tasks-panel .panel-head { + align-items: flex-start; + flex-direction: column; + } + .code-header { + padding: 18px 14px; + } + .code-tabs { + padding: 0 14px; + } + .quality { + padding: 12px 14px; + } + .agent-strip { + align-items: flex-start; + } + td, + th { + padding: 12px 16px; + } +} +@media (max-width: 600px) { + .workspace { + grid-template-columns: 130px minmax(0, 1fr); + } + .function-row { + padding: 10px 9px; + } + .function-row strong { + font-size: 10px; + } + .function-search { + padding: 0 7px 10px; + } + .function-search input { + font-size: 10px; + padding: 8px; + } + .pane-head { + padding: 15px 8px; + } + .pane-head h2 { + font-size: 12px; + } + .code-header { + align-items: flex-start; + flex-direction: column; + } + .code-header h2 { + font-size: 14px; + } + .code-tabs { + gap: 15px; + } + .code-tabs button { + font-size: 10px; + } + .code-actions { + flex-wrap: wrap; + } + .line-number { + flex-basis: 36px; + padding-right: 10px; + } + .topbar { + padding: 0 14px; + } + .top-actions { + gap: 8px; + } + .top-actions #refresh { + display: none; + } + .overview { + padding: 20px 14px; + } + .agent-strip { + flex-direction: column; + } + .form-grid { + grid-template-columns: 1fr; + } + dialog { + padding: 20px; + } + .dialog-foot { + flex-wrap: wrap; + } + .counts { + gap: 24px; + } + .filters { + width: 100%; + } + .filters input { + width: 100%; + } + td:first-child { + min-width: 160px; + } + td:last-child { + min-width: 180px; + } +} + +.code-wrap { + display: flex; + flex-direction: row; + align-items: center; + gap: 7px; + margin: 0 0 0 auto; + font-size: 11px; + 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; + } +} diff --git a/crates/e5r-cli/src/main.rs b/crates/e5r-cli/src/main.rs index c36dd86..7f303dd 100644 --- a/crates/e5r-cli/src/main.rs +++ b/crates/e5r-cli/src/main.rs @@ -12,6 +12,7 @@ mod addr; mod annotate; mod batch; mod budget; +mod dashboard; mod json; mod out; mod patch; @@ -19,6 +20,7 @@ mod print; mod progress; mod repl; mod shell; +mod work; use std::path::PathBuf; use std::process::ExitCode; @@ -453,6 +455,24 @@ pub enum PatchCommand { /// What to do with a project file. #[derive(Subcommand)] pub enum ProjectCommand { + /// Manage durable tasks shared by people and agents. + Task { + /// The project file. + project: PathBuf, + #[command(subcommand)] + what: work::Command, + }, + /// Open a local project dashboard and decompiler workspace. + Dashboard { + /// The 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. @@ -986,6 +1006,15 @@ 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::Task { project, what } => { + patch::project_read(project)?; + work::run(w, project, what) + } + ProjectCommand::Dashboard { + project, + port, + binary, + } => dashboard::serve(project, *port, binary.as_deref()), ProjectCommand::New { binary, out, diff --git a/crates/e5r-cli/src/work.rs b/crates/e5r-cli/src/work.rs new file mode 100644 index 0000000..f4c56cd --- /dev/null +++ b/crates/e5r-cli/src/work.rs @@ -0,0 +1,146 @@ +//! 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 { + let store = Store::new(e5r_db::work::beside(project)); + 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..a24da8e 100644 --- a/crates/e5r-db/src/lib.rs +++ b/crates/e5r-db/src/lib.rs @@ -13,6 +13,7 @@ pub mod log; pub mod patch; pub mod project; 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/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/docs/dashboard.md b/docs/dashboard.md new file mode 100644 index 0000000..f627213 --- /dev/null +++ b/docs/dashboard.md @@ -0,0 +1,147 @@ +# A workspace for people and agents + +Create a binary project, then serve its workspace locally: + +```sh +./target/release/e5r project new ./a.out --out investigation.e5rproj +./target/release/e5r project dashboard investigation.e5rproj +``` + +Open the printed `http://127.0.0.1:7879` address. `--port 0` selects a free +port; `--binary PATH` opens a moved binary only if its content still matches the +project. Ctrl-C stops the workspace. Assets ship inside the Rust executable; +there is no frontend build step, CDN, font download, or Node runtime requirement. + +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. +An open editor keeps its contents while the background refreshes. + +The decompiler uses a familiar three-column workspace: searchable functions on +the left, code in the center, and evidence, variables and related tasks on the +right. Use `/` to focus function search. Pseudocode, disassembly and incoming +references are tabs over the same recovered function. Click a known function +name or address to navigate; Back returns to the previous function. Toggle **Wrap** for long expressions. Copy code +or its CLI command, or use **Task here** to retain the function address as +evidence in a new task. Large function lists are loaded in display batches; +search always searches the complete list. Narrow screens collapse the context +column so code keeps space. + +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 **startup snapshot**; restart the server after changing +them. 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 reads and writes exactly the same task store as the browser: + +```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 by **Task here**. + +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. Browser writer names are +remembered locally and remain editable. + +## 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. The browser keeps +your rejected edit visible and offers **Reload latest task**. 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. Writes require JSON and the custom +`X-E5R-Client: dashboard` header; there is no CORS permission. Task request bodies +are capped at 128 KiB. Binary paths and arbitrary commands cannot be supplied by +HTTP. Analysis endpoints accept only a recovered function's address. + +| Route | Meaning | +| --- | --- | +| `GET /api/project` | project identity and container information | +| `GET /api/tasks` | shared task snapshot | +| `POST /api/tasks` | create `{content, author, revision: null}` | +| `POST /api/tasks/T-0001` | update `{content, author, revision}` | +| `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 | + +Responses are JSON; task conflicts return 409, missing routes/functions return +404, invalid content returns 400. The server handles requests serially, and +single-function decompilation can delay task requests. 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 +``` + +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 browser +editing and stale revisions, checks function navigation 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`. diff --git a/scripts/test-dashboard.mjs b/scripts/test-dashboard.mjs new file mode 100644 index 0000000..1381b85 --- /dev/null +++ b/scripts/test-dashboard.mjs @@ -0,0 +1,259 @@ +// 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"), + `__attribute__((noinline)) int parse_header(int length) { if(length < 0 || length > 32) return -1; return length + 1; }\nint main(void) { return parse_header(4) == 5 ? 0 : 1; }\n`, + ); + execFileSync("cc", ["-g", "-O0", join(root, "sample.c"), "-o", binary]); + 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 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, + 400, + ); + 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/tasks", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-E5R-Client": "dashboard", + }, + body: "x".repeat(128 * 1024 + 1), + }) + ).status, + 400, + ); + 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)); + await page.goto(url); + await page + .locator("#headline") + .filter({ hasText: "The next step is clear." }) + .waitFor(); + await page + .getByRole("button", { name: "Trace input validation", exact: true }) + .click(); + await page.locator("[name=next]").fill("Human draft that must survive"); + const draft = { ...task.content, next: "Agent update" }; + 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.getByRole("button", { name: "Save task", exact: true }).click(); + await page + .locator("#form-error") + .filter({ hasText: "revision conflict" }) + .waitFor(); + assert.equal( + await page.locator("[name=next]").inputValue(), + "Human draft that must survive", + ); + await page.getByRole("button", { name: "Reload latest task" }).click(); + await page.waitForFunction( + () => document.querySelector("[name=next]").value === "Agent update", + ); + assert.equal(await page.locator("[name=next]").inputValue(), "Agent update"); + await page.locator("[name=state]").selectOption("blocked"); + await page + .locator("[name=blocker]") + .fill("Need a sample input from the owner"); + await page.getByRole("button", { name: "Save task", exact: true }).click(); + await page.locator("#task-dialog").waitFor({ state: "hidden" }); + await page + .locator("#headline") + .filter({ hasText: "1 item needs attention." }) + .waitFor(); + const board = JSON.parse(cli("project", "task", project, "list", "--json")); + assert.equal(board.tasks[0].attention, "Need a sample input from the owner"); + 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()); + 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(); + await page.locator("#back").click(); + await page.locator("#function-name").filter({ hasText: "main" }).waitFor(); + await page.getByRole("tab", { name: "Disassembly" }).click(); + await page.locator("#quality").filter({ hasText: "instructions" }).waitFor(); + assert((await page.locator("#code").textContent()).includes(main.addr)); + await page.getByRole("tab", { name: "References" }).click(); + await page + .locator("#quality") + .filter({ hasText: "Incoming references" }) + .waitFor(); + await page.getByRole("button", { name: "+ Task here" }).click(); + assert( + (await page.locator("[name=evidence]").inputValue()).includes( + `function:${main.addr}`, + ), + ); + await page + .locator("[name=title]") + .fill(''); + await page.getByRole("button", { name: "Save task", exact: true }).click(); + await page.locator("#task-dialog").waitFor({ state: "hidden" }); + await page + .locator("#related-work") + .getByText('', { exact: false }) + .waitFor(); + assert.equal(await page.evaluate(() => window.taskXss), undefined); + assert.equal(await page.locator("#related-work img").count(), 0); + await page.setViewportSize({ width: 390, height: 844 }); + 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, 3); + assert.equal(saved.history.length, 2); + console.log( + "Dashboard integration passed: CLI parity, browser edits, stale conflicts, attention, function navigation, evidence, 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 }); +} From a03a6e134c480ae478fada2a5bbac29da7e9de7d Mon Sep 17 00:00:00 2001 From: Koukyosyumei Date: Thu, 8 Oct 2026 15:12:50 -0400 Subject: [PATCH 04/12] Add global project management with e5r ui and agent skill --- .github/workflows/release.yaml | 4 +- AGENTS.md | 2 +- CLAUDE.md | 2 +- CONTRIBUTING.md | 2 +- Cargo.toml | 2 +- README.md | 13 +- crates/e5r-cli/src/dashboard.rs | 515 +++++++++++++++------ crates/e5r-cli/src/dashboard/app.js | 22 +- crates/e5r-cli/src/dashboard/index.html | 5 +- crates/e5r-cli/src/dashboard/projects.html | 159 +++++++ crates/e5r-cli/src/dashboard/projects.js | 204 ++++++++ crates/e5r-cli/src/dashboard/style.css | 38 ++ crates/e5r-cli/src/main.rs | 94 +++- crates/e5r-cli/src/patch.rs | 26 +- crates/e5r-cli/src/projects.rs | 87 ++++ crates/e5r-db/src/lib.rs | 1 + crates/e5r-db/src/registry.rs | 428 +++++++++++++++++ MANUAL.md => docs/MANUAL.md | 10 +- ROADMAP.md => docs/ROADMAP.md | 16 +- docs/dashboard.md | 73 ++- scripts/build-release.sh | 4 +- scripts/test-projects.mjs | 149 ++++++ skills/e5r/SKILL.md | 39 ++ 23 files changed, 1697 insertions(+), 198 deletions(-) create mode 100644 crates/e5r-cli/src/dashboard/projects.html create mode 100644 crates/e5r-cli/src/dashboard/projects.js create mode 100644 crates/e5r-cli/src/projects.rs create mode 100644 crates/e5r-db/src/registry.rs rename MANUAL.md => docs/MANUAL.md (97%) rename ROADMAP.md => docs/ROADMAP.md (99%) create mode 100644 scripts/test-projects.mjs create mode 100644 skills/e5r/SKILL.md 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/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.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 a4be743..0ce28d5 100644 --- a/README.md +++ b/README.md @@ -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 @@ -117,11 +117,12 @@ decompiler pane provides searchable functions, pseudocode, disassembly and references, with tasks and evidence shared by humans and agents. ```bash -e5r project new ./a.out --out investigation.e5rproj -e5r project dashboard investigation.e5rproj -e5r project task investigation.e5rproj list --json +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. @@ -142,7 +143,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 @@ -212,7 +213,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-cli/src/dashboard.rs b/crates/e5r-cli/src/dashboard.rs index b945505..c34736d 100644 --- a/crates/e5r-cli/src/dashboard.rs +++ b/crates/e5r-cli/src/dashboard.rs @@ -1,10 +1,14 @@ -//! Loopback dashboard. All analysis and coordination comes from library APIs. +//! Loopback collection and project workspaces backed by the library APIs. use crate::{annotate, out::Out, patch}; -use e5r_db::work::{Draft, Store}; +use e5r_db::{ + project::Project, + registry::{self, Registry}, + work::{Draft, Store}, +}; use serde::Deserialize; use serde_json::{Value, json}; use std::io::Read; -use std::path::Path; +use std::path::{Path, PathBuf}; use tiny_http::{Header, Method, Request, Response, Server, StatusCode}; #[derive(Deserialize)] @@ -41,7 +45,7 @@ fn trusted(request: &Request, host: &str) -> bool { && field(request, "Origin").is_none_or(|origin| origin == format!("http://{host}")) } -fn edit(request: &mut Request) -> Result { +fn body(request: &mut Request) -> Result { if field(request, "X-E5R-Client") != Some("dashboard") { return Err("missing dashboard client header".into()); } @@ -61,82 +65,285 @@ fn edit(request: &mut Request) -> Result { if data.len() > CAP { return Err("request exceeds 128 KiB".into()); } - let change: Edit = serde_json::from_slice(&data).map_err(|e| e.to_string())?; + serde_json::from_slice(&data).map_err(|e| e.to_string()) +} + +fn edit(request: &mut Request) -> Result { + let change: Edit = body(request)?; if change.author.trim().is_empty() { return Err("a writer name is required".into()); } Ok(change) } -pub fn serve(project_path: &Path, port: u16, binary: Option<&Path>) -> Result { - let project = patch::project_read(project_path)?; - let binary = binary.unwrap_or_else(|| Path::new(&project.binary.path)); - let file = std::fs::File::open(binary).map_err(|e| format!("{}: {e}", binary.display()))?; - 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()); +#[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, + }) } - let mut options = e5r_analysis::Options::default(); - for (key, value) in &project.analysis { - let boolean = || { - value - .parse::() - .map_err(|_| format!("{key} needs true or false")) + 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() }; - 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")?) + 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:?}" + )); + } } - _ => { - 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, +} + +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); + Ok(Self { + key, + program, + declared, + comments, + }) } - let load = e5r_format::LoadOptions { - base: project.load.base, - arch: project.load.arch.clone(), - ..Default::default() - }; - let mut object = e5r_format::load(&data, &load).map_err(|e| e.to_string())?; - if project - .load - .readers - .iter() - .any(|reader| reader != object.format.as_str()) - { - return Err("dashboard does not yet support custom project readers".into()); + 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")?; + if self.program.function(at).is_none() { + return Err("function not found".into()); + } + 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()) } - e5r_api::apply_relative_relocations(&mut object); - annotate::load_pdb(&mut object, binary); - eprintln!("Analyzing {} for the workspace…", binary.display()); - let mut program = e5r_analysis::analyze(object, &options); - let db = project - .log - .as_ref() - .map(std::path::PathBuf::from) - .unwrap_or_else(|| annotate::default_path(binary)); - annotate::load(&db)?; // Refuse a damaged annotation log before opening. - for (at, name, _) in annotate::names(&program, &db) { - if let Some(f) = program.functions.get_mut(&at) { - f.name = Some(name); +} + +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(), + ); } } - let declared = annotate::declarations(&program, &db); - let comments = annotate::comments(&program, &db); - let store = Store::new(e5r_db::work::beside(project_path)); +} + +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 +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct NewProject { + name: String, + binary: PathBuf, +} + +fn register(request: &mut Request, registry: &Registry) -> Result { + let create: NewProject = body(request)?; + registry::validate_name(&create.name)?; + if !create.binary.is_absolute() { + return Err("enter an absolute binary path".into()); + } + let file = std::fs::File::open(&create.binary).map_err(|e| e.to_string())?; + let data = crate::map_file(&file).map_err(|e| e.to_string())?; + let object = + e5r_format::load(&data, &e5r_format::LoadOptions::default()).map_err(|e| e.to_string())?; + let project = patch::make_project(&create.binary, &object, &data, &[], None)?; + let path = registry.create(&create.name, &project, None)?; + Ok(json!({"name":create.name,"project":path.display().to_string()})) +} + +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!( - "Project workspace: http://{host}\nCtrl-C stops it. Binary and annotations are a startup snapshot." + "{}: http://{host}\nCtrl-C stops it. Analysis loads on demand; tasks stay live.", + if single.is_some() { + "Project workspace" + } else { + "Project collection" + } ); for mut request in server.incoming_requests() { if !trusted(&request, &host) { @@ -151,18 +358,37 @@ pub fn serve(project_path: &Path, port: u16, binary: Option<&Path>) -> Result Some(( + "/" 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 { @@ -170,90 +396,97 @@ pub fn serve(project_path: &Path, port: u16, binary: Option<&Path>) -> Result = (|| { - match (request.method(), path.as_str()) { - (&Method::Get, "/api/project") => Ok( - json!({ "schema":"e5r.workspace.v1", "project":project_path.display().to_string(), "binary":binary.display().to_string(), "hash":format!("{:016x}", project.binary.hash), "info":crate::json::info(&program.object), "author":crate::whoami(), "snapshot":true, "log":db.display().to_string(), "base":project.load.base.map(|a| a.to_string()), "arch":project.load.arch.as_ref().map(|a| a.name()), "no_scan":!options.scan_gaps, "threads":options.threads }), - ), - (&Method::Get, "/api/tasks") => { - serde_json::to_value(store.board()?).map_err(|e| e.to_string()) + 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})), + Method::Post => register(&mut request,registry), + _ => 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")) + { + if target.is_some_and(|target| crate::addr::parse_number(target).is_none()) { + answer(request, Err("expected a function address".into())); + continue; } - (&Method::Get, "/api/functions") => { - serde_json::to_value(crate::json::funcs(&program)).map_err(|e| e.to_string()) + 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(), + ); } - (&Method::Post, "/api/tasks") => { - let change = edit(&mut request)?; - if change.revision.is_some() { - return Err("new tasks do not take a revision".into()); - } - serde_json::to_value(store.create(change.content, &change.author)?) - .map_err(|e| e.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::Post, "/api/tasks") => { + let change = edit(&mut request)?; + if change.revision.is_some() { + return Err("new tasks do not take a revision".into()); } - _ if request.method() == &Method::Post && path.starts_with("/api/tasks/") => { - let change = edit(&mut request)?; - serde_json::to_value(store.update( - &path[11..], - change.revision.ok_or("revision is required")?, - change.content, - &change.author, - )?) + serde_json::to_value(workspace.store().create(change.content, &change.author)?) .map_err(|e| e.to_string()) - } - _ if request.method() == &Method::Get => { - let (kind, target) = path - .strip_prefix("/api/") - .and_then(|s| s.split_once('/')) - .ok_or("route not found")?; - // A pane asks about one recovered function, never an unbounded 'all'. - let at = crate::addr::parse_number(target) - .map(e5r_core::Addr) - .ok_or("expected a function address")?; - if program.function(at).is_none() { - return Err("function not found".into()); - } - let mut output = Out::buffer(); - let limits = crate::print::Limits { - budget: crate::budget::Budget::new(Some(10.0), Some(1)), - progress: false, - declared: &declared, - comments: &comments, - }; - match kind { - "decompile" => { - crate::print::decompile(&mut output, &program, target, true, limits)?; - } - "disas" => { - crate::print::disas(&mut output, &program, target, true, true, limits)?; - } - "xrefs" => { - crate::print::xrefs(&mut output, &program, target, false, true)?; - } - _ => return Err("route not found".into()), - } - serde_json::from_str(&output.take()).map_err(|e| e.to_string()) - } - _ => Err("route not found".into()), } - })(); - 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(), - ); + _ if request.method() == &Method::Post && route.starts_with("/api/tasks/") => { + let change = edit(&mut request)?; + serde_json::to_value(workspace.store().update( + &route[11..], + change.revision.ok_or("revision is required")?, + change.content, + &change.author, + )?) + .map_err(|e| e.to_string()) } - } + _ => Err("route not found".into()), + })(); + answer(request, result); } Ok(0) } diff --git a/crates/e5r-cli/src/dashboard/app.js b/crates/e5r-cli/src/dashboard/app.js index 59f6f89..21c5be8 100644 --- a/crates/e5r-cli/src/dashboard/app.js +++ b/crates/e5r-cli/src/dashboard/app.js @@ -1,4 +1,7 @@ "use strict"; +const base = location.pathname.startsWith("/p/") + ? location.pathname.replace(/\/$/, "") + : ""; const $ = (id) => document.getElementById(id); const state = { project: null, @@ -55,7 +58,7 @@ function shellQuote(text) { } async function api(path, body) { const response = await fetch( - path, + base + path, body === undefined ? {} : { @@ -104,6 +107,12 @@ function view(name) { 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] && @@ -261,7 +270,7 @@ async function refreshTasks() { renderBoard(); } $("sync").textContent = "Tasks up to date"; - error(""); + if (!state.project?.binary_error) error(""); } catch (e) { $("sync").textContent = "Tasks unavailable"; error(`Could not refresh tasks: ${e.message}`); @@ -529,7 +538,7 @@ function renderReferences(data) { message( $("code"), "No incoming references reported.", - "This is the analysis result for this startup snapshot.", + "This is the analysis result for this analysis snapshot.", ); return; } @@ -704,7 +713,8 @@ window.addEventListener("keydown", (e) => { async function boot() { try { state.project = await api("/api/project"); - const name = state.project.project.split("/").pop(); + 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; @@ -712,6 +722,10 @@ async function boot() { `e5r project task ${shellQuote(state.project.project)} list --json`; renderBoard(); await refreshTasks(); + if (state.project.binary_error) + error( + `Binary unavailable: ${state.project.binary_error}. Project tasks remain available.`, + ); route(); setInterval(refreshTasks, 5000); } catch (e) { diff --git a/crates/e5r-cli/src/dashboard/index.html b/crates/e5r-cli/src/dashboard/index.html index faeafbd..8e539e6 100644 --- a/crates/e5r-cli/src/dashboard/index.html +++ b/crates/e5r-cli/src/dashboard/index.html @@ -11,6 +11,9 @@
diff --git a/crates/e5r-cli/src/dashboard/projects.html b/crates/e5r-cli/src/dashboard/projects.html new file mode 100644 index 0000000..5f252e9 --- /dev/null +++ b/crates/e5r-cli/src/dashboard/projects.html @@ -0,0 +1,159 @@ + + + + + + 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 manage 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 +
+ +
+
+
+
+ +
+
+
+
NEW PROJECT
+

Add a binary to your library

+
+ +
+ +

+ Use a file on the machine running e5r. The project and tasks live in + your global library; the binary stays where it is. +

+ +
+ Existing project names are never overwritten. +
+
+
+ + diff --git a/crates/e5r-cli/src/dashboard/projects.js b/crates/e5r-cli/src/dashboard/projects.js new file mode 100644 index 0000000..2c33036 --- /dev/null +++ b/crates/e5r-cli/src/dashboard/projects.js @@ -0,0 +1,204 @@ +"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.` + : "Add a binary here, or 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 ${tone}`); + c.append(el("b", String(n)), el("span", label)); + $("counts").append(c); + } + $("attention").replaceChildren(); + $("attention-count").textContent = String(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. Add a binary with New project, or import an existing manifest from 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; +$("new-project").onclick = () => { + $("project-form").reset(); + $("form-error").hidden = true; + $("project-dialog").showModal(); + $("project-form").elements.name.focus(); +}; +$("close-dialog").onclick = () => $("project-dialog").close(); +$("project-form").onsubmit = async (event) => { + event.preventDefault(); + const form = $("project-form"); + $("save-project").disabled = true; + $("form-error").hidden = true; + try { + const response = await fetch("/api/projects", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-E5R-Client": "dashboard", + }, + body: JSON.stringify({ + name: form.elements.name.value.trim(), + binary: form.elements.binary.value.trim(), + }), + }), + data = await response.json(); + if (!response.ok) throw new Error(data.error); + $("project-dialog").close(); + await refresh(); + } catch (e) { + $("form-error").textContent = e.message; + $("form-error").hidden = false; + } finally { + $("save-project").disabled = false; + } +}; +$("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 index 24e8788..2be68a3 100644 --- a/crates/e5r-cli/src/dashboard/style.css +++ b/crates/e5r-cli/src/dashboard/style.css @@ -1029,3 +1029,41 @@ summary { 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: 11px; + display: block; + overflow-wrap: anywhere; +} +.all-projects { + margin-top: 24px; + 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: 11px; + } +} diff --git a/crates/e5r-cli/src/main.rs b/crates/e5r-cli/src/main.rs index 7f303dd..0547383 100644 --- a/crates/e5r-cli/src/main.rs +++ b/crates/e5r-cli/src/main.rs @@ -18,6 +18,7 @@ mod out; mod patch; mod print; mod progress; +mod projects; mod repl; mod shell; mod work; @@ -101,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. @@ -455,16 +462,40 @@ 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 { - /// The project file. + /// Global project name or project file. project: PathBuf, #[command(subcommand)] what: work::Command, }, /// Open a local project dashboard and decompiler workspace. Dashboard { - /// The project file. + /// Global project name or project file. project: PathBuf, /// Loopback port; zero chooses a free port. #[arg(long, default_value_t = 7879)] @@ -477,9 +508,12 @@ pub enum ProjectCommand { 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, @@ -572,7 +606,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 { @@ -595,7 +629,10 @@ 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, .. } @@ -647,6 +684,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() { @@ -1006,18 +1044,29 @@ 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 } => { - patch::project_read(project)?; - work::run(w, project, what) + let project = projects::resolve(project)?; + patch::project_read(&project)?; + work::run(w, &project, what) } ProjectCommand::Dashboard { project, port, binary, - } => dashboard::serve(project, *port, binary.as_deref()), + } => dashboard::serve(&projects::resolve(project)?, *port, binary.as_deref()), ProjectCommand::New { binary, out, + name, settings, log, } => { @@ -1026,11 +1075,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)); @@ -1044,7 +1104,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/projects.rs b/crates/e5r-cli/src/projects.rs new file mode 100644 index 0000000..ad87801 --- /dev/null +++ b/crates/e5r-cli/src/projects.rs @@ -0,0 +1,87 @@ +//! 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 { + 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-db/src/lib.rs b/crates/e5r-db/src/lib.rs index a24da8e..56f0d99 100644 --- a/crates/e5r-db/src/lib.rs +++ b/crates/e5r-db/src/lib.rs @@ -12,6 +12,7 @@ pub mod anchor; pub mod log; pub mod patch; pub mod project; +pub mod registry; pub mod signature; pub mod work; diff --git a/crates/e5r-db/src/registry.rs b/crates/e5r-db/src/registry.rs new file mode 100644 index 0000000..81af84a --- /dev/null +++ b/crates/e5r-db/src/registry.rs @@ -0,0 +1,428 @@ +//! 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(), + }; + 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())?; + } + } + 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))); + } + 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/MANUAL.md b/docs/MANUAL.md similarity index 97% rename from MANUAL.md rename to docs/MANUAL.md index ff8d890..dc86b16 100644 --- a/MANUAL.md +++ b/docs/MANUAL.md @@ -451,6 +451,11 @@ 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" @@ -459,8 +464,9 @@ 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. Binary analysis and annotations are -a startup snapshot; tasks refresh live. See [docs/dashboard.md](docs/dashboard.md) +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 diff --git a/ROADMAP.md b/docs/ROADMAP.md similarity index 99% rename from ROADMAP.md rename to docs/ROADMAP.md index 3fc4aeb..7033cbe 100644 --- a/ROADMAP.md +++ b/docs/ROADMAP.md @@ -27,7 +27,7 @@ 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 @@ -225,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 @@ -501,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 @@ -703,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 @@ -948,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`. @@ -958,7 +958,7 @@ 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 @@ -969,7 +969,7 @@ it was closed. 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`](docs/dashboard.md). + [`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 index f627213..c822e7b 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -1,16 +1,51 @@ # A workspace for people and agents -Create a binary project, then serve its workspace locally: +Start the global collection from any directory: ```sh -./target/release/e5r project new ./a.out --out investigation.e5rproj -./target/release/e5r project dashboard investigation.e5rproj +e5r project new ./a.out --name parser +e5r ui ``` -Open the printed `http://127.0.0.1:7879` address. `--port 0` selects a free -port; `--binary PATH` opens a moved binary only if its content still matches the -project. Ctrl-C stops the workspace. Assets ship inside the Rust executable; -there is no frontend build step, CDN, font download, or Node runtime requirement. +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. **New project** registers a local binary from an absolute path. +An unavailable binary does not prevent editing 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: + +```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 @@ -34,8 +69,9 @@ 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 **startup snapshot**; restart the server after changing -them. Task data stays live. Signature-library references and patch-set references +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`, @@ -110,11 +146,13 @@ is no longer running. Corrupt JSON is an error, never an invisible missing task. The workspace binds IPv4 loopback only. Host and Origin checks reject cross-site and rebound-host requests. Writes require JSON and the custom `X-E5R-Client: dashboard` header; there is no CORS permission. Task request bodies -are capped at 128 KiB. Binary paths and arbitrary commands cannot be supplied by -HTTP. Analysis endpoints accept only a recovered function's address. +are capped at 128 KiB. Project creation accepts an absolute local binary path; no arbitrary commands +are accepted. Analysis endpoints accept only a recovered function's address. | Route | Meaning | | --- | --- | +| `GET /api/projects` | all registered projects and live task signals | +| `POST /api/projects` | create a project from `{name, binary}` | | `GET /api/project` | project identity and container information | | `GET /api/tasks` | shared task snapshot | | `POST /api/tasks` | create `{content, author, revision: null}` | @@ -124,9 +162,13 @@ HTTP. Analysis endpoints accept only a recovered function's address. | `GET /api/disas/0xADDRESS` | one function's instructions | | `GET /api/xrefs/0xADDRESS` | incoming references | +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. The server handles requests serially, and -single-function decompilation can delay task requests. The existing budget is +404, invalid content returns 400. Analysis runs on a separate worker with a bounded queue, so project monitoring +and task editing 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. @@ -138,6 +180,7 @@ 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 @@ -145,3 +188,7 @@ workspace on a free port, compares decompiler output with the CLI, tests browser editing and stale revisions, checks function navigation 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, browser creation, decompiler access and binary +relocation with a temporary `E5R_PROJECT_HOME`. 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-projects.mjs b/scripts/test-projects.mjs new file mode 100644 index 0000000..52aef1d --- /dev/null +++ b/scripts/test-projects.mjs @@ -0,0 +1,149 @@ +// 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", + ), + ); + cli("project", "import", local, "--name", "beta"); + 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 }); + await page.locator("#new-project").click(); + await page.locator("[name=name]").fill("gamma"); + await page.locator("[name=binary]").fill(binary); + await page.locator("#save-project").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("dialog[open] [name=title]").inputValue(), + "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 pane = await ( + await fetch(url + `/p/beta/api/decompile/${main.addr}`) + ).json(); + assert.deepEqual( + pane, + JSON.parse(cli("decompile", binary, main.addr, "--json")), + ); + 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, browser 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..22e4e44 --- /dev/null +++ b/skills/e5r/SKILL.md @@ -0,0 +1,39 @@ +--- +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. From 790b312714f969502672c4ce5f10b828da143637 Mon Sep 17 00:00:00 2001 From: Koukyosyumei Date: Thu, 8 Oct 2026 15:13:58 -0400 Subject: [PATCH 05/12] Fix CI analysis regressions and optimize test execution --- .github/workflows/ci.yaml | 4 + crates/e5r-decomp/tests/datatests.rs | 28 +++++- crates/e5r-ir/src/opt.rs | 130 ++++++++++++++++++++++++++- crates/e5r-ir/tests/call_sites.rs | 12 +-- 4 files changed, 157 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 60049c9..843e7f6 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -45,6 +45,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 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-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 { From 5febe77281f4064317f162729708c1657eb19a6b Mon Sep 17 00:00:00 2001 From: Koukyosyumei Date: Thu, 8 Oct 2026 15:27:39 -0400 Subject: [PATCH 06/12] Bound CI package download stalls with retries and timeouts --- .github/workflows/ci.yaml | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 843e7f6..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. @@ -72,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 @@ -102,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 @@ -135,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 @@ -158,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 From 58b97bb73c7fe9254f7c621f8a28b11c7af34f8e Mon Sep 17 00:00:00 2001 From: Koukyosyumei Date: Thu, 8 Oct 2026 15:31:31 -0400 Subject: [PATCH 07/12] Prefer registered project names over same-named binaries --- crates/e5r-cli/src/projects.rs | 15 +++++++++++++++ docs/dashboard.md | 3 ++- scripts/test-projects.mjs | 13 +++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/crates/e5r-cli/src/projects.rs b/crates/e5r-cli/src/projects.rs index ad87801..9d1f9f3 100644 --- a/crates/e5r-cli/src/projects.rs +++ b/crates/e5r-cli/src/projects.rs @@ -12,6 +12,21 @@ pub fn registry() -> Result { } 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()); } diff --git a/docs/dashboard.md b/docs/dashboard.md index c822e7b..fbffb51 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -24,7 +24,8 @@ 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: +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 diff --git a/scripts/test-projects.mjs b/scripts/test-projects.mjs index 52aef1d..138945a 100644 --- a/scripts/test-projects.mjs +++ b/scripts/test-projects.mjs @@ -116,6 +116,19 @@ try { 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( From 5d6a7a5a76cd6b4f58d9e5e3bdbd907bd1c48773 Mon Sep 17 00:00:00 2001 From: Koukyosyumei Date: Thu, 8 Oct 2026 16:41:50 -0400 Subject: [PATCH 08/12] Make dashboard read-only and add call graphs and agent records --- .gitignore | 3 + README.md | 3 +- crates/e5r-cli/src/dashboard.rs | 127 ++++-------- crates/e5r-cli/src/dashboard/app.js | 217 ++++++++------------- crates/e5r-cli/src/dashboard/index.html | 111 +---------- crates/e5r-cli/src/dashboard/projects.html | 49 +---- crates/e5r-cli/src/dashboard/projects.js | 39 +--- crates/e5r-cli/src/dashboard/style.css | 12 ++ crates/e5r-cli/src/main.rs | 21 ++ crates/e5r-cli/src/work.rs | 5 +- crates/e5r-db/src/registry.rs | 27 +++ docs/dashboard.md | 61 +++--- scripts/test-dashboard.mjs | 98 ++++------ scripts/test-projects.mjs | 22 ++- skills/e5r/SKILL.md | 21 ++ 15 files changed, 317 insertions(+), 499 deletions(-) diff --git a/.gitignore b/.gitignore index acee0dd..bb16659 100644 --- a/.gitignore +++ b/.gitignore @@ -257,3 +257,6 @@ __marimo__/ # 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/README.md b/README.md index 0ce28d5..b8ea5bf 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,8 @@ e5r project new ./a.out --out a.e5rproj # reopen it later without reanalysi A local dashboard puts blockers, review requests and next actions first. Its decompiler pane provides searchable functions, pseudocode, disassembly and -references, with tasks and evidence shared by humans and agents. +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 diff --git a/crates/e5r-cli/src/dashboard.rs b/crates/e5r-cli/src/dashboard.rs index c34736d..2568316 100644 --- a/crates/e5r-cli/src/dashboard.rs +++ b/crates/e5r-cli/src/dashboard.rs @@ -3,22 +3,12 @@ use crate::{annotate, out::Out, patch}; use e5r_db::{ project::Project, registry::{self, Registry}, - work::{Draft, Store}, + work::Store, }; -use serde::Deserialize; use serde_json::{Value, json}; -use std::io::Read; use std::path::{Path, PathBuf}; use tiny_http::{Header, Method, Request, Response, Server, StatusCode}; -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct Edit { - content: Draft, - author: String, - revision: Option, -} - fn header(name: &str, value: &str) -> Header { Header::from_bytes(name.as_bytes(), value.as_bytes()).expect("constant HTTP header") } @@ -45,37 +35,6 @@ fn trusted(request: &Request, host: &str) -> bool { && field(request, "Origin").is_none_or(|origin| origin == format!("http://{host}")) } -fn body(request: &mut Request) -> Result { - if field(request, "X-E5R-Client") != Some("dashboard") { - return Err("missing dashboard client header".into()); - } - if field(request, "Content-Type") != Some("application/json") { - return Err("expected application/json".into()); - } - const CAP: usize = 128 * 1024; - if request.body_length().is_some_and(|n| n > CAP) { - return Err("request exceeds 128 KiB".into()); - } - let mut data = Vec::new(); - request - .as_reader() - .take((CAP + 1) as u64) - .read_to_end(&mut data) - .map_err(|e| e.to_string())?; - if data.len() > CAP { - return Err("request exceeds 128 KiB".into()); - } - serde_json::from_slice(&data).map_err(|e| e.to_string()) -} - -fn edit(request: &mut Request) -> Result { - let change: Edit = body(request)?; - if change.author.trim().is_empty() { - return Err("a writer name is required".into()); - } - Ok(change) -} - #[derive(Clone)] struct Workspace { path: PathBuf, @@ -216,6 +175,21 @@ impl Analysis { if self.program.function(at).is_none() { return Err("function not found".into()); } + 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)), @@ -296,28 +270,6 @@ fn worker() -> std::sync::mpsc::SyncSender { sender } -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct NewProject { - name: String, - binary: PathBuf, -} - -fn register(request: &mut Request, registry: &Registry) -> Result { - let create: NewProject = body(request)?; - registry::validate_name(&create.name)?; - if !create.binary.is_absolute() { - return Err("enter an absolute binary path".into()); - } - let file = std::fs::File::open(&create.binary).map_err(|e| e.to_string())?; - let data = crate::map_file(&file).map_err(|e| e.to_string())?; - let object = - e5r_format::load(&data, &e5r_format::LoadOptions::default()).map_err(|e| e.to_string())?; - let project = patch::make_project(&create.binary, &object, &data, &[], None)?; - let path = registry.create(&create.name, &project, None)?; - Ok(json!({"name":create.name,"project":path.display().to_string()})) -} - 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. @@ -345,7 +297,7 @@ fn serve_inner( "Project collection" } ); - for mut request in server.incoming_requests() { + for request in server.incoming_requests() { if !trusted(&request, &host) { respond( request, @@ -355,6 +307,15 @@ fn serve_inner( ); 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() { @@ -400,7 +361,7 @@ fn serve_inner( 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})), - Method::Post => register(&mut request,registry), + _ => Err("route not found".into()), }; answer(request, result); @@ -433,9 +394,12 @@ fn serve_inner( .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")) - { + if let Some((kind, target)) = analysis_route.filter(|(kind, _)| { + matches!( + *kind, + "functions" | "decompile" | "disas" | "xrefs" | "callgraph" + ) + }) { if target.is_some_and(|target| crate::addr::parse_number(target).is_none()) { answer(request, Err("expected a function address".into())); continue; @@ -466,23 +430,16 @@ fn serve_inner( (&Method::Get, "/api/tasks") => { serde_json::to_value(workspace.store().board()?).map_err(|e| e.to_string()) } - (&Method::Post, "/api/tasks") => { - let change = edit(&mut request)?; - if change.revision.is_some() { - return Err("new tasks do not take a revision".into()); + (&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})); + } } - serde_json::to_value(workspace.store().create(change.content, &change.author)?) - .map_err(|e| e.to_string()) - } - _ if request.method() == &Method::Post && route.starts_with("/api/tasks/") => { - let change = edit(&mut request)?; - serde_json::to_value(workspace.store().update( - &route[11..], - change.revision.ok_or("revision is required")?, - change.content, - &change.author, - )?) - .map_err(|e| e.to_string()) + Ok(json!({"schema":"e5r.records.v1","records":records})) } _ => Err("route not found".into()), })(); diff --git a/crates/e5r-cli/src/dashboard/app.js b/crates/e5r-cli/src/dashboard/app.js index 21c5be8..8c578d9 100644 --- a/crates/e5r-cli/src/dashboard/app.js +++ b/crates/e5r-cli/src/dashboard/app.js @@ -9,7 +9,8 @@ const state = { functions: [], selected: null, pane: "decompile", - editing: null, + currentTask: null, + recordStamp: "", request: 0, history: [], code: "", @@ -23,17 +24,6 @@ const labels = { review: "Review", done: "Done", }; -const blank = () => ({ - title: "", - description: "", - state: "planned", - priority: "normal", - owner: "", - next: "", - blocker: "", - evidence: [], - depends_on: [], -}); function el(tag, text, className) { const e = document.createElement(tag); if (text !== undefined) e.textContent = text; @@ -56,20 +46,8 @@ function message(node, title, detail) { function shellQuote(text) { return "'" + text.replaceAll("'", "'\\''") + "'"; } -async function api(path, body) { - const response = await fetch( - base + path, - body === undefined - ? {} - : { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-E5R-Client": "dashboard", - }, - body: JSON.stringify(body), - }, - ); +async function api(path) { + const response = await fetch(base + path); const data = await response.json(); if (!response.ok) { const error = new Error( @@ -192,7 +170,7 @@ function renderBoard() { open.length ? "No task ready to claim." : "Start the first task.", open.length ? "Active work, blockers and prerequisites are listed below." - : "Use “New task” to capture the next investigation.", + : "Ask an agent to capture the next investigation.", ); if (attention.length > 6) $("attention").append( @@ -265,6 +243,10 @@ async function refreshTasks() { 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(); @@ -276,96 +258,52 @@ async function refreshTasks() { error(`Could not refresh tasks: ${e.message}`); } } -function openTask(task = null, content = null) { - state.editing = task; - const d = content || task?.content || blank(), - form = $("task-form"); - for (const key of [ - "title", - "description", - "state", - "priority", - "owner", - "next", - "blocker", - ]) - form.elements[key].value = d[key]; - form.elements.evidence.value = d.evidence.join("\n"); - form.elements.depends_on.value = d.depends_on.join(", "); - form.elements.author.value = - localStorage.getItem("e5r.writer") || state.project?.author || "human"; - $("edit-id").textContent = task - ? `${task.id} · REVISION ${task.revision}` - : "NEW TASK"; - $("edit-heading").textContent = task - ? "Keep the next step clear" - : "Define the next outcome"; - $("revision-note").textContent = task - ? "Stale edits are refused. Task history is retained." - : "Saved beside the project; shared with agents."; - $("form-error").hidden = true; - $("reload-task").hidden = true; - $("history").hidden = !task?.history.length; +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 div = el("div", undefined, "history-row"); - div.append( - el("strong", `${h.author} · ${new Date(h.at * 1000).toLocaleString()}`), - el("div", `${labels[h.previous.state]} · ${h.previous.title}`), - el("div", h.previous.next || "No next action"), - ); - $("history-items").append(div); + 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); } - blockerToggle(); if (!$("task-dialog").open) $("task-dialog").showModal(); - form.elements.title.focus(); -} -function blockerToggle() { - const blocked = $("task-form").elements.state.value === "blocked"; - $("blocker-label").hidden = !blocked; - $("task-form").elements.blocker.required = blocked; } -async function saveTask(event) { - event.preventDefault(); - const form = $("task-form"), - content = blank(); - for (const key of [ - "title", - "description", - "state", - "priority", - "owner", - "next", - "blocker", - ]) - content[key] = form.elements[key].value.trim(); - if (content.state !== "blocked") content.blocker = ""; - content.evidence = form.elements.evidence.value - .split("\n") - .map((s) => s.trim()) - .filter(Boolean); - content.depends_on = form.elements.depends_on.value - .split(/[\s,]+/) - .filter(Boolean); - const author = form.elements.author.value.trim(); - $("save-task").disabled = true; - $("form-error").hidden = true; +async function refreshRecords() { try { - await api(state.editing ? `/api/tasks/${state.editing.id}` : "/api/tasks", { - content, - author, - revision: state.editing?.revision ?? null, - }); - localStorage.setItem("e5r.writer", author); - $("task-dialog").close(); - await refreshTasks(); - } catch (e) { - $("form-error").textContent = e.message; - $("form-error").hidden = false; - $("reload-task").hidden = e.status !== 409; - } finally { - $("save-task").disabled = false; - } + const data = await api("/api/records"); + const stamp = JSON.stringify(data); + if (stamp === state.recordStamp) return; + 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) $("records").textContent = "No records yet. Ask an agent to write a finding, note or report with e5r project record."; + } catch (e) { $("records").textContent = `Records unavailable: ${e.message}`; } } async function loadFunctions() { $("function-count").textContent = "Loading…"; @@ -431,7 +369,6 @@ function selectFunction(f, push = true) { $("back").disabled = !state.history.length; $("function-name").textContent = f.name; $("function-address").textContent = f.addr; - $("task-function").disabled = false; $("copy-code").disabled = true; history.replaceState(null, "", `#workspace/${f.addr}`); view("workspace"); @@ -467,7 +404,7 @@ function renderRelated() { ); if (!tasks.length) $("related-work").textContent = - "No task linked yet. “Task here” preserves this function as evidence."; + "No task linked yet. Ask an agent to link this function as evidence."; } function syntaxLine(line, known) { const container = el("span", undefined, "line-source"); @@ -573,6 +510,23 @@ function renderReferences(data) { state.code = JSON.stringify(data, null, 2); $("copy-code").disabled = false; } +function renderCallGraph(data) { + clear($("code")); + const graph = el("div", undefined, "call-graph"); + for (const edge of data.edges) { + for (const [i, addr] of [edge.from, edge.to].entries()) { + if (i) graph.append(el("span", "→", "call-arrow")); + const f = state.functions.find(f => f.addr === addr); + const node = el(f ? "button" : "span", f ? `${f.name}\n${addr}` : addr, "call-node"); + if (f) node.onclick = () => selectFunction(f); + graph.append(node); + } + } + $("code").append(graph); + if (!data.edges.length) message($("code"), "No recovered direct calls.", "Indirect targets are not resolved by this graph."); + state.code = JSON.stringify(data, null, 2); + $("copy-code").disabled = false; +} async function loadPane() { if (!state.selected) return; const serial = ++state.request, @@ -605,7 +559,8 @@ async function loadPane() { .join(" "); $("analysis-command").textContent = `e5r ${command} ${shellQuote(state.project.binary)} ${f.addr}${options ? " " + options : ""}${pane === "disas" ? " --bytes" : ""}`; - $("copy-analysis").disabled = false; + $("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; @@ -635,6 +590,9 @@ async function loadPane() { else renderCode(lines.join("\n")); $("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 = @@ -656,17 +614,16 @@ for (const b of document.querySelectorAll("[data-pane]")) state.pane = b.dataset.pane; loadPane(); }; -$("new-task").onclick = () => openTask(); -$("refresh").onclick = refreshTasks; +$("refresh").onclick = () => { refreshTasks(); refreshRecords(); }; $("close-dialog").onclick = () => $("task-dialog").close(); -$("task-form").onsubmit = saveTask; -$("task-form").elements.state.onchange = blockerToggle; -$("reload-task").onclick = async () => { - const id = state.editing?.id; - await refreshTasks(); - const task = state.board.tasks.find((r) => r.task.id === id)?.task; - if (task) openTask(task); +$("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 = () => { @@ -684,15 +641,6 @@ $("wrap-code").onchange = () => { $("wrap-code").checked = localStorage.getItem("e5r.wrap") !== "false"; $("code").classList.toggle("wrap", $("wrap-code").checked); $("copy-code").onclick = () => copy(state.code, $("copy-code")); -$("task-function").onclick = () => { - const d = blank(); - d.title = `Investigate ${state.selected.name}`; - d.evidence = [ - `function:${state.selected.addr}`, - $("analysis-command").textContent, - ]; - openTask(null, d); -}; $("back").onclick = () => { const addr = state.history.pop(), f = state.functions.find((f) => f.addr === addr); @@ -722,12 +670,13 @@ async function boot() { `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, 5000); + setInterval(() => { refreshTasks(); refreshRecords(); }, 5000); } catch (e) { error(`Could not open project: ${e.message}`); $("sync").textContent = "Unavailable"; diff --git a/crates/e5r-cli/src/dashboard/index.html b/crates/e5r-cli/src/dashboard/index.html index 8e539e6..658f869 100644 --- a/crates/e5r-cli/src/dashboard/index.html +++ b/crates/e5r-cli/src/dashboard/index.html @@ -41,7 +41,7 @@
Connecting… + >
@@ -109,6 +109,7 @@

Project work

+

Findings, notes & reports

Written by agents · read-only
AGENT HANDOFF
@@ -157,9 +158,7 @@

Explore the binary

← Back + >
@@ -184,7 +183,7 @@

Explore the binary

data-pane="xrefs" > References
@@ -202,8 +201,7 @@

Explore the binary

{ }

Follow the code. Keep the context.

- Select a function to see its pseudocode and evidence.
Create - a task here to capture the next investigation. + Select a function to see its pseudocode and evidence.
Ask an agent to record the next investigation.

@@ -236,100 +234,11 @@

Follow the code. Keep the context.

- -
-
-
-
NEW TASK
-

Define the next outcome

-
- -
- -
- -
- -
- -
-
- Revision history -
-
- -
- Saved beside the project; shared with agents. -
-
+ +

+
+

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 index 5f252e9..60dd176 100644 --- a/crates/e5r-cli/src/dashboard/projects.html +++ b/crates/e5r-cli/src/dashboard/projects.html @@ -34,9 +34,7 @@
Connecting… + >
@@ -64,7 +62,7 @@

Needs attention

Projects

Open a project to manage tasks or explore its codeOpen a project to read tasks or explore its code
@@ -112,48 +110,5 @@

Projects

- -
-
-
-
NEW PROJECT
-

Add a binary to your library

-
- -
- -

- Use a file on the machine running e5r. The project and tasks live in - your global library; the binary stays where it is. -

- -
- Existing project names are never overwritten. -
-
-
diff --git a/crates/e5r-cli/src/dashboard/projects.js b/crates/e5r-cli/src/dashboard/projects.js index 2c33036..6e2a0f2 100644 --- a/crates/e5r-cli/src/dashboard/projects.js +++ b/crates/e5r-cli/src/dashboard/projects.js @@ -31,7 +31,7 @@ function render() { : "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.` - : "Add a binary here, or run e5r project new PATH --name NAME. Existing manifests can be imported with e5r project import."; + : "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", ""], @@ -133,7 +133,7 @@ function renderProjects() { $("empty").hidden = !!visible.length; $("empty").textContent = projects.length ? "No projects match this view." - : "No projects registered. Add a binary with New project, or import an existing manifest from the CLI."; + : "No projects registered. Ask an agent to register or import a project through the CLI."; } async function refresh() { try { @@ -158,41 +158,6 @@ async function refresh() { $("refresh").onclick = refresh; $("search").oninput = renderProjects; $("filter").onchange = renderProjects; -$("new-project").onclick = () => { - $("project-form").reset(); - $("form-error").hidden = true; - $("project-dialog").showModal(); - $("project-form").elements.name.focus(); -}; -$("close-dialog").onclick = () => $("project-dialog").close(); -$("project-form").onsubmit = async (event) => { - event.preventDefault(); - const form = $("project-form"); - $("save-project").disabled = true; - $("form-error").hidden = true; - try { - const response = await fetch("/api/projects", { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-E5R-Client": "dashboard", - }, - body: JSON.stringify({ - name: form.elements.name.value.trim(), - binary: form.elements.binary.value.trim(), - }), - }), - data = await response.json(); - if (!response.ok) throw new Error(data.error); - $("project-dialog").close(); - await refresh(); - } catch (e) { - $("form-error").textContent = e.message; - $("form-error").hidden = false; - } finally { - $("save-project").disabled = false; - } -}; $("copy-command").onclick = async () => { try { await navigator.clipboard.writeText("e5r project list --json"); diff --git a/crates/e5r-cli/src/dashboard/style.css b/crates/e5r-cli/src/dashboard/style.css index 2be68a3..18b3505 100644 --- a/crates/e5r-cli/src/dashboard/style.css +++ b/crates/e5r-cli/src/dashboard/style.css @@ -1067,3 +1067,15 @@ summary { font-size: 11px; } } + +/* 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; grid-template-columns: minmax(0,1fr) auto minmax(0,1fr); gap: 14px; padding: 24px; align-items: center; } +.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; } + +.call-node:hover { border-color: #d5b56b; } diff --git a/crates/e5r-cli/src/main.rs b/crates/e5r-cli/src/main.rs index 0547383..af603b5 100644 --- a/crates/e5r-cli/src/main.rs +++ b/crates/e5r-cli/src/main.rs @@ -493,6 +493,14 @@ pub enum ProjectCommand { #[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. @@ -1058,6 +1066,19 @@ fn project(w: &mut out::Out, what: &ProjectCommand) -> Result { 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, diff --git a/crates/e5r-cli/src/work.rs b/crates/e5r-cli/src/work.rs index f4c56cd..e0e09e7 100644 --- a/crates/e5r-cli/src/work.rs +++ b/crates/e5r-cli/src/work.rs @@ -67,7 +67,10 @@ fn read(path: &Path) -> Result { } pub fn run(w: &mut Out, project: &Path, command: &Command) -> Result { - let store = Store::new(e5r_db::work::beside(project)); + 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 } => { diff --git a/crates/e5r-db/src/registry.rs b/crates/e5r-db/src/registry.rs index 81af84a..424ba1a 100644 --- a/crates/e5r-db/src/registry.rs +++ b/crates/e5r-db/src/registry.rs @@ -219,6 +219,12 @@ impl Registry { 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) @@ -233,6 +239,20 @@ impl Registry { .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())?; @@ -245,6 +265,13 @@ impl Registry { 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); diff --git a/docs/dashboard.md b/docs/dashboard.md index fbffb51..f4c2987 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -11,8 +11,8 @@ 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. **New project** registers a local binary from an absolute path. -An unavailable binary does not prevent editing that project's tasks. +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: @@ -54,15 +54,18 @@ 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. -An open editor keeps its contents while the background refreshes. +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 uses a familiar three-column workspace: searchable functions on the left, code in the center, and evidence, variables and related tasks on the right. Use `/` to focus function search. Pseudocode, disassembly and incoming references are tabs over the same recovered function. Click a known function name or address to navigate; Back returns to the previous function. Toggle **Wrap** for long expressions. Copy code -or its CLI command, or use **Task here** to retain the function address as -evidence in a new task. Large function lists are loaded in display batches; +or its CLI command, ask an agent to retain the function address as +evidence in a task. **Call graph** shows incoming and outgoing direct calls +around the selected function; click a node to navigate. Unresolved indirect +call targets are not included. Large function lists are loaded in display batches; search always searches the complete list. Narrow screens collapse the context column so code keeps space. @@ -80,7 +83,7 @@ analysis settings are `scan_gaps`, `follow_calls`, `strings`, `xrefs`, `noreturn ## The agent interface -The CLI reads and writes exactly the same task store as the browser: +The CLI writes the task store that the browser reads: ```sh e5r project task investigation.e5rproj add 'Trace input validation' \ @@ -116,14 +119,13 @@ 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 by **Task here**. +`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. Browser writer names are -remembered locally and remain editable. +`--author` defaults to `E5R_AUTHOR`, then the OS user. The browser has no write controls. ## Durability and conflicts @@ -136,8 +138,7 @@ 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. The browser keeps -your rejected edit visible and offers **Reload latest task**. The lock and +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. @@ -145,30 +146,26 @@ 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. Writes require JSON and the custom -`X-E5R-Client: dashboard` header; there is no CORS permission. Task request bodies -are capped at 128 KiB. Project creation accepts an absolute local binary path; no arbitrary commands -are accepted. Analysis endpoints accept only a recovered function's address. +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 | -| `POST /api/projects` | create a project from `{name, binary}` | | `GET /api/project` | project identity and container information | | `GET /api/tasks` | shared task snapshot | -| `POST /api/tasks` | create `{content, author, revision: null}` | -| `POST /api/tasks/T-0001` | update `{content, author, revision}` | | `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/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 editing remain responsive during decompilation. Only one analyzed +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. @@ -185,11 +182,31 @@ E5R_PLAYWRIGHT_MODULE=/path/to/playwright/index.mjs node scripts/test-projects.m ``` 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 browser -editing and stale revisions, checks function navigation and linked evidence, +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 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, browser creation, decompiler access and binary +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. 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 +``` + +Full record text lives in `description`; use `--input FILE` for large content. +The UI renders text literally, including Markdown, without executing HTML. diff --git a/scripts/test-dashboard.mjs b/scripts/test-dashboard.mjs index 1381b85..c194202 100644 --- a/scripts/test-dashboard.mjs +++ b/scripts/test-dashboard.mjs @@ -81,7 +81,7 @@ try { body: "{}", }) ).status, - 400, + 405, ); assert.equal( ( @@ -116,7 +116,7 @@ try { body: "x".repeat(128 * 1024 + 1), }) ).status, - 400, + 405, ); browser = await chromium.launch({ headless: true, args: ["--no-sandbox"] }); const page = await browser.newPage({ @@ -132,49 +132,33 @@ try { await page .getByRole("button", { name: "Trace input validation", exact: true }) .click(); - await page.locator("[name=next]").fill("Human draft that must survive"); - const draft = { ...task.content, next: "Agent update" }; + assert.equal(await page.locator("#task-dialog input, #task-dialog textarea, #task-dialog select").count(), 0); + await page.locator("#expand-task").click(); + assert(await page.locator("#task-dialog").evaluate(e => 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.getByRole("button", { name: "Save task", exact: true }).click(); - await page - .locator("#form-error") - .filter({ hasText: "revision conflict" }) - .waitFor(); - assert.equal( - await page.locator("[name=next]").inputValue(), - "Human draft that must survive", - ); - await page.getByRole("button", { name: "Reload latest task" }).click(); - await page.waitForFunction( - () => document.querySelector("[name=next]").value === "Agent update", - ); - assert.equal(await page.locator("[name=next]").inputValue(), "Agent update"); - await page.locator("[name=state]").selectOption("blocked"); - await page - .locator("[name=blocker]") - .fill("Need a sample input from the owner"); - await page.getByRole("button", { name: "Save task", exact: true }).click(); - await page.locator("#task-dialog").waitFor({ state: "hidden" }); - await page - .locator("#headline") - .filter({ hasText: "1 item needs attention." }) - .waitFor(); - const board = JSON.parse(cli("project", "task", project, "list", "--json")); - assert.equal(board.tasks[0].attention, "Need a sample input from the owner"); + 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"); + } + 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, @@ -208,23 +192,13 @@ try { .locator("#quality") .filter({ hasText: "Incoming references" }) .waitFor(); - await page.getByRole("button", { name: "+ Task here" }).click(); - assert( - (await page.locator("[name=evidence]").inputValue()).includes( - `function:${main.addr}`, - ), - ); - await page - .locator("[name=title]") - .fill(''); - await page.getByRole("button", { name: "Save task", exact: true }).click(); - await page.locator("#task-dialog").waitFor({ state: "hidden" }); - await page - .locator("#related-work") - .getByText('', { exact: false }) - .waitFor(); + 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.locator("#related-work img").count(), 0); await page.setViewportSize({ width: 390, height: 844 }); await page.screenshot({ path: "/tmp/e5r-dashboard-mobile.png", @@ -242,10 +216,10 @@ try { const saved = JSON.parse( readFileSync(join(project + ".work", task.id + ".json"), "utf8"), ); - assert.equal(saved.revision, 3); - assert.equal(saved.history.length, 2); + assert.equal(saved.revision, 2); + assert.equal(saved.history.length, 1); console.log( - "Dashboard integration passed: CLI parity, browser edits, stale conflicts, attention, function navigation, evidence, XSS, mobile layout, HTTP boundaries.", + "Dashboard integration passed: CLI parity, read-only UI, live agent updates, full task view, records, call graph, function navigation, evidence, XSS, mobile layout, HTTP boundaries.", ); } finally { if (browser) await browser.close(); diff --git a/scripts/test-projects.mjs b/scripts/test-projects.mjs index 138945a..46d3c7b 100644 --- a/scripts/test-projects.mjs +++ b/scripts/test-projects.mjs @@ -39,7 +39,13 @@ try { "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")), @@ -93,17 +99,15 @@ try { 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 }); - await page.locator("#new-project").click(); - await page.locator("[name=name]").fill("gamma"); - await page.locator("[name=binary]").fill(binary); - await page.locator("#save-project").click(); - await page.waitForFunction( - () => document.querySelectorAll("#project-rows .project-link").length === 3, - ); + 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("dialog[open] [name=title]").inputValue(), + await page.locator("#edit-heading").textContent(), "Inspect entry point", ); const functions = await (await fetch(url + "/p/beta/api/functions")).json(); @@ -150,7 +154,7 @@ try { ); assert.equal(errors.length, 0, errors.join("\n")); console.log( - "Global projects: registration, import, cwd independence, isolated tasks, browser creation, decompiler and relocation passed.", + "Global projects: registration, import, cwd independence, isolated tasks, read-only collection and CLI creation, decompiler and relocation passed.", ); } finally { if (browser) await browser.close(); diff --git a/skills/e5r/SKILL.md b/skills/e5r/SKILL.md index 22e4e44..1d5f6a3 100644 --- a/skills/e5r/SKILL.md +++ b/skills/e5r/SKILL.md @@ -37,3 +37,24 @@ 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. From 9cd05861f4f5eaac4042b87fd36d27acf0ad848b Mon Sep 17 00:00:00 2001 From: Koukyosyumei Date: Thu, 8 Oct 2026 19:00:38 -0400 Subject: [PATCH 09/12] Improve workspace readability and function navigation --- crates/e5r-cli/src/dashboard/app.js | 140 +++++- crates/e5r-cli/src/dashboard/index.html | 25 +- crates/e5r-cli/src/dashboard/projects.html | 2 +- crates/e5r-cli/src/dashboard/projects.js | 3 +- crates/e5r-cli/src/dashboard/style.css | 473 +++++++-------------- docs/dashboard.md | 48 ++- scripts/test-dashboard.mjs | 34 +- 7 files changed, 363 insertions(+), 362 deletions(-) diff --git a/crates/e5r-cli/src/dashboard/app.js b/crates/e5r-cli/src/dashboard/app.js index 8c578d9..f3ebca6 100644 --- a/crates/e5r-cli/src/dashboard/app.js +++ b/crates/e5r-cli/src/dashboard/app.js @@ -13,6 +13,7 @@ const state = { recordStamp: "", request: 0, history: [], + future: [], code: "", boardStamp: "", functionLimit: 400, @@ -146,11 +147,12 @@ function renderBoard() { [active.length, "active", ""], [ready.length, "ready", ""], ]) { - const e = el("div", undefined, `count ${tone}`); + 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")); @@ -362,17 +364,31 @@ function renderFunctions() { ); } function selectFunction(f, push = true) { - if (push && state.selected && state.selected.addr !== f.addr) + 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.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(); $("variables-section").hidden = true; renderRelated(); @@ -444,6 +460,8 @@ function syntaxLine(line, known) { 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], @@ -513,22 +531,30 @@ function renderReferences(data) { function renderCallGraph(data) { clear($("code")); const graph = el("div", undefined, "call-graph"); - for (const edge of data.edges) { - for (const [i, addr] of [edge.from, edge.to].entries()) { - if (i) graph.append(el("span", "→", "call-arrow")); - const f = state.functions.find(f => f.addr === addr); - const node = el(f ? "button" : "span", f ? `${f.name}\n${addr}` : addr, "call-node"); - if (f) node.onclick = () => selectFunction(f); - graph.append(node); + 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); - if (!data.edges.length) message($("code"), "No recovered direct calls.", "Indirect targets are not resolved by this 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; @@ -539,7 +565,7 @@ async function loadPane() { ); $("quality").className = "quality"; $("quality").textContent = - `Loading ${pane === "decompile" ? "pseudocode" : pane === "disas" ? "disassembly" : "references"}…`; + `Loading ${pane === "decompile" ? "pseudocode" : pane === "disas" ? "disassembly" : pane === "callgraph" ? "call graph" : "references"}…`; message( $("code"), "Reading the function…", @@ -641,23 +667,95 @@ $("wrap-code").onchange = () => { $("wrap-code").checked = localStorage.getItem("e5r.wrap") !== "false"; $("code").classList.toggle("wrap", $("wrap-code").checked); $("copy-code").onclick = () => copy(state.code, $("copy-code")); -$("back").onclick = () => { - const addr = state.history.pop(), - f = state.functions.find((f) => f.addr === addr); - if (f) selectFunction(f, false); +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 ( - e.key === "/" && - !$("workspace").hidden && - !["INPUT", "TEXTAREA", "SELECT"].includes(document.activeElement.tagName) && - !$("task-dialog").open - ) { + 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); +} +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"); diff --git a/crates/e5r-cli/src/dashboard/index.html b/crates/e5r-cli/src/dashboard/index.html index 658f869..5aab662 100644 --- a/crates/e5r-cli/src/dashboard/index.html +++ b/crates/e5r-cli/src/dashboard/index.html @@ -18,9 +18,9 @@
Loading project…
@@ -120,7 +120,19 @@

Project work

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

Functions

@@ -156,6 +168,7 @@

Explore the binary

aria-label="Previous function" > ← Back @@ -199,14 +212,14 @@

Explore the binary

>
{ } -

Follow the code. Keep the context.

+

Select a function to begin.

- Select a function to see its pseudocode and evidence.
Ask an agent to record the next investigation. + Search by name or address on the left.
Follow calls in the code; use Back to retrace your steps.

-