diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bac65ec --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,63 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # PUC Lua, not LuaJIT: luarocks fails to load the (large) luarocks.org + # manifest under LuaJIT with "main function has more than 65536 constants" + - uses: leafo/gh-actions-lua@v10 + with: + luaVersion: "5.4" + - uses: leafo/gh-actions-luarocks@v4 + + - name: Install luacheck + run: luarocks install luacheck + + - name: luacheck + run: luacheck lua/ ftplugin/ tests/ + + # informational only: stylua reformats the manual column alignment in the + # config tables, so a failure here is not treated as a build break + - name: stylua (informational) + uses: JohnnyMorganz/stylua-action@v4 + continue-on-error: true + with: + token: ${{ secrets.GITHUB_TOKEN }} + version: latest + args: --check lua/ ftplugin/ tests/ + + test: + name: Test (nvim ${{ matrix.nvim }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + nvim: [stable, nightly] + steps: + - uses: actions/checkout@v4 + + - name: Install Neovim + uses: rhysd/action-setup-vim@v1 + with: + neovim: true + version: ${{ matrix.nvim }} + + - name: Install binutils (readelf, size, strings) + run: sudo apt-get update && sudo apt-get install -y binutils + + - name: Fetch plenary.nvim + run: git clone --depth 1 https://github.com/nvim-lua/plenary.nvim .tests/plenary.nvim + + - name: Run tests + run: | + nvim --headless -u tests/minimal_init.lua \ + -c "PlenaryBustedDirectory tests/ {minimal_init = 'tests/minimal_init.lua'}" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c951535 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +# local test dependencies (plenary.nvim is cloned here by CI / contributors) +.tests/ + +# the vhs script used to record demo.gif is a local tool, not part of the repo +demo.tape diff --git a/.luacheckrc b/.luacheckrc new file mode 100644 index 0000000..fe73db7 --- /dev/null +++ b/.luacheckrc @@ -0,0 +1,17 @@ +-- luacheck configuration for bin.nvim +std = "luajit" +cache = true + +-- Neovim's global; everything hangs off it +globals = { "vim" } + +-- the test files use plenary's busted-style globals +files["tests/"] = { + globals = { "describe", "it", "before_each", "after_each", "pending", "assert" }, +} + +-- generated / vendored +exclude_files = { ".tests/" } + +-- the rendered output and long readelf command lines run wide on purpose +max_line_length = false diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..cbcf502 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,24 @@ +# Changelog + +All notable changes to this project are documented here. The format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project aims to +follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.0] - 2026-06-08 + +First public release. + +### Added + +- Open any ELF binary as a structured, collapsible inspection buffer built from + `readelf` and a handful of optional tools (`file`, `size`, `strings`, `ldd`, + `checksec`, `c++filt`, `rustfilt`). +- Sections load in parallel; a missing optional tool leaves a short notice in + place rather than failing the whole view. +- `:Bin` and `:Bin /path/to/binary`, alongside `require("bin").open()`. +- Folding (`zM` / `zR` / `za`) and `]]` / `[[` section navigation. +- Buffer-local syntax highlighting under a private `binview` filetype. +- `:checkhealth bin`, reporting the Neovim version floor and which tools are + present. + +[1.0.0]: https://github.com/a-mango/bin.nvim/releases/tag/v1.0.0 diff --git a/README.md b/README.md index a2e2db4..b449dde 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,13 @@ When you open an ELF file, the buffer is populated with the output of several analysis tools organised into collapsible sections. All sections are loaded in parallel; missing optional tools display a short notice rather than an error. +## Demo + +![bin.nvim opening /usr/bin/ls](demo.gif) + ## Requirements -- Neovim >= 0.9 +- Neovim >= 0.10 (for `vim.system`) - `readelf` — required (part of binutils) - `file`, `size`, `strings`, `ldd` — optional (binutils / glibc) - `checksec` — optional @@ -42,6 +46,9 @@ use { } ``` +Your plugin manager generates the help tags, so `:help bin.nvim` works once it +is installed. + ## Configuration `setup()` accepts an options table. All keys are optional and fall back to the @@ -80,7 +87,9 @@ These keymaps are active only inside ELF inspection buffers. ## Output -Opening any ELF file (e.g. `nvim /usr/bin/ls`) replaces the buffer with: +Opening any ELF file (e.g. `nvim /usr/bin/ls`) replaces the buffer with the +inspection view. You can also inspect a file at any time with `:Bin` (current +file) or `:Bin /path/to/binary`. The buffer then looks like: ``` bin.nvim — /usr/bin/ls @@ -170,6 +179,14 @@ Press `zM` to collapse all sections to their titles for a quick overview: Use `]]` / `[[` to move between sections, and `za` to expand the one you need. +## Limitations + +Detection happens on `BufReadPost`, so Neovim reads the whole file into a buffer +before the inspection view replaces it. On a large or stripped binary that costs +some memory and a brief pause before the summary shows. Intercepting earlier +would avoid the read but blanks ordinary non-ELF buffers, so the current +behaviour is a deliberate trade. + ## License MIT diff --git a/demo.gif b/demo.gif new file mode 100644 index 0000000..b805a48 Binary files /dev/null and b/demo.gif differ diff --git a/doc/bin.txt b/doc/bin.txt index 5162961..460d81e 100644 --- a/doc/bin.txt +++ b/doc/bin.txt @@ -2,29 +2,60 @@ USAGE *bin-usage* -bin.nvim intercepts ELF files via |BufReadCmd| and replaces the binary -buffer with a human-readable summary produced by readelf(1). - -Keymaps in the info buffer: - q close buffer - r reload / refresh +bin.nvim watches |BufReadPost| and, when the file it just read is an ELF +binary, replaces it with a readable summary built from readelf(1) and a +handful of optional tools. Each section loads in parallel; a missing +optional tool leaves a short notice in place of an error. + +Requires Neovim 0.10 or newer (it uses |vim.system()|). + +Keymaps inside the inspection buffer: + q close the buffer + r reload and refresh + ]] jump to the next section + [[ jump to the previous section + zM collapse every section to its title + zR expand every section + za toggle the section under the cursor CONFIGURATION *bin-config* +setup() takes an options table; every key is optional and falls back to the +default shown here. + >lua require("bin").setup({ - sections = { "header", "sections", "segments", "symbols", "dynamic" }, - max_symbols = 200, + sections = { + "file", "header", "checksec", "size", + "sections", "segments", "dynamic", + "notes", "versions", "relocations", + "symbols", "strings", "ldd", "dwarf", + }, + max_symbols = 200, + max_strings = 300, + min_string_len = 10, + max_dwarf = 300, + demangle = true, }) < COMMANDS *bin-commands* - :lua require("bin").open() inspect current file + :Bin inspect the current file + :Bin /path/to/binary inspect a specific file + :lua require("bin").open() inspect the current file :lua require("bin").open("/path") inspect a specific file HEALTH *bin-health* :checkhealth bin +LIMITATIONS *bin-limitations* + +Detection runs on |BufReadPost|, which means Neovim has already read the whole +file into a buffer by the time the inspection view replaces it. For a large or +stripped binary that read costs memory and a moment of lag before the summary +appears. Reading nothing would mean intercepting before the load, but that path +blanks ordinary non-ELF buffers, so the trade here is deliberate. + vim:tw=78:ft=help:norl: diff --git a/ftplugin/binview.lua b/ftplugin/binview.lua new file mode 100644 index 0000000..f107e6c --- /dev/null +++ b/ftplugin/binview.lua @@ -0,0 +1,20 @@ +vim.wo.wrap = false +vim.wo.number = false +vim.wo.relativenumber = false +vim.wo.signcolumn = "no" +vim.wo.cursorline = true + +-- folding: section chrome (┌│└, blank, header) stays visible at all levels; +-- content lines fold at level 1. Use zM to collapse all, zR to open all. +vim.wo.foldmethod = "expr" +vim.wo.foldexpr = "v:lua.require('bin').foldexpr(v:lnum)" +vim.wo.foldtext = "v:lua.require('bin').foldtext()" +vim.wo.foldlevel = 99 +vim.wo.foldminlines = 1 + +-- section navigation +local o = { noremap = true, silent = true, buffer = true } +vim.keymap.set("n", "]]", function() vim.fn.search("^┌", "W") end, o) +vim.keymap.set("n", "[[", function() vim.fn.search("^┌", "bW") end, o) + +-- syntax highlighting lives in syntax/binview.vim (buffer-local, no accumulation) diff --git a/ftplugin/elf.lua b/ftplugin/elf.lua deleted file mode 100644 index 08f63a7..0000000 --- a/ftplugin/elf.lua +++ /dev/null @@ -1,63 +0,0 @@ -vim.wo.wrap = false -vim.wo.number = false -vim.wo.relativenumber = false -vim.wo.signcolumn = "no" -vim.wo.cursorline = true - --- folding: section chrome (┌│└, blank, header) stays visible at all levels; --- content lines fold at level 1. Use zM to collapse all, zR to open all. -vim.wo.foldmethod = "expr" -vim.wo.foldexpr = "v:lua.require('bin').foldexpr(v:lnum)" -vim.wo.foldtext = "v:lua.require('bin').foldtext()" -vim.wo.foldlevel = 99 -vim.wo.foldminlines = 1 - --- section navigation -local o = { noremap = true, silent = true, buffer = true } -vim.keymap.set("n", "]]", function() vim.fn.search("^┌", "W") end, o) -vim.keymap.set("n", "[[", function() vim.fn.search("^┌", "bW") end, o) - --- syntax highlighting via matchadd -local function hi(group, pattern) - vim.fn.matchadd(group, pattern) -end - --- section chrome -hi("Title", "^[┌└│].*") -hi("Comment", "^ bin\\.nvim.*") -hi("Special", "^ .*═.*") - --- checksec labels -hi("Identifier", "^ \\s*[A-Z][A-Za-z ]*:") - --- addresses and numbers -hi("Number", "0x[0-9a-fA-F]\\+") -hi("Number", "\\<\\d\\+\\>") - --- section types -hi("Type", - "\\<\\(PROGBITS\\|NOBITS\\|DYNAMIC\\|SYMTAB\\|STRTAB\\|RELA\\|REL\\|NOTE\\|GNU_HASH\\|GNU_RELRO\\|DYNSYM\\|NULL\\)\\>") - --- segment types -hi("Keyword", - "\\<\\(LOAD\\|INTERP\\|PHDR\\|TLS\\|GNU_STACK\\|GNU_EH_FRAME\\|DYNAMIC\\|NOTE\\)\\>") - --- brackets (section names in output) -hi("String", "\\[.*\\]") - --- symbol attributes -hi("Function", "\\") -hi("Type", "\\") -hi("Constant", "\\<\\(GLOBAL\\|LOCAL\\|WEAK\\)\\>") - --- security property values -hi("DiagnosticOk", "\\<\\(Full RELRO\\|Canary found\\|NX enabled\\|PIE enabled\\)\\>") -hi("DiagnosticWarn", "\\<\\(Partial RELRO\\|No canary\\|NX disabled\\|No PIE\\|Partial\\)\\>") -hi("DiagnosticError", "\\<\\(No RELRO\\|NX disabled\\)\\>") - --- status / info lines -hi("WarningMsg", "(\\.\\.\\. (first.*)") -hi("ErrorMsg", "\\[error\\].*") -hi("Comment", " (.*not installed.*)") -hi("Comment", " (no .*)") -hi("Comment", " (static binary.*)") diff --git a/lua/bin/health.lua b/lua/bin/health.lua index 0abb71d..64791aa 100644 --- a/lua/bin/health.lua +++ b/lua/bin/health.lua @@ -3,10 +3,10 @@ local M = {} function M.check() vim.health.start("bin.nvim") - if vim.fn.has("nvim-0.9") == 1 then - vim.health.ok("Neovim >= 0.9") + if vim.fn.has("nvim-0.10") == 1 then + vim.health.ok("Neovim >= 0.10") else - vim.health.error("Neovim >= 0.9 required (vim.system API)") + vim.health.error("Neovim >= 0.10 required (vim.system API)") end local function check(name, pkg, required) diff --git a/lua/bin/init.lua b/lua/bin/init.lua index 4025ce4..7b34029 100644 --- a/lua/bin/init.lua +++ b/lua/bin/init.lua @@ -14,9 +14,10 @@ M.config = { demangle = true, } --- incremented on every M.open() call; async callbacks check against it to --- discard results from a superseded reload -local current_gen = 0 +-- per-buffer generation, bumped on every M.open() for that buffer; async +-- callbacks check against it to discard results from a superseded reload. +-- Keyed by buffer so opening a second binary can't strand the first. +local gens = {} -- ── helpers ─────────────────────────────────────────────────────────────────── @@ -48,12 +49,21 @@ local function missing(tool) return { " (" .. tool .. " not installed — skipping)" } end --- caps a list of plain strings; returns a new list with a trailing note when truncated -local function cap(raw_lines, max, label) - if #raw_lines <= max then return raw_lines end +-- splits `str` into lines but stops once `max` is reached, so a huge dump (the +-- multi-million-line DWARF of a debug build, say) never materialises in full +-- just to show a few hundred lines. The config cap thus bounds the work, not +-- only the display. Adds a trailing note when the output was cut. +local function cap_lines(str, max, label) local t = {} - for i = 1, max do t[i] = raw_lines[i] end - t[#t + 1] = string.format("... (first %d/%d %s — adjust config to see more)", max, #raw_lines, label) + local n = 0 + for line in (str or ""):gmatch("[^\n]+") do + n = n + 1 + if n > max then + t[#t + 1] = string.format("... (first %d %s shown — raise the limit in config to see more)", max, label) + break + end + t[n] = line + end return t end @@ -127,24 +137,28 @@ runners.header = function(path, _, _, cb) end) end +-- format checksec's CSV row into aligned label/value lines. Splits on commas +-- while keeping empty fields, so a blank value can't shift the remaining +-- columns onto the wrong labels. +local function parse_checksec(raw) + local fields = vim.split((raw:gsub("\n$", "")), ",", { plain = true }) + local labels = { + "RELRO", "Stack Canary", "NX", "PIE", + "RPATH", "RUNPATH", "Symbols", "FORTIFY", + "Fortified", "Fortifiable", + } + local result = {} + for i, label in ipairs(labels) do + result[#result + 1] = string.format(" %-16s %s", label .. ":", fields[i] or "?") + end + return result +end + runners.checksec = function(path, _, exe, cb) if not exe.checksec then cb(missing("checksec")); return end vim.system({ "checksec", "--file=" .. path, "--output=csv" }, { text = true }, function(r) if r.code ~= 0 then cb({ " [error] " .. r.stderr }); return end - local fields = {} - for f in (r.stdout:gsub("\n$", "")):gmatch("[^,]+") do - fields[#fields + 1] = f - end - local labels = { - "RELRO", "Stack Canary", "NX", "PIE", - "RPATH", "RUNPATH", "Symbols", "FORTIFY", - "Fortified", "Fortifiable", - } - local result = {} - for i, label in ipairs(labels) do - result[#result + 1] = string.format(" %-16s %s", label .. ":", fields[i] or "?") - end - cb(result) + cb(parse_checksec(r.stdout)) end) end @@ -213,7 +227,7 @@ runners.symbols = function(path, cfg, exe, cb) vim.system({ "readelf", "-s", "--wide", path }, { text = true }, function(r) if r.code ~= 0 then cb({ " [error] " .. r.stderr }); return end demangle(r.stdout, exe, cfg, function(out) - local capped = cap(lines(out), cfg.max_symbols, "lines") + local capped = cap_lines(out, cfg.max_symbols, "lines") local result = {} for _, line in ipairs(capped) do result[#result + 1] = " " .. line @@ -227,7 +241,7 @@ runners.strings = function(path, cfg, exe, cb) if not exe.strings then cb(missing("strings")); return end vim.system({ "strings", "-n", tostring(cfg.min_string_len), path }, { text = true }, function(r) if r.code ~= 0 then cb({ " [error] " .. r.stderr }); return end - local capped = cap(lines(r.stdout), cfg.max_strings, "strings") + local capped = cap_lines(r.stdout, cfg.max_strings, "strings") local result = {} for _, line in ipairs(capped) do result[#result + 1] = " " .. line @@ -255,7 +269,7 @@ runners.dwarf = function(path, cfg, _, cb) if out == "" then cb({ " (no debug symbols — compile with -g)" }); return end - local capped = cap(lines(out), cfg.max_dwarf, "lines") + local capped = cap_lines(out, cfg.max_dwarf, "lines") local result = {} for _, line in ipairs(capped) do result[#result + 1] = " " .. line @@ -281,7 +295,7 @@ local function render(buf, path, section_order, section_data, cfg) vim.api.nvim_buf_set_lines(buf, 0, -1, false, content) vim.bo[buf].modifiable = false vim.bo[buf].buftype = "nofile" - vim.bo[buf].filetype = "elf" + vim.bo[buf].filetype = "binview" vim.bo[buf].swapfile = false end @@ -296,7 +310,7 @@ local function show_loading(buf, path) vim.api.nvim_buf_set_lines(buf, 0, -1, false, content) vim.bo[buf].modifiable = false vim.bo[buf].buftype = "nofile" - vim.bo[buf].filetype = "elf" + vim.bo[buf].filetype = "binview" vim.bo[buf].swapfile = false end @@ -344,8 +358,10 @@ function M.open(path) return end - current_gen = current_gen + 1 - local gen = current_gen + -- whatever buffer we were called from; if the file was read into a normal + -- buffer (BufReadPost, or :edit), we wipe it once the preview is up so it + -- doesn't linger behind us and resurface when the preview is closed + local origin = vim.api.nvim_get_current_buf() local bufname = "elf://" .. path local buf = vim.fn.bufnr(bufname, false) @@ -354,10 +370,24 @@ function M.open(path) vim.api.nvim_buf_set_name(buf, bufname) end + gens[buf] = (gens[buf] or 0) + 1 + local gen = gens[buf] + show_loading(buf, path) set_keymaps(buf, path) vim.api.nvim_set_current_buf(buf) + vim.schedule(function() + if origin ~= buf + and vim.api.nvim_buf_is_valid(origin) + and vim.bo[origin].buftype == "" + and vim.fn.fnamemodify(vim.api.nvim_buf_get_name(origin), ":p") + == vim.fn.fnamemodify(path, ":p") + then + vim.api.nvim_buf_delete(origin, { force = true }) + end + end) + -- snapshot config and check executables on the main thread before going async local cfg = M.config local exe = { @@ -374,13 +404,20 @@ function M.open(path) local section_data = {} local remaining = #sections + -- nothing configured: render the title bar now, rather than waiting forever + -- on a countdown that starts already at zero + if remaining == 0 then + render(buf, path, sections, section_data, cfg) + return + end + local function on_done(sec, data) - if gen ~= current_gen then return end -- superseded by a newer reload + if gen ~= gens[buf] then return end -- superseded by a newer reload section_data[sec] = data remaining = remaining - 1 if remaining == 0 then vim.schedule(function() - if gen ~= current_gen then return end + if gen ~= gens[buf] then return end render(buf, path, sections, section_data, cfg) end) end @@ -389,7 +426,13 @@ function M.open(path) for _, sec in ipairs(sections) do local runner = runners[sec] if runner then - runner(path, cfg, exe, function(data) on_done(sec, data) end) + -- vim.system throws synchronously when the tool is absent; catch it so a + -- missing dependency degrades to an error section instead of aborting the + -- whole open (and leaving the buffer stuck on "Loading...") + local ok, err = pcall(runner, path, cfg, exe, function(data) on_done(sec, data) end) + if not ok then + on_done(sec, { " [error] " .. tostring(err) }) + end else on_done(sec, { " (unknown section: " .. sec .. ")" }) end @@ -399,6 +442,10 @@ end function M.setup(opts) M.config = vim.tbl_deep_extend("force", M.config, opts or {}) + vim.api.nvim_create_user_command("Bin", function(o) + M.open(o.args ~= "" and o.args or nil) + end, { nargs = "?", complete = "file", desc = "Inspect an ELF binary" }) + vim.api.nvim_create_autocmd("BufReadPost", { group = vim.api.nvim_create_augroup("bin_nvim", { clear = true }), callback = function(ev) @@ -410,4 +457,10 @@ function M.setup(opts) }) end +-- internals exposed for the test suite; not part of the public API +M._test = { + is_elf = is_elf, + parse_checksec = parse_checksec, +} + return M diff --git a/stylua.toml b/stylua.toml new file mode 100644 index 0000000..d2adbab --- /dev/null +++ b/stylua.toml @@ -0,0 +1,6 @@ +column_width = 100 +line_endings = "Unix" +indent_type = "Spaces" +indent_width = 2 +quote_style = "AutoPreferDouble" +call_parentheses = "Always" diff --git a/syntax/binview.vim b/syntax/binview.vim new file mode 100644 index 0000000..44b622b --- /dev/null +++ b/syntax/binview.vim @@ -0,0 +1,64 @@ +" syntax/binview.vim — highlighting for bin.nvim ELF inspection buffers. +" Buffer-local, so (unlike matchadd) it neither accumulates on reload nor +" bleeds onto other files shown in the same window. + +if exists("b:current_syntax") + finish +endif + +" section chrome and headings +syntax match elfChrome "^[┌└│].*" +syntax match elfTitle "^ bin\.nvim.*" +syntax match elfRule "^ .*═.*" + +" checksec-style labels: " RELRO:" etc. +syntax match elfLabel "^ \s*[A-Z][A-Za-z ]*:" + +" addresses and bare numbers +syntax match elfHex "0x[0-9a-fA-F]\+" +syntax match elfNum "\<\d\+\>" + +" section names in brackets +syntax match elfBracket "\[.*\]" + +" section and segment types +syntax keyword elfSecType PROGBITS NOBITS SYMTAB STRTAB RELA REL GNU_HASH GNU_RELRO DYNSYM NULL +syntax keyword elfSegType LOAD INTERP PHDR TLS GNU_STACK GNU_EH_FRAME DYNAMIC NOTE + +" symbol attributes +syntax keyword elfFunc FUNC +syntax keyword elfObject OBJECT +syntax keyword elfBind GLOBAL LOCAL WEAK + +" security property values (most severe defined last so it wins on overlap) +syntax match elfSecOk "\<\(Full RELRO\|Canary found\|NX enabled\|PIE enabled\)\>" +syntax match elfSecWarn "\<\(Partial RELRO\|No canary\|No PIE\|Partial\)\>" +syntax match elfSecErr "\<\(No RELRO\|NX disabled\)\>" + +" status and info lines +syntax match elfTrunc "(\.\.\. (first.*)" +syntax match elfError "\[error\].*" +syntax match elfNote " (.*not installed.*)" +syntax match elfNote " (no .*)" +syntax match elfNote " (static binary.*)" + +highlight default link elfChrome Title +highlight default link elfTitle Comment +highlight default link elfRule Special +highlight default link elfLabel Identifier +highlight default link elfHex Number +highlight default link elfNum Number +highlight default link elfBracket String +highlight default link elfSecType Type +highlight default link elfSegType Keyword +highlight default link elfFunc Function +highlight default link elfObject Type +highlight default link elfBind Constant +highlight default link elfSecOk DiagnosticOk +highlight default link elfSecWarn DiagnosticWarn +highlight default link elfSecErr DiagnosticError +highlight default link elfTrunc WarningMsg +highlight default link elfError ErrorMsg +highlight default link elfNote Comment + +let b:current_syntax = "binview" diff --git a/tests/bin_spec.lua b/tests/bin_spec.lua new file mode 100644 index 0000000..422dd4e --- /dev/null +++ b/tests/bin_spec.lua @@ -0,0 +1,146 @@ +local bin = require("bin") + +-- the default section list, captured before any test mutates config via setup() +local default_sections = vim.deepcopy(bin.config.sections) + +-- a buffer has finished rendering once its title line is in place and the +-- "Loading..." placeholder is gone +local function rendered(buf) + if buf == -1 or not vim.api.nvim_buf_is_valid(buf) then return false end + local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + if #lines < 2 or not lines[1]:match("bin%.nvim") then return false end + for _, l in ipairs(lines) do + if l:match("Loading") then return false end + end + return true +end + +local function open_and_wait(path, timeout) + bin.open(path) + local buf = vim.fn.bufnr("elf://" .. path) + local ok = vim.wait(timeout or 15000, function() return rendered(buf) end, 50) + return buf, ok +end + +-- vim.fn.bufnr does substring matching, which would match "elf://" when +-- asked for ""; compare full names instead +local function buf_by_exact_name(name) + for _, b in ipairs(vim.api.nvim_list_bufs()) do + if vim.api.nvim_buf_get_name(b) == name then return b end + end + return -1 +end + +describe("is_elf", function() + it("recognises an ELF binary", function() + assert.is_true(bin._test.is_elf(vim.v.progpath)) + end) + + it("rejects a text file", function() + local tmp = vim.fn.tempname() + vim.fn.writefile({ "not an elf" }, tmp) + assert.is_false(bin._test.is_elf(tmp)) + os.remove(tmp) + end) + + it("rejects a path that does not exist", function() + assert.is_false(bin._test.is_elf("/no/such/file/xyz")) + end) +end) + +describe("parse_checksec", function() + -- checksec --output=csv puts the filename last, so fields 1..10 are the props + local full = "Full RELRO,Canary found,NX enabled,PIE enabled,No RPATH,No RUNPATH,No Symbols,Partial,11,19,/bin/ls" + + it("maps a full row onto the right labels", function() + local out = bin._test.parse_checksec(full) + assert.equals(" RELRO: Full RELRO", out[1]) + assert.equals(" PIE: PIE enabled", out[4]) + assert.equals(" Fortifiable: 19", out[10]) + end) + + it("keeps columns aligned when a field is empty", function() + local blank = ",Canary found,NX enabled,PIE enabled,No RPATH,No RUNPATH,No Symbols,Partial,11,19,/bin/ls" + local out = bin._test.parse_checksec(blank) + assert.is_truthy(out[1]:match("^ RELRO:%s+$")) -- empty value, no shift + assert.equals(" Stack Canary: Canary found", out[2]) + assert.equals(" PIE: PIE enabled", out[4]) + end) +end) + +describe("open", function() + before_each(function() + bin.setup({ sections = vim.deepcopy(default_sections) }) + end) + + it("renders an ELF into a binview buffer", function() + local buf, ok = open_and_wait(vim.v.progpath) + assert.is_true(ok, "buffer did not render in time") + assert.equals("binview", vim.bo[buf].filetype) + local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + assert.is_truthy(lines[1]:match("bin%.nvim")) + local has_header = false + for _, l in ipairs(lines) do + if l:match("^┌") then has_header = true; break end + end + assert.is_true(has_header, "no section header rendered") + end) + + it("warns and creates no buffer for a non-ELF file", function() + local tmp = vim.fn.tempname() + vim.fn.writefile({ "plain text" }, tmp) + bin.open(tmp) + assert.equals(-1, vim.fn.bufnr("elf://" .. tmp)) + os.remove(tmp) + end) + + it("does not hang when sections is empty", function() + bin.setup({ sections = {} }) + local buf, ok = open_and_wait(vim.v.progpath, 3000) + assert.is_true(ok, "empty-sections open stayed on Loading") + local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + assert.is_truthy(lines[1]:match("bin%.nvim")) + end) + + it("renders two binaries without stranding the first", function() + local p2 = vim.fn.exepath("readelf") + if p2 == "" then + pending("readelf not available") + return + end + -- light sections so this isolates the generation counter, not DWARF speed. + -- Under the old global counter, opening p2 superseded p1's generation and + -- left p1 stuck on Loading forever; per-buffer counters let both finish. + bin.setup({ sections = { "header", "file" } }) + local p1 = vim.v.progpath + bin.open(p1) + bin.open(p2) + local b1 = vim.fn.bufnr("elf://" .. p1) + local b2 = vim.fn.bufnr("elf://" .. p2) + local ok = vim.wait(10000, function() + return rendered(b1) and rendered(b2) + end, 25) + assert.is_true(ok, "one binary stayed on Loading (stranded)") + end) + + it("intercepts a real file read and drops the origin buffer", function() + -- the actual `nvim ` path: the BufReadPost autocmd registered by + -- setup() fires, M.open swaps in the inspection buffer, and the raw binary + -- buffer is wiped so `q` doesn't dump the user back onto it + bin.setup({ sections = { "header", "file" } }) + local target = vim.fn.exepath("readelf") + if target == "" then + pending("readelf not available") + return + end + vim.cmd.edit(target) + local elfname = "elf://" .. target + -- cleanup is scheduled right after the buffer swap, independent of render + local ok = vim.wait(5000, function() + local cur = vim.api.nvim_get_current_buf() + return vim.api.nvim_buf_get_name(cur) == elfname + and buf_by_exact_name(target) == -1 + end, 25) + assert.is_true(ok, "did not swap to the inspection buffer and remove the origin") + end) +end) diff --git a/tests/minimal_init.lua b/tests/minimal_init.lua new file mode 100644 index 0000000..2f421fa --- /dev/null +++ b/tests/minimal_init.lua @@ -0,0 +1,16 @@ +-- Minimal init for running the suite under `nvim --headless`. +-- plenary.nvim is located via $PLENARY_DIR, falling back to a local checkout +-- under .tests/ (see CONTRIBUTING / the CI workflow). + +local plugin_root = vim.fn.getcwd() +local plenary_dir = vim.env.PLENARY_DIR or (plugin_root .. "/.tests/plenary.nvim") + +vim.opt.runtimepath:prepend(plugin_root) +vim.opt.runtimepath:prepend(plenary_dir) + +vim.cmd("runtime plugin/plenary.vim") + +-- `nvim -u ` does not load ftplugins or syntax; the binview filetype +-- needs both for its ftplugin and syntax files to attach. +vim.cmd("filetype plugin on") +vim.cmd("syntax on")