From 00ba8781f2112344ea9c4ee2efa5d62a984b8871 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Mon, 7 Sep 2026 17:35:00 +0000 Subject: [PATCH] =?UTF-8?q?Initial=20commit:=20kmod-tui=20v1=20=E2=80=94?= =?UTF-8?q?=20kernel=20module=20inspector=20TUI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 5 + Cargo.lock | 599 +++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 17 ++ README.md | 95 ++++++++ SPEC.md | 127 ++++++++++ src/app.rs | 305 ++++++++++++++++++++++++ src/event.rs | 16 ++ src/main.rs | 169 +++++++++++++ src/model.rs | 407 ++++++++++++++++++++++++++++++++ src/ui/detail.rs | 207 ++++++++++++++++ src/ui/list.rs | 91 +++++++ src/ui/mod.rs | 171 ++++++++++++++ 12 files changed, 2209 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 SPEC.md create mode 100644 src/app.rs create mode 100644 src/event.rs create mode 100644 src/main.rs create mode 100644 src/model.rs create mode 100644 src/ui/detail.rs create mode 100644 src/ui/list.rs create mode 100644 src/ui/mod.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9256349 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/target +*.rs.bk +*.o +*.so +*.dylib diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..ef5e62b --- /dev/null +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..9a3aae7 --- /dev/null +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md new file mode 100644 index 0000000..e3368d1 --- /dev/null +++ b/README.md @@ -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 ` 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//` — refcnt, coresize, initsize, initstate, srcversion, + taint, `parameters/`, `holders/` +- `modinfo ` — 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`. diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..8220a5a --- /dev/null +++ b/SPEC.md @@ -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//` — per-module sysfs dir with: + `refcnt`, `initstate`, `coresize`, `initsize`, `version`, `srcversion`, + `taint`, `holders/` (modules depending on this one), `parameters/` (tunable params) +- `modinfo ` — 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//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 (once on startup; refresh on 'r') + App holds Vec + 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. diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..d7c5d08 --- /dev/null +++ b/src/app.rs @@ -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, + /// Indices into `modules` after filtering + sorting. + pub view: Vec, + 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)>>, + /// 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 = 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); + } +} diff --git a/src/event.rs b/src/event.rs new file mode 100644 index 0000000..812c72b --- /dev/null +++ b/src/event.rs @@ -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 { + if event::poll(Duration::from_millis(timeout_ms)).unwrap_or(false) { + if let Ok(Event::Key(key)) = event::read() { + return Some(key); + } + } + None +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..f4dea1c --- /dev/null +++ b/src/main.rs @@ -0,0 +1,169 @@ +//! kmod-tui — a TUI for browsing and inspecting loaded Linux kernel modules. +//! +//! Read-only. Data from /proc/modules, /sys/module//, 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>) -> 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; + } + _ => {} + } +} diff --git a/src/model.rs b/src/model.rs new file mode 100644 index 0000000..639cd64 --- /dev/null +++ b/src/model.rs @@ -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// : refcnt, coresize, initsize, initstate, srcversion, +//! taint, version, parameters/, holders/ +//! - `modinfo ` : 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, + pub state: String, + /// Base address (hex string as in /proc/modules). + pub addr: String, + + // From /sys/module// + pub coresize: Option, + pub initsize: Option, + pub initstate: Option, + pub srcversion: Option, + pub taint: Option, + pub version: Option, + /// Parameters and their current values (read from /sys/module//parameters). + pub parameters: Vec<(String, String)>, + /// Modules holding a reference (from /sys/module//holders). + pub holders: Vec, + + // From `modinfo` (lazy; None until explicitly fetched). + pub modinfo: Option, +} + +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, + 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 { + 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 { + 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//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//holders. +fn read_holders(name: &str) -> Vec { + 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 { + let proc = fs::read_to_string("/proc/modules").unwrap_or_default(); + let mut modules: Vec = 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/. + 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 ` and parse it into a ModInfo struct. +pub fn fetch_modinfo(name: &str) -> Option { + 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) when ready. Lets the UI stay responsive. +pub fn spawn_modinfo_fetch(name: String) -> mpsc::Receiver<(String, Option)> { + 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 \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 "); + 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"); + } +} diff --git a/src/ui/detail.rs b/src/ui/detail.rs new file mode 100644 index 0000000..f5cbaf2 --- /dev/null +++ b/src/ui/detail.rs @@ -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> { + 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> { + let mut lines: Vec = 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::>() + .join(", ") + }; + lines.push(kv("Params", ¶ms)); + 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::>()), chunks[1]); + + // Parameters and holders as lists. + let sub = Layout::horizontal([Constraint::Fill(1), Constraint::Fill(1)]).split(chunks[2]); + let pitems: Vec = 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 = 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> { + let mut lines: Vec = 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 +} diff --git a/src/ui/list.rs b/src/ui/list.rs new file mode 100644 index 0000000..2878881 --- /dev/null +++ b/src/ui/list.rs @@ -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 = 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); +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..82155ef --- /dev/null +++ b/src/ui/mod.rs @@ -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); +}