Initial commit: kmod-tui v1 — kernel module inspector TUI

Features:
- Browse all loaded kernel modules from /proc/modules
- Two-pane layout: scrollable list + detail pane
- Full-screen detail view with modinfo, parameters, holders
- Vim-style navigation (j/k, Enter, /, s, r, ?, q)
- Edge-scrolling (cursor moves freely, viewport follows)
- Filter by module name AND dependents (used_by)
- Sort by name, size, refcnt, or dependents count
- Lazy modinfo fetching (shells out only on selection)
- 13 unit tests (parser, filter, scroll logic)

Tech: Rust + ratatui + crossterm
This commit is contained in:
Hermes Agent
2026-09-07 17:35:00 +00:00
commit 00ba8781f2
12 changed files with 2209 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
/target
*.rs.bk
*.o
*.so
*.dylib
Generated
+599
View File
@@ -0,0 +1,599 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "allocator-api2"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "bitflags"
version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
[[package]]
name = "cassowary"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
[[package]]
name = "castaway"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
dependencies = [
"rustversion",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "compact_str"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e"
dependencies = [
"castaway",
"cfg-if",
"itoa",
"rustversion",
"ryu",
"static_assertions",
]
[[package]]
name = "crossterm"
version = "0.28.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
dependencies = [
"bitflags",
"crossterm_winapi",
"mio",
"parking_lot",
"rustix",
"signal-hook",
"signal-hook-mio",
"winapi",
]
[[package]]
name = "crossterm_winapi"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
dependencies = [
"winapi",
]
[[package]]
name = "darling"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec"
dependencies = [
"darling_core",
"darling_macro",
]
[[package]]
name = "darling_core"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff"
dependencies = [
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn 3.0.5",
]
[[package]]
name = "darling_macro"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785"
dependencies = [
"darling_core",
"quote",
"syn 3.0.5",
]
[[package]]
name = "either"
version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "indoc"
version = "2.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
dependencies = [
"rustversion",
]
[[package]]
name = "instability"
version = "0.3.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bf84e73fa6f27f299dec58e13223cf70db80da872eb921d4f6138342a0eabc8"
dependencies = [
"darling",
"indoc",
"proc-macro2",
"quote",
"syn 3.0.5",
]
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "kmod-tui"
version = "0.1.0"
dependencies = [
"crossterm",
"ratatui",
]
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "linux-raw-sys"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
[[package]]
name = "lock_api"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
"scopeguard",
]
[[package]]
name = "log"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "lru"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
dependencies = [
"hashbrown",
]
[[package]]
name = "mio"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
dependencies = [
"libc",
"log",
"wasi",
"windows-sys 0.61.2",
]
[[package]]
name = "parking_lot"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
dependencies = [
"lock_api",
"parking_lot_core",
]
[[package]]
name = "parking_lot_core"
version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"smallvec",
"windows-link",
]
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "ratatui"
version = "0.28.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdef7f9be5c0122f890d58bdf4d964349ba6a6161f705907526d891efabba57d"
dependencies = [
"bitflags",
"cassowary",
"compact_str",
"crossterm",
"instability",
"itertools",
"lru",
"paste",
"strum",
"strum_macros",
"unicode-segmentation",
"unicode-truncate",
"unicode-width",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags",
]
[[package]]
name = "rustix"
version = "0.38.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.59.0",
]
[[package]]
name = "rustversion"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "signal-hook"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
dependencies = [
"libc",
"signal-hook-registry",
]
[[package]]
name = "signal-hook-mio"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
dependencies = [
"libc",
"mio",
"signal-hook",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "smallvec"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f"
[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
dependencies = [
"heck",
"proc-macro2",
"quote",
"rustversion",
"syn 2.0.119",
]
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-segmentation"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "unicode-truncate"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
dependencies = [
"itertools",
"unicode-segmentation",
"unicode-width",
]
[[package]]
name = "unicode-width"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "kmod-tui"
version = "0.1.0"
edition = "2021"
description = "A TUI for browsing and inspecting loaded Linux kernel modules"
[[bin]]
name = "kmod-tui"
path = "src/main.rs"
[dependencies]
ratatui = "0.28"
crossterm = "0.28"
[profile.release]
opt-level = 2
lto = "thin"
+95
View File
@@ -0,0 +1,95 @@
# kmod-tui
A terminal UI for Linux sysadmins to browse and inspect loaded kernel modules.
Read-only — built for looking, not for changing module state.
## Why
`lsmod` shows a flat table. `modinfo <mod>` shows one module. This tool gives
you a navigable, filterable, sorted view of all loaded modules with rich
per-module detail (license, author, description, parameters, holders,
dependency info) a keystroke away.
## Data sources (read-only)
- `/proc/modules` — the loaded module list
- `/sys/module/<name>/` — refcnt, coresize, initsize, initstate, srcversion,
taint, `parameters/`, `holders/`
- `modinfo <name>` — filename, license, description, author, depends,
vermagic, retpoline, intree, signature info (fetched lazily, only when a
module is selected for detail, so the list view stays fast)
## Build & run
```sh
cargo run --release
# or
cargo build --release
./target/release/kmod-tui
```
Requires the Rust toolchain (rustup stable). No other deps; ratatui/crossterm
are vendored by cargo.
## Keys
List view:
```
j / ↓ move down
k / ↑ move up
g / Home top
G / End bottom
PgDn / PgUp scroll by 10
Enter open full-detail screen for the selected module
/ start filtering by module name (type, Esc to clear)
s cycle sort: name → size → refcnt → dependents
r refresh the module list from /proc/modules
? toggle help overlay
q quit
```
Full-detail view:
```
j / ↓ , k / ↑ move selection (stays in full-detail, reloads detail)
Esc / q back to list
? help
```
Filter mode (after `/`):
```
type append to filter
Backspace delete last char
Enter / Esc exit filter (Esc also clears)
```
## Layout
- Top bar: module count + kernel version + current sort/filter.
- Middle: scrollable module table (#, name, size, refcnt, used-by, state).
- Bottom: two-column detail pane for the highlighted module.
- Footer: key hints (context-sensitive).
## Architecture
```
src/
main.rs CLI bootstrap, event loop, key dispatch
app.rs App state: module list, cursor, filter, sort, view mode, lazy fetches
event.rs crossterm input polling
model.rs Module/ModInfo types, /proc + /sys collection, modinfo parsing, tests
ui/
mod.rs frame composition, status bar, footer, help overlay
list.rs module table
detail.rs detail pane + full-detail screen
```
## Tests
```sh
cargo test --release
```
Unit tests cover the /proc/modules `used-by` parser, the `modinfo` output
parser, and the human-readable size formatter.
## Non-goals (v1)
- Writing module parameters or loading/unloading modules — read-only by design.
- Showing all installable (not-yet-loaded) modules — planned for v1.1.
- Remote hosts — local only.
## Roadmap
- v1.1: dependency tree view, all-available-modules toggle, CSV/TSV export,
config file (`~/.config/kmod-tui/config.toml`).
- v2: optional inotify auto-refresh on `/proc/modules`.
+127
View File
@@ -0,0 +1,127 @@
# kmod-tui — Kernel Module Inspector (Rust TUI)
A terminal UI for Linux sysadmins to browse and inspect loaded kernel modules.
## Goal
Show all loaded kernel modules in a navigable, filterable list, with a detail
view for each module's metadata, dependencies, parameters, and holders.
## Data Sources (verified on this host, kernel 6.8.0-137-generic, 130 modules)
- `/proc/modules` — one line per loaded module:
`name size refcnt used-by state addr`
(e.g. `wireguard 114688 0 - Live 0xffffffffc125d000`)
- `/sys/module/<name>/` — per-module sysfs dir with:
`refcnt`, `initstate`, `coresize`, `initsize`, `version`, `srcversion`,
`taint`, `holders/` (modules depending on this one), `parameters/` (tunable params)
- `modinfo <name>` — richest metadata: filename, license, description, author,
depends, retpoline, intree, vermagic, signature info
## Tech Stack
- Rust (stable)
- `ratatui` — TUI rendering (modern maintained fork of tui-rs)
- `crossterm` — terminal backend (cross-platform, works everywhere)
- `clap` — CLI args (optional: --sort, --filter, --no-modinfo for speed)
- No shell-out to `modinfo`; parse `/proc/modules` + walk `/sys/module` directly.
(Avoids a process spawn per module; modinfo data like license/description/author
comes from the .ko file's modinfo section — we parse the relevant sysfs files
and, where sysfs lacks it, read the module file's modinfo section directly.
Decision: for v1, shell out to `modinfo` lazily only when a module is selected
for detail — keeps the list fast and detail rich. Revisit later.)
## UI Layout
```
┌─ kmod-tui — 130 modules loaded (kernel 6.8.0-137-generic) ──────────────┐
│ Filter: [_____________] Sort: name▼ Loaded only: on │
├────┬──────────────────┬────────┬──────┬────────────────────┬────────────┤
│ # │ Module │ Size │ Refc │ Used by │ State │
├────┼──────────────────┼────────┼──────┼────────────────────┼────────────┤
│ 1 │ cpuid │ 12288 │ 0 │ - │ Live │
│ 2 │ wireguard │ 114688 │ 0 │ - │ Live │
│ 3 │ curve25519_x86_64│ 36864 │ 1 │ wireguard │ Live │
│ .. │ .. │ .. │ .. │ .. │ .. │
├────┴──────────────────┴────────┴──────┴────────────────────┴────────────┤
│ Detail: wireguard │
│ Filename: /lib/modules/.../wireguard.ko.zst │
│ License: GPL Description: ... Author: ... │
│ Depends: (none) Holders: (none) Parameters: (none) │
│ Coresize: 114688 Initsize: 0 Refcnt: 0 Srcversion: ... │
└────────────────────────────────────────────────────────────────────────┘
↑/↓ move Enter expand / filter s sort q quit
```
Two-pane layout: scrollable module list (top ~70%), detail pane (bottom ~30%).
Selecting a row populates the detail pane. Enter opens a full-screen detail
view for the selected module with all sysfs fields, parameters, holders,
and a dependency tree.
## Features
### v1 (MVP — the "nice looking, human-navigable" baseline)
1. Module list from /proc/modules with columns: #, name, size (human-readable),
refcnt, used-by, state.
2. Detail pane for highlighted module: filename, license, description, author,
depends, holders, parameters (name + current value), coresize, initsize,
refcnt, srcversion, taint, vermagic.
3. Vim-style navigation: j/k or ↑/↓ to move, Enter for full detail, Esc/q back.
4. Live filter (`/` then type, fuzzy or substring match on name).
5. Sort cycle (`s`): name, size, refcnt, dependents-count.
6. Footer help bar.
7. Color: state (Live=green, Loading/Unloading=yellow), refcnt>0 (bold),
module-with-dependents highlighted.
8. Header shows module count, kernel version (from `uname -r`).
### v1.1 (polish)
- Dependency tree view (expand a module → see what it depends on and what
depends on it, recursively).
- Parameter inspection with current values read from /sys/module/<n>/parameters.
- Export current view to file (CSV/TSV).
- Config file (~/.config/kmod-tui/config.toml) for default sort, columns.
### v2 (nice-to-have, later)
- Show ALL installable modules (from /lib/modules/$(uname -r)/), not just
loaded — mark loaded vs available-not-loaded.
- Toggle between "loaded only" and "all available".
- modprobe/insmod/rmmod actions (guarded, with confirmation — but see note:
this is read-only for v1; mutating is a separate, deliberate decision).
- Module loading state watch (inotify on /proc/modules, auto-refresh).
## Architecture (v1)
```
src/
main.rs — CLI args, app bootstrap, event loop
app.rs — App state: module list, cursor, filter, sort mode, focus
model.rs — Module struct, data collection from /proc + /sys
ui/
mod.rs — frame composition, layout
list.rs — module list table rendering
detail.rs — detail pane + full detail view
help.rs — help bar / overlay
event.rs — crossterm input polling
config.rs — (v1.1) config file load
```
Data flow:
collect_modules() → Vec<Module> (once on startup; refresh on 'r')
App holds Vec<Module> + filter/sort/cursor state
ratatui draws from App state each frame
crossterm polls input; key handlers mutate App state; redraw on change
(or redraw on a fixed tick — simpler: event-driven redraw)
## Open Decisions (for Isaac)
1. Shelling out to `modinfo` for detail metadata (filename/license/desc/author)
vs parsing .ko files directly? Recommend: shell out lazily on selection —
simple and correct. We only parse /proc + /sys for the list.
2. Read-only for v1 (no rmmod/modprobe)? Recommend: YES, read-only v1.
3. Project location: /root/kmod-tui ? Recommend: yes.
4. Install Rust via rustup (recommended) or apt rustc package?
Recommend: rustup (stable toolchain, latest ratatui).
5. Naming: `kmod-tui` ok?
## Build & Run
cargo run --release
(or `cargo build --release` then ./target/release/kmod-tui)
## Non-goals (v1)
- Editing module parameters (write to /sys) — risky, out of scope.
- Loading/unloading modules — risky, out of scope.
- Remote hosts — local only (could add SSH later).
- Persistent state — none needed.
+305
View File
@@ -0,0 +1,305 @@
//! App state: the in-memory model the UI renders from.
use crate::model::{self, ModInfo, Module};
use std::sync::mpsc;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SortMode {
Name,
Size,
Refcnt,
Dependents,
}
impl SortMode {
pub fn next(self) -> Self {
match self {
SortMode::Name => SortMode::Size,
SortMode::Size => SortMode::Refcnt,
SortMode::Refcnt => SortMode::Dependents,
SortMode::Dependents => SortMode::Name,
}
}
pub fn label(self) -> &'static str {
match self {
SortMode::Name => "name",
SortMode::Size => "size",
SortMode::Refcnt => "refcnt",
SortMode::Dependents => "dependents",
}
}
}
/// Which screen is active.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum View {
List,
FullDetail,
}
pub struct App {
/// All loaded modules (unfiltered, unsorted master copy).
pub modules: Vec<Module>,
/// Indices into `modules` after filtering + sorting.
pub view: Vec<usize>,
pub cursor: usize,
pub filter: String,
pub filter_active: bool,
pub sort: SortMode,
pub view_mode: View,
pub kernel: String,
/// Pending lazy modinfo fetches.
pub pending: Vec<mpsc::Receiver<(String, Option<ModInfo>)>>,
/// Whether the help overlay is shown.
pub help: bool,
pub should_quit: bool,
/// Vertical scroll offset (index of the first visible row in the list).
pub scroll: usize,
/// Number of data rows visible in the list pane (set by the UI each draw).
pub viewport_rows: usize,
}
impl App {
pub fn new() -> Self {
let modules = model::collect_modules();
let kernel = model::kernel_release();
let mut app = App {
modules,
view: Vec::new(),
cursor: 0,
filter: String::new(),
filter_active: false,
sort: SortMode::Name,
view_mode: View::List,
kernel,
pending: Vec::new(),
help: false,
should_quit: false,
scroll: 0,
viewport_rows: 0,
};
app.rebuild_view();
app
}
/// Rebuild the filtered + sorted view from the master module list.
pub fn rebuild_view(&mut self) {
let f = self.filter.to_lowercase();
let mut idxs: Vec<usize> = self
.modules
.iter()
.enumerate()
.filter(|(_, m)| m.matches_filter(&f))
.map(|(i, _)| i)
.collect();
idxs.sort_by(|&a, &b| {
let ma = &self.modules[a];
let mb = &self.modules[b];
match self.sort {
SortMode::Name => ma.name.cmp(&mb.name),
SortMode::Size => mb.size.cmp(&ma.size),
SortMode::Refcnt => mb.refcnt.cmp(&ma.refcnt),
SortMode::Dependents => mb.used_by.len().cmp(&ma.used_by.len()),
}
});
self.view = idxs;
if self.cursor >= self.view.len() {
self.cursor = self.cursor.saturating_sub(1);
}
}
/// Index into `modules` of the module under the cursor, if any.
pub fn current_module(&self) -> Option<&Module> {
self.view
.get(self.cursor)
.and_then(|&i| self.modules.get(i))
}
/// Reserved for v1.1: mutating a selected module in place (e.g. on refresh).
#[allow(dead_code)]
pub fn current_module_mut(&mut self) -> Option<&mut Module> {
self.view
.get(self.cursor)
.and_then(|&i| self.modules.get_mut(i))
}
pub fn move_cursor(&mut self, delta: i32) {
if self.view.is_empty() {
return;
}
let n = self.view.len() as i32;
let mut c = self.cursor as i32 + delta;
if c < 0 {
c = 0;
}
if c >= n {
c = n - 1;
}
self.cursor = c as usize;
}
pub fn toggle_sort(&mut self) {
self.sort = self.sort.next();
self.rebuild_view();
self.cursor = 0;
self.scroll = 0;
}
/// Reserved for v1.1: set an explicit sort mode by key.
#[allow(dead_code)]
pub fn set_sort(&mut self, s: SortMode) {
self.sort = s;
self.rebuild_view();
self.cursor = 0;
self.scroll = 0;
}
pub fn enter_filter(&mut self) {
self.filter_active = true;
}
pub fn push_filter_char(&mut self, c: char) {
self.filter.push(c);
self.rebuild_view();
self.cursor = 0;
self.scroll = 0;
}
pub fn pop_filter_char(&mut self) {
self.filter.pop();
self.rebuild_view();
self.cursor = 0;
self.scroll = 0;
}
pub fn clear_filter(&mut self) {
self.filter.clear();
self.filter_active = false;
self.rebuild_view();
self.cursor = 0;
self.scroll = 0;
}
pub fn refresh(&mut self) {
self.modules = model::collect_modules();
self.rebuild_view();
}
/// Request modinfo for the module under the cursor (if not already loaded).
pub fn ensure_modinfo(&mut self) {
if let Some(m) = self.current_module() {
if m.modinfo.is_none() {
let name = m.name.clone();
let rx = model::spawn_modinfo_fetch(name);
self.pending.push(rx);
}
}
}
/// Drain any completed lazy modinfo fetches and merge them into the
/// matching module. Returns true if anything changed (needs redraw).
pub fn poll_pending(&mut self) -> bool {
let mut changed = false;
let mut i = 0;
while i < self.pending.len() {
match self.pending[i].try_recv() {
Ok((name, mi)) => {
if let Some(m) = self.modules.iter_mut().find(|m| m.name == name) {
m.modinfo = mi;
changed = true;
}
self.pending.remove(i);
}
Err(mpsc::TryRecvError::Empty) => {
i += 1;
}
Err(mpsc::TryRecvError::Disconnected) => {
self.pending.remove(i);
}
}
}
changed
}
/// Adjust the scroll offset so the cursor stays within the viewport.
/// Edge-scrolling: only scrolls when the cursor would leave the visible
/// area. Called by the UI after layout is known (viewport_rows is set).
pub fn adjust_scroll(&mut self) {
self.scroll = compute_scroll(self.cursor, self.scroll, self.viewport_rows, self.view.len());
}
}
/// Pure scroll-offset computation. Edge-scrolling: the cursor moves freely
/// within the viewport; the viewport only scrolls when the cursor would
/// leave it.
///
/// - `cursor` — index of the selected row
/// - `current_scroll`— the current scroll offset (top visible row index)
/// - `rows_visible` — how many data rows fit in the list pane
/// - `total` — total number of rows in the view
pub fn compute_scroll(cursor: usize, current_scroll: usize, rows_visible: usize, total: usize) -> usize {
if rows_visible == 0 || total <= rows_visible {
return 0;
}
let max_scroll = total - rows_visible;
let mut scroll = current_scroll;
if cursor < scroll {
// Cursor is above the viewport — scroll up to reveal it.
scroll = cursor;
} else if cursor >= scroll + rows_visible {
// Cursor is below the viewport — scroll down so it's the last row.
scroll = cursor - rows_visible + 1;
}
// cursor is within [scroll, scroll+rows_visible) — no change needed.
scroll.min(max_scroll)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scroll_no_scroll_when_all_fit() {
// total (8) < rows_visible (10) — everything fits, no scrolling.
assert_eq!(compute_scroll(5, 0, 10, 8), 0);
}
#[test]
fn scroll_stays_at_top_when_cursor_in_viewport() {
// cursor 3 in [0,10) — no scroll change.
assert_eq!(compute_scroll(3, 0, 10, 100), 0);
}
#[test]
fn scroll_down_when_cursor_below_viewport() {
// cursor 10, viewport [0,10) — scroll to 1, cursor becomes last row.
assert_eq!(compute_scroll(10, 0, 10, 100), 1);
// cursor 15 — scroll to 6.
assert_eq!(compute_scroll(15, 0, 10, 100), 6);
}
#[test]
fn scroll_up_when_cursor_above_viewport() {
// cursor 5 < scroll 20 — scroll up to 5.
assert_eq!(compute_scroll(5, 20, 10, 100), 5);
}
#[test]
fn scroll_no_change_when_cursor_in_middle_of_viewport() {
// cursor 25 in [20,30) — no scroll change.
assert_eq!(compute_scroll(25, 20, 10, 100), 20);
}
#[test]
fn scroll_clamps_at_bottom() {
// Can't scroll past total - rows_visible = 90.
assert_eq!(compute_scroll(99, 90, 10, 100), 90);
// Even if cursor is at the very last row.
assert_eq!(compute_scroll(99, 0, 10, 100), 90);
}
#[test]
fn scroll_handles_zero_rows() {
assert_eq!(compute_scroll(5, 0, 0, 100), 0);
}
}
+16
View File
@@ -0,0 +1,16 @@
//! Terminal input events via crossterm.
use crossterm::event::{self, Event, KeyEvent};
use std::time::Duration;
/// Poll for a terminal event with a small timeout. Returns None if no event
/// arrived within the poll window (lets the app stay responsive and do
/// background work like receiving lazy modinfo fetches).
pub fn poll(timeout_ms: u64) -> Option<KeyEvent> {
if event::poll(Duration::from_millis(timeout_ms)).unwrap_or(false) {
if let Ok(Event::Key(key)) = event::read() {
return Some(key);
}
}
None
}
+169
View File
@@ -0,0 +1,169 @@
//! kmod-tui — a TUI for browsing and inspecting loaded Linux kernel modules.
//!
//! Read-only. Data from /proc/modules, /sys/module/<name>/, and `modinfo`.
mod app;
mod event;
mod model;
mod ui;
use std::io::{self, stdout};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crossterm::execute;
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
use crate::app::View;
fn main() -> io::Result<()> {
// Setup terminal.
enable_raw_mode()?;
let mut stdout = stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
// Run the app.
let result = run(&mut terminal);
// Restore terminal regardless of outcome.
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
result
}
fn run(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> io::Result<()> {
let mut app = app::App::new();
// Eagerly fetch modinfo for the first module so the detail pane isn't empty.
app.ensure_modinfo();
loop {
// Draw current state.
terminal.draw(|f| ui::draw(f, &mut app))?;
// Poll for input (short timeout so we can drain background fetches).
if let Some(key) = event::poll(100) {
handle_key(&mut app, key);
}
// Drain any completed modinfo fetches; redraw if something arrived.
if app.poll_pending() {
terminal.draw(|f| ui::draw(f, &mut app))?;
}
if app.should_quit {
break;
}
}
Ok(())
}
fn handle_key(app: &mut app::App, key: KeyEvent) {
// Global: quit works anywhere (unless typing in filter).
if !app.filter_active && matches!(key.code, KeyCode::Char('q')) {
app.should_quit = true;
return;
}
if !app.filter_active && matches!(key.code, KeyCode::Char('?')) {
app.help = !app.help;
return;
}
if app.help {
// Any key (except ?) closes help.
if matches!(key.code, KeyCode::Char('?')) {
app.help = false;
} else {
app.help = false;
}
return;
}
if app.filter_active {
handle_filter_key(app, key);
return;
}
match app.view_mode {
View::List => handle_list_key(app, key),
View::FullDetail => handle_detail_key(app, key),
}
}
fn handle_filter_key(app: &mut app::App, key: KeyEvent) {
match key.code {
KeyCode::Esc => app.clear_filter(),
KeyCode::Enter => app.filter_active = false,
KeyCode::Backspace => {
app.pop_filter_char();
}
KeyCode::Char(c) => {
if key.modifiers.contains(KeyModifiers::CONTROL) {
if c == 'c' {
app.clear_filter();
}
return;
}
app.push_filter_char(c);
}
_ => {}
}
}
fn handle_list_key(app: &mut app::App, key: KeyEvent) {
match key.code {
KeyCode::Down | KeyCode::Char('j') => app.move_cursor(1),
KeyCode::Up | KeyCode::Char('k') => app.move_cursor(-1),
KeyCode::Char('G') => {
if !app.view.is_empty() {
app.cursor = app.view.len() - 1;
}
}
KeyCode::Char('g') => {
if key.modifiers.contains(KeyModifiers::CONTROL) || key.modifiers.is_empty() {
app.cursor = 0;
}
}
KeyCode::PageDown => {
let step = 10;
app.move_cursor(step);
}
KeyCode::PageUp => app.move_cursor(-10),
KeyCode::Home => app.cursor = 0,
KeyCode::End => {
if !app.view.is_empty() {
app.cursor = app.view.len() - 1;
}
}
KeyCode::Enter => {
app.view_mode = View::FullDetail;
app.ensure_modinfo();
}
KeyCode::Char('/') => app.enter_filter(),
KeyCode::Char('s') => app.toggle_sort(),
KeyCode::Char('r') => app.refresh(),
_ => {}
}
}
fn handle_detail_key(app: &mut app::App, key: KeyEvent) {
match key.code {
KeyCode::Esc | KeyCode::Char('q') => app.view_mode = View::List,
KeyCode::Up | KeyCode::Char('k') => {
app.view_mode = View::List;
app.move_cursor(-1);
app.ensure_modinfo();
app.view_mode = View::FullDetail;
}
KeyCode::Down | KeyCode::Char('j') => {
app.view_mode = View::List;
app.move_cursor(1);
app.ensure_modinfo();
app.view_mode = View::FullDetail;
}
_ => {}
}
}
+407
View File
@@ -0,0 +1,407 @@
//! Data model: collect and represent loaded kernel modules.
//!
//! Data sources (read-only):
//! - /proc/modules : one line per loaded module
//! - /sys/module/<name>/ : refcnt, coresize, initsize, initstate, srcversion,
//! taint, version, parameters/, holders/
//! - `modinfo <name>` : filename, license, description, author, depends,
//! vermagic, retpoline, intree, signature info
//! (shelled out lazily, only when detail is requested,
//! to keep the list view fast.)
use std::fs;
use std::path::Path;
use std::process::Command;
use std::sync::mpsc;
use std::thread;
/// One loaded kernel module.
#[derive(Debug, Clone)]
pub struct Module {
pub name: String,
/// Size in bytes from /proc/modules (this is the core size for most modules).
pub size: usize,
pub refcnt: i64,
/// Modules that depend on this one (parsed from the "used-by" column).
pub used_by: Vec<String>,
pub state: String,
/// Base address (hex string as in /proc/modules).
pub addr: String,
// From /sys/module/<name>/
pub coresize: Option<usize>,
pub initsize: Option<usize>,
pub initstate: Option<String>,
pub srcversion: Option<String>,
pub taint: Option<String>,
pub version: Option<String>,
/// Parameters and their current values (read from /sys/module/<name>/parameters).
pub parameters: Vec<(String, String)>,
/// Modules holding a reference (from /sys/module/<name>/holders).
pub holders: Vec<String>,
// From `modinfo` (lazy; None until explicitly fetched).
pub modinfo: Option<ModInfo>,
}
impl Module {
/// True if the module matches a substring filter: against its own name OR
/// any of the modules that depend on it (`used_by`). Case-insensitive.
pub fn matches_filter(&self, needle: &str) -> bool {
if needle.is_empty() {
return true;
}
let needle = needle.to_lowercase();
if self.name.to_lowercase().contains(&needle) {
return true;
}
self.used_by
.iter()
.any(|dep| dep.to_lowercase().contains(&needle))
}
}
/// The richest metadata, obtained from `modinfo`.
#[derive(Debug, Clone, Default)]
pub struct ModInfo {
pub filename: String,
pub license: String,
pub description: String,
pub author: String,
pub depends: Vec<String>,
pub vermagic: String,
pub retpoline: String,
pub intree: String,
pub signer: String,
pub sig_hashalgo: String,
/// Other fields we didn't classify, kept as (key, value) pairs.
pub other: Vec<(String, String)>,
}
/// Read a sysfs file to a trimmed String. Returns None on any error.
fn read_sysfs(path: &Path) -> Option<String> {
fs::read_to_string(path).ok().map(|s| s.trim().to_string())
}
/// Parse the "used-by" column of /proc/modules: tokens separated by commas,
/// or "-" if nothing uses this module.
fn parse_used_by(s: &str) -> Vec<String> {
if s == "-" || s.is_empty() {
Vec::new()
} else {
s.split_terminator(',')
.map(|x| x.trim().trim_end_matches('[').trim_end_matches(']').to_string())
.filter(|x| !x.is_empty())
.collect()
}
}
/// Read parameters from /sys/module/<name>/parameters.
fn read_parameters(name: &str) -> Vec<(String, String)> {
let dir = Path::new("/sys/module").join(name).join("parameters");
let mut out = Vec::new();
if let Ok(entries) = fs::read_dir(&dir) {
for ent in entries.flatten() {
let pname = ent.file_name().to_string_lossy().to_string();
let val = fs::read_to_string(ent.path())
.unwrap_or_default()
.trim()
.to_string();
out.push((pname, val));
}
}
// Deterministic ordering.
out.sort_by(|a, b| a.0.cmp(&b.0));
out
}
/// Read holders from /sys/module/<name>/holders.
fn read_holders(name: &str) -> Vec<String> {
let dir = Path::new("/sys/module").join(name).join("holders");
let mut out = Vec::new();
if let Ok(entries) = fs::read_dir(&dir) {
for ent in entries.flatten() {
out.push(ent.file_name().to_string_lossy().to_string());
}
}
out.sort();
out
}
/// Collect all currently-loaded modules from /proc/modules and /sys/module.
pub fn collect_modules() -> Vec<Module> {
let proc = fs::read_to_string("/proc/modules").unwrap_or_default();
let mut modules: Vec<Module> = Vec::new();
for line in proc.lines() {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() < 6 {
continue;
}
// parts: name size refcnt used_by state addr [extra...]
let name = parts[0].to_string();
let size: usize = parts[1].parse().unwrap_or(0);
let refcnt: i64 = parts[2].parse().unwrap_or(0);
let used_by = parse_used_by(parts[3]);
let state = parts.get(4).map(|s| s.to_string()).unwrap_or_default();
let addr = parts.get(5).map(|s| s.to_string()).unwrap_or_default();
// Enrich from /sys/module/<name>.
let sys = Path::new("/sys/module").join(&name);
let coresize = read_sysfs(&sys.join("coresize")).and_then(|s| s.parse().ok());
let initsize = read_sysfs(&sys.join("initsize")).and_then(|s| s.parse().ok());
let initstate = read_sysfs(&sys.join("initstate"));
let srcversion = read_sysfs(&sys.join("srcversion"));
let taint = read_sysfs(&sys.join("taint"));
let version = read_sysfs(&sys.join("version"));
let parameters = read_parameters(&name);
let holders = read_holders(&name);
modules.push(Module {
name,
size,
refcnt,
used_by,
state,
addr,
coresize,
initsize,
initstate,
srcversion,
taint,
version,
parameters,
holders,
modinfo: None,
});
}
modules.sort_by(|a, b| a.name.cmp(&b.name));
modules
}
/// Fetch rich metadata via `modinfo <name>` and parse it into a ModInfo struct.
pub fn fetch_modinfo(name: &str) -> Option<ModInfo> {
let output = Command::new("modinfo").arg(name).output().ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
Some(parse_modinfo(&stdout))
}
/// Parse `modinfo` output. Each line: `key: value`. Multi-line values (like
/// `signature`) are folded by detecting a leading tab/whitespace continuation.
fn parse_modinfo(text: &str) -> ModInfo {
let mut mi = ModInfo::default();
let known: &[&str] = &[
"filename",
"license",
"description",
"author",
"depends",
"vermagic",
"retpoline",
"intree",
"signer",
"sig_hashalgo",
"sig_id",
"sig_key",
"signature",
"srcversion",
"name",
"version",
];
let mut last_key = String::new();
for raw in text.lines() {
// Continuation line (starts with whitespace) appends to last value.
if raw.starts_with('\t') || (raw.starts_with(' ') && !raw.trim().is_empty()) {
if !last_key.is_empty() {
let cont = raw.trim();
// Append to whichever field last_key refers to.
append_to_field(&mut mi, &last_key, cont);
}
continue;
}
if let Some((k, v)) = raw.split_once(':') {
let key = k.trim().to_string();
let val = v.trim().to_string();
last_key = key.clone();
set_field(&mut mi, &key, &val, known);
}
}
mi
}
fn set_field(mi: &mut ModInfo, key: &str, val: &str, known: &[&str]) {
match key {
"filename" => mi.filename = val.to_string(),
"license" => mi.license = val.to_string(),
"description" => mi.description = val.to_string(),
"author" => mi.author = val.to_string(),
"depends" => {
mi.depends = if val == "-" || val.is_empty() {
Vec::new()
} else {
val.split(',').map(|s| s.trim().to_string()).collect()
};
}
"vermagic" => mi.vermagic = val.to_string(),
"retpoline" => mi.retpoline = val.to_string(),
"intree" => mi.intree = val.to_string(),
"signer" => mi.signer = val.to_string(),
"sig_hashalgo" => mi.sig_hashalgo = val.to_string(),
_ => {
let _ = known;
mi.other.push((key.to_string(), val.to_string()));
}
}
}
fn append_to_field(mi: &mut ModInfo, key: &str, val: &str) {
match key {
"description" => {
if !mi.description.is_empty() {
mi.description.push(' ');
}
mi.description.push_str(val);
}
"signature" | "sig_key" => {
// Append to the most recent 'other' entry if it matches.
if let Some(last) = mi.other.last_mut() {
if last.0 == key {
last.1.push_str(val);
}
}
}
_ => {}
}
}
/// Spawn a thread to fetch modinfo for a module, returning a channel that
/// yields (name, Option<ModInfo>) when ready. Lets the UI stay responsive.
pub fn spawn_modinfo_fetch(name: String) -> mpsc::Receiver<(String, Option<ModInfo>)> {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let mi = fetch_modinfo(&name);
let _ = tx.send((name, mi));
});
rx
}
/// Human-readable byte size, /proc/modules style: 12288 -> "12K", 114688 -> "112K".
pub fn human_size(bytes: usize) -> String {
let k = 1024usize;
let m = k * 1024;
if bytes >= m {
format!("{:.1}M", bytes as f64 / m as f64)
} else if bytes >= k {
format!("{}K", bytes / k)
} else {
format!("{}B", bytes)
}
}
/// Kernel release string, e.g. "6.8.0-137-generic".
pub fn kernel_release() -> String {
fs::read_to_string("/proc/sys/kernel/osrelease")
.map(|s| s.trim().to_string())
.unwrap_or_else(|_| "unknown".to_string())
}
/// Count of modules currently loaded (cheap: line count of /proc/modules).
/// Reserved for a future status-bar "loaded vs filtered" split.
#[allow(dead_code)]
pub fn loaded_count() -> usize {
fs::read_to_string("/proc/modules")
.map(|s| s.lines().filter(|l| !l.is_empty()).count())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_used_by_dash() {
assert!(parse_used_by("-").is_empty());
}
#[test]
fn parse_used_by_list() {
let v = parse_used_by("wireguard,curve25519_x86_64");
assert_eq!(v, vec!["wireguard", "curve25519_x86_64"]);
}
#[test]
fn filter_matches_name() {
let m = Module {
name: "wireguard".into(),
size: 0,
refcnt: 0,
used_by: vec![],
state: "Live".into(),
addr: String::new(),
coresize: None,
initsize: None,
initstate: None,
srcversion: None,
taint: None,
version: None,
parameters: vec![],
holders: vec![],
modinfo: None,
};
assert!(m.matches_filter("wire"));
assert!(m.matches_filter("GUARD")); // case-insensitive
assert!(!m.matches_filter("ipsec"));
assert!(m.matches_filter("")); // empty matches all
}
#[test]
fn filter_matches_dependents() {
let m = Module {
name: "curve25519_x86_64".into(),
size: 0,
refcnt: 1,
used_by: vec!["wireguard".into()],
state: "Live".into(),
addr: String::new(),
coresize: None,
initsize: None,
initstate: None,
srcversion: None,
taint: None,
version: None,
parameters: vec![],
holders: vec![],
modinfo: None,
};
// Name doesn't contain "wireguard" but a dependent does.
assert!(m.matches_filter("wireguard"));
assert!(m.matches_filter("curve")); // also still matches own name
assert!(!m.matches_filter("chacha"));
}
#[test]
fn parse_modinfo_basic() {
let s = "filename: /lib/modules/6.8.0-137-generic/kernel/wireguard/wireguard.ko.zst\n\
license: GPL\n\
description: WireGuard secure tunnel\n\
author: Jason A. Donenfeld <Jason@zx2c4.com>\n\
depends: \n\
retpoline: Y\n\
intree: Y\n\
name: wireguard\n\
vermagic: 6.8.0-137-generic SMP preempt mod_unload modversions \n";
let mi = parse_modinfo(s);
assert_eq!(mi.license, "GPL");
assert_eq!(mi.description, "WireGuard secure tunnel");
assert_eq!(mi.author, "Jason A. Donenfeld <Jason@zx2c4.com>");
assert!(mi.depends.is_empty());
assert_eq!(mi.retpoline, "Y");
}
#[test]
fn human_size_units() {
assert_eq!(human_size(12288), "12K");
assert_eq!(human_size(114688), "112K");
assert_eq!(human_size(1048576), "1.0M");
assert_eq!(human_size(512), "512B");
}
}
+207
View File
@@ -0,0 +1,207 @@
//! The detail pane (bottom of list view) and the full-screen detail view.
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap};
use ratatui::Frame;
use crate::app::App;
use crate::model::{human_size, Module};
/// Bottom detail pane: a compact two-column summary of the selected module.
pub fn draw_pane(f: &mut Frame, area: Rect, app: &App) {
let title = match app.current_module() {
Some(m) => format!(" Detail: {} ", m.name),
None => " Detail ".to_string(),
};
let block = Block::default().borders(Borders::ALL).title(title);
f.render_widget(block, area);
let inner = area.inner(ratatui::layout::Margin {
horizontal: 1,
vertical: 1,
});
if inner.height < 1 {
return;
}
let Some(m) = app.current_module() else {
let p = Paragraph::new("No module selected.")
.style(Style::default().fg(Color::DarkGray));
f.render_widget(p, inner);
return;
};
let cols = Layout::horizontal([Constraint::Fill(1), Constraint::Fill(1)]).split(inner);
let left = summary_lines(m);
let right = meta_lines(m);
f.render_widget(Paragraph::new(left).wrap(Wrap { trim: true }), cols[0]);
f.render_widget(Paragraph::new(right).wrap(Wrap { trim: true }), cols[1]);
}
fn kv<'a>(k: &str, v: &str) -> Line<'a> {
Line::from(vec![
Span::styled(
format!("{:<11}", k),
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
),
Span::raw(v.to_string()),
])
}
fn summary_lines<'a>(m: &Module) -> Vec<Line<'a>> {
let mut lines = vec![
kv("Name", &m.name),
kv("Size", &human_size(m.size)),
kv("Refcnt", &m.refcnt.to_string()),
kv("State", &m.state),
kv("Address", &m.addr),
];
if let Some(cs) = m.coresize {
lines.push(kv("Coresize", &human_size(cs)));
}
if let Some(is) = m.initsize {
lines.push(kv("Initsize", &human_size(is)));
}
if let Some(v) = &m.initstate {
lines.push(kv("Initstate", v));
}
if let Some(v) = &m.taint {
if !v.is_empty() {
lines.push(kv("Taint", v));
}
}
if let Some(v) = &m.version {
lines.push(kv("Version", v));
}
if let Some(v) = &m.srcversion {
lines.push(kv("Srcversion", v));
}
lines
}
fn meta_lines<'a>(m: &Module) -> Vec<Line<'a>> {
let mut lines: Vec<Line> = Vec::new();
match &m.modinfo {
Some(mi) => {
lines.push(kv("Filename", &mi.filename));
lines.push(kv("License", &mi.license));
lines.push(kv("Author", &mi.author));
lines.push(kv("Vermagic", &mi.vermagic));
lines.push(kv("Retpoline", &mi.retpoline));
lines.push(kv("Intree", &mi.intree));
lines.push(kv("Signer", &mi.signer));
}
None => {
lines.push(Line::from(Span::styled(
"Loading modinfo…",
Style::default().fg(Color::DarkGray),
)));
}
}
let dep = if m.used_by.is_empty() {
"(none)".to_string()
} else {
m.used_by.join(", ")
};
lines.push(kv("Used by", &dep));
let holders = if m.holders.is_empty() {
"(none)".to_string()
} else {
m.holders.join(", ")
};
lines.push(kv("Holders", &holders));
let params = if m.parameters.is_empty() {
"(none)".to_string()
} else {
m.parameters
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join(", ")
};
lines.push(kv("Params", &params));
lines
}
/// Full-screen detail view for the selected module.
pub fn draw_full(f: &mut Frame, area: Rect, app: &App) {
let title = match app.current_module() {
Some(m) => format!(" {} — full detail (Esc to return) ", m.name),
None => " Full detail ".to_string(),
};
let block = Block::default().borders(Borders::ALL).title(title);
f.render_widget(block, area);
let inner = area.inner(ratatui::layout::Margin {
horizontal: 1,
vertical: 1,
});
let Some(m) = app.current_module() else {
return;
};
// Three sections: summary, modinfo/depends, parameters+holders.
let chunks = Layout::vertical([
Constraint::Length(9),
Constraint::Length(8),
Constraint::Min(5),
])
.split(inner);
f.render_widget(Paragraph::new(summary_lines(m)), chunks[0]);
let mut meta = meta_lines_full(m);
f.render_widget(Paragraph::new(meta.drain(..).collect::<Vec<_>>()), chunks[1]);
// Parameters and holders as lists.
let sub = Layout::horizontal([Constraint::Fill(1), Constraint::Fill(1)]).split(chunks[2]);
let pitems: Vec<ListItem> = if m.parameters.is_empty() {
vec![ListItem::new("(none)")]
} else {
m.parameters
.iter()
.map(|(k, v)| ListItem::new(format!("{} = {}", k, v)))
.collect()
};
f.render_widget(
List::new(pitems).block(Block::default().borders(Borders::ALL).title("Parameters")),
sub[0],
);
let hitems: Vec<ListItem> = if m.holders.is_empty() {
vec![ListItem::new("(none)")]
} else {
m.holders.iter().map(|h| ListItem::new(h.clone())).collect()
};
f.render_widget(
List::new(hitems).block(Block::default().borders(Borders::ALL).title("Holders")),
sub[1],
);
}
fn meta_lines_full<'a>(m: &Module) -> Vec<Line<'a>> {
let mut lines: Vec<Line> = Vec::new();
if let Some(mi) = &m.modinfo {
lines.push(kv("Filename", &mi.filename));
lines.push(kv("License", &mi.license));
lines.push(kv("Description", &mi.description));
lines.push(kv("Author", &mi.author));
lines.push(kv("Vermagic", &mi.vermagic));
let dep = if mi.depends.is_empty() {
"(none)".to_string()
} else {
mi.depends.join(", ")
};
lines.push(kv("Depends", &dep));
lines.push(kv("Retpoline", &mi.retpoline));
} else {
lines.push(Line::from(Span::styled(
"Loading modinfo… (press Enter again if it stalls)",
Style::default().fg(Color::DarkGray),
)));
}
lines
}
#[allow(dead_code)]
fn _alignment_unused() -> Alignment {
Alignment::Left
}
+91
View File
@@ -0,0 +1,91 @@
//! The module list pane (top of the list view).
//!
//! Scrolling is edge-based: the cursor moves freely within the viewport and
//! the list only scrolls when the cursor would leave the visible area. The
//! scroll offset lives in `App` (set by `ui::mod` via `adjust_scroll`) so it's
//! stateful and stable across draws.
use ratatui::layout::{Constraint, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::widgets::{Block, Borders, Row, Table};
use ratatui::Frame;
use crate::app::App;
use crate::model::human_size;
/// Column widths.
const WIDTHS: [Constraint; 6] = [
Constraint::Length(4), // #
Constraint::Min(22), // name
Constraint::Length(8), // size
Constraint::Length(5), // refcnt
Constraint::Min(18), // used by
Constraint::Length(7), // state
];
/// How many data rows fit in the list pane for the given area (excludes the
/// header row and the border). Used by `ui::mod` to size the viewport before
/// the list is drawn.
pub fn visible_rows(area: Rect) -> usize {
let inner = area.inner(ratatui::layout::Margin {
horizontal: 1,
vertical: 1,
});
inner.height.saturating_sub(1) as usize // account for the header row
}
pub fn draw(f: &mut Frame, area: Rect, app: &App) {
let block = Block::default()
.borders(Borders::ALL)
.title(format!(" Loaded modules: {} ", app.view.len()));
f.render_widget(block, area);
let inner = area.inner(ratatui::layout::Margin {
horizontal: 1,
vertical: 1,
});
if inner.height < 1 {
return;
}
let header = Row::new(vec!["#", "Module", "Size", "Refc", "Used by", "State"])
.style(Style::default().add_modifier(Modifier::BOLD).fg(Color::Cyan));
let rows_visible = visible_rows(area);
let start = app.scroll;
let rows: Vec<Row> = app
.view
.iter()
.enumerate()
.skip(start)
.take(rows_visible)
.map(|(vn, &idx)| {
let m = &app.modules[idx];
let used = if m.used_by.is_empty() {
"-".to_string()
} else {
m.used_by.join(",")
};
let selected = vn == app.cursor;
let row_style = if selected {
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
Row::new(vec![
format!("{}", vn + 1),
m.name.clone(),
human_size(m.size),
format!("{}", m.refcnt),
used,
m.state.clone(),
])
.style(row_style)
})
.collect();
let table = Table::new(rows, WIDTHS).header(header);
f.render_widget(table, inner);
}
+171
View File
@@ -0,0 +1,171 @@
pub mod list;
pub mod detail;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
use ratatui::Frame;
use crate::app::{App, View};
/// Compose the whole frame from the app state.
pub fn draw(f: &mut Frame, app: &mut App) {
let area = f.area();
let chunks = Layout::vertical([
Constraint::Length(3), // status/filter bar
Constraint::Min(5), // main area
Constraint::Length(2), // footer
])
.split(area);
draw_status(f, chunks[0], app);
match app.view_mode {
View::List => {
let main = Layout::vertical([Constraint::Fill(2), Constraint::Length(10)])
.split(chunks[1]);
// Set how many rows the list pane can show, then edge-scroll so
// the cursor stays visible without centering it.
app.viewport_rows = list::visible_rows(main[0]);
app.adjust_scroll();
list::draw(f, main[0], app);
detail::draw_pane(f, main[1], app);
}
View::FullDetail => {
detail::draw_full(f, chunks[1], app);
}
}
draw_footer(f, chunks[2], app);
if app.help {
draw_help(f, area);
}
}
fn draw_status(f: &mut Frame, area: Rect, app: &App) {
let title = format!(
" kmod-tui — {} modules loaded (kernel {}) ",
app.view.len(),
app.kernel
);
let block = Block::default().borders(Borders::ALL).title(title);
f.render_widget(block, area);
let inner = area.inner(ratatui::layout::Margin {
horizontal: 1,
vertical: 1,
});
let line = if app.filter_active {
Line::from(vec![
Span::styled(
"Filter: ",
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
),
Span::raw(app.filter.clone()),
Span::styled("_", Style::default().fg(Color::Gray)),
Span::raw(" "),
Span::raw(format!("Sort: {}", app.sort.label())),
])
} else {
Line::from(vec![
Span::styled(
"Sort: ",
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
),
Span::raw(app.sort.label()),
Span::raw(" "),
Span::styled(
format!("Filter: {}", if app.filter.is_empty() { "(none)" } else { &app.filter }),
Style::default().fg(Color::Gray),
),
])
};
f.render_widget(Paragraph::new(line), inner);
}
fn draw_footer(f: &mut Frame, area: Rect, app: &App) {
let block = Block::default().borders(Borders::TOP);
f.render_widget(block, area);
let inner = area.inner(ratatui::layout::Margin {
horizontal: 1,
vertical: 0,
});
let keys = match app.view_mode {
View::List => {
if app.filter_active {
"Esc clear Backspace del type to filter Enter accept q quit"
} else {
"↑/j down ↓/k up Enter detail / filter s sort r refresh ? help q quit"
}
}
View::FullDetail => "Esc back ? help q quit",
};
let line = Line::from(Span::styled(
keys,
Style::default().fg(Color::DarkGray),
));
f.render_widget(Paragraph::new(line), inner);
}
fn draw_help(f: &mut Frame, area: Rect) {
let help_text = vec![
Line::from(Span::styled(
"kmod-tui — help",
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(vec![
Span::styled(" ↑/j ", Style::default().fg(Color::Yellow)),
Span::raw("move selection down"),
]),
Line::from(vec![
Span::styled(" ↓/k ", Style::default().fg(Color::Yellow)),
Span::raw("move selection up"),
]),
Line::from(vec![
Span::styled(" Enter ", Style::default().fg(Color::Yellow)),
Span::raw("open full detail for selected module"),
]),
Line::from(vec![
Span::styled(" / ", Style::default().fg(Color::Yellow)),
Span::raw("start filtering by module name"),
]),
Line::from(vec![
Span::styled(" Esc ", Style::default().fg(Color::Yellow)),
Span::raw("clear filter / close full detail"),
]),
Line::from(vec![
Span::styled(" s ", Style::default().fg(Color::Yellow)),
Span::raw("cycle sort: name → size → refcnt → dependents"),
]),
Line::from(vec![
Span::styled(" r ", Style::default().fg(Color::Yellow)),
Span::raw("refresh module list from /proc/modules"),
]),
Line::from(vec![
Span::styled(" ? ", Style::default().fg(Color::Yellow)),
Span::raw("toggle this help overlay"),
]),
Line::from(vec![
Span::styled(" q ", Style::default().fg(Color::Yellow)),
Span::raw("quit"),
]),
];
let width = 52;
let height = help_text.len() as u16 + 4;
let x = area.width.saturating_sub(width) / 2;
let y = area.height.saturating_sub(height) / 2;
let popup = Rect::new(x, y, width, height);
f.render_widget(Clear, popup);
let block = Block::default()
.borders(Borders::ALL)
.title(" Help (press ? to close) ");
f.render_widget(block, popup);
let inner = popup.inner(ratatui::layout::Margin {
horizontal: 1,
vertical: 1,
});
f.render_widget(Paragraph::new(help_text), inner);
}