diff --git a/.config/tuxtape-dashboard-config.toml b/.config/tuxtape-dashboard-config.toml new file mode 100644 index 0000000..874b08a --- /dev/null +++ b/.config/tuxtape-dashboard-config.toml @@ -0,0 +1,14 @@ +[keybindings.Normal] +q = "Quit" +shift-h = "TabLeft" +shift-l = "TabRight" +h = "PaneLeft" +l = "PaneRight" +j = "ScrollDown" +k = "ScrollUp" +"" = "Select" + +[database] +server_url = "127.0.0.1:50051" +use_tls = false +# tls_cert_path = "put path here if use_tls = true" diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..16b38e7 --- /dev/null +++ b/.envrc @@ -0,0 +1,3 @@ +export TUXTAPE_DASHBOARD_CONFIG=`pwd`/.config +export TUXTAPE_DASHBOARD_DATA=`pwd`/.data +export TUXTAPE_DASHBOARD_LOG_LEVEL=debug diff --git a/.github/ISSUE_TEMPLATE/bug.md b/.github/ISSUE_TEMPLATE/bug.md new file mode 100644 index 0000000..8d84504 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.md @@ -0,0 +1,44 @@ +--- +name: Bug report - TuxTape +about: Tell us about the issue you've encountered with TuxTape, and we'll do our best to help you! +labels: status/needs-triage, type/bug +--- + + + + + +### **What did you expect to happen?** +_Describe what you thought would happen._ + + +### **What actually happened?** +_Tell us what happened instead, including any error messages, screenshots, or logs._ + + + +### **Steps to reproduce the issue:** +_Help us understand how to recreate the problem. The more detailed, the better!_ + +1. Step 1 +2. Step 2 +3. ... + + + +### **Your setup details:** +- **TuxTape version**: +- **Operating System**: +- **Anything else we should know?**: + + + +--- + +Thanks for taking the time to report this! We'll review it and get back to you as soon as we can.i + diff --git a/.github/ISSUE_TEMPLATE/future.md b/.github/ISSUE_TEMPLATE/future.md new file mode 100644 index 0000000..998644d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/future.md @@ -0,0 +1,29 @@ +--- +name: Feature Request +about: Suggest an idea or improvement for the project +labels: status/needs-triage, type/enhancement +--- + + + +### **What would you like?** +_Tell us about the feature or improvement you have in mind. What problem does it solve, or how will it help you?_ + + +### **Why do you need this?** +_Share how this feature will make your work easier, more efficient, or just more fun!_ + +### **Anything else to add?** +_If you have extra details, screenshots, or context, we would love to see it!_ + + +## **Your setup:** +-**Version**: +-**Operating System**: +-**Anything else?**: + diff --git a/.github/dependency-review-config.yml b/.github/dependency-review-config.yml new file mode 100644 index 0000000..0749f30 --- /dev/null +++ b/.github/dependency-review-config.yml @@ -0,0 +1,10 @@ +fail-on-severity: 'critical' +allow-licenses: + - Apache-2.0 + - 0BSD + - BSD-3-Clause + - ISC + - MIT + - MPL-2.0 + - Unicode-3.0 + - Zlib diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..a998ec0 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,26 @@ + + +**Issue Number:** +_Reference the issue this PR fixes._ + + +**Description of Changes:** +_Provide a clear and concise explanation of what changes you made and why._ + + +**Testing Done:** +_How did you test your changes? Share details like steps, tools used, or results._ + + +**Terms of contribution:** + +By submitting this pull request, I agree that this contribution is licensed under the terms of the Apache License, Version 2.0. + + +--- + +Thanks for submitting your pull request! We will review it as soon as possible. + diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 0000000..27de400 --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,16 @@ +name: 'Dependency Review' +on: [pull_request] + +permissions: + contents: read + +jobs: + dependency-review: + runs-on: ubuntu-latest + steps: + - name: 'Checkout Repository' + uses: actions/checkout@v4 + - name: 'Dependency Review' + uses: actions/dependency-review-action@v4 + with: + config-file: './.github/dependency-review-config.yml' diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f9840db --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +target/ +.vscode/ +*.DS_Store diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..bbf1f7f --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,4168 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "addr2line" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +[[package]] +name = "adler2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" + +[[package]] +name = "ahash" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45862d1c77f2228b9e10bc609d5bc203d86ebc9b87ad8d5d5167a6c9abf739d9" + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" + +[[package]] +name = "anstyle-parse" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2109dbce0e72be3ec00bed26e6a7479ca384ad226efdd66db8fa2e3a38c83125" +dependencies = [ + "anstyle", + "windows-sys 0.59.0", +] + +[[package]] +name = "anyhow" +version = "1.0.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c95c10ba0b00a02636238b814946408b1322d5ac4760326e6fb8ec956d85775" + +[[package]] +name = "arc-swap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" + +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.87", +] + +[[package]] +name = "async-trait" +version = "0.1.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.87", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "sync_wrapper 1.0.1", + "tower 0.5.1", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper 1.0.1", + "tower-layer", + "tower-service", +] + +[[package]] +name = "backtrace" +version = "0.3.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b05800d2e817c8b3b4b54abd461726265fa9789ae34330622f2db9ee696f9d" +dependencies = [ + "addr2line", + "cc", + "cfg-if", + "libc", + "miniz_oxide 0.7.4", + "object", + "rustc-demangle", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "better-panic" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fa9e1d11a268684cbd90ed36370d7577afb6c62d912ddff5c15fc34343e5036" +dependencies = [ + "backtrace", + "console", +] + +[[package]] +name = "bitflags" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" +dependencies = [ + "serde", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bstr" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a68f1f47cdf0ec8ee4b941b2eee2a80cb796db73118c0dd09ac63fbe405be22" +dependencies = [ + "memchr", + "regex-automata 0.4.9", + "serde", +] + +[[package]] +name = "bumpalo" +version = "3.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ac0150caa2ae65ca5bd83f25c7de183dea78d4d366469f148435e2acfbad0da" + +[[package]] +name = "camino" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b96ec4966b5813e2c0507c1f86115c8c5abaadc3980879c3424042a02fd1ad3" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo-platform" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24b1f0365a6c6bb4020cd05806fd0d33c44d38046b8bd7f0e40814b9763cabfc" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d886547e41f740c616ae73108f6eb70afe6d940c7bc697cb30f13daec073037" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror", +] + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0abae9be0aaf9ea96a3b1b8b1b55c602ca751eba1b1500220cea4ecbafe7c0d5" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd9de9f2205d5ef3fd67e685b0df337994ddd4495e2a28d185500d0e1edfea47" +dependencies = [ + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-targets 0.52.6", +] + +[[package]] +name = "clap" +version = "4.5.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb3b4b9e5a7c7514dfa52869339ee98b3156b0bfb4e8a77c4ff4babb64b1604f" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b17a95aa67cc7b5ebd32aa5370189aa0d79069ef1c64ce893bd30fb24bff20ec" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", + "terminal_size", + "unicase", + "unicode-width 0.2.0", +] + +[[package]] +name = "clap_derive" +version = "4.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ac6a0c7b1a9e9a5186361f67dfa1b88213572f427fb9ab038efb2bd8c582dab" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.87", +] + +[[package]] +name = "clap_lex" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afb84c814227b90d6895e01398aee0d8033c00e7466aca416fb6a8e0eb19d8a7" + +[[package]] +name = "clru" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbd0f76e066e64fdc5631e3bb46381254deab9ef1158292f27c8c57e3bf3fe59" + +[[package]] +name = "color-eyre" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55146f5e46f237f7423d74111267d4597b59b0dad0ffaf7303bce9945d843ad5" +dependencies = [ + "backtrace", + "color-spantrace", + "eyre", + "indenter", + "once_cell", + "owo-colors", + "tracing-error", +] + +[[package]] +name = "color-spantrace" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd6be1b2a7e382e2b98b43b2adcca6bb0e465af0bdd38123873ae61eb17a72c2" +dependencies = [ + "once_cell", + "owo-colors", + "tracing-core", + "tracing-error", +] + +[[package]] +name = "colorchoice" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" + +[[package]] +name = "compact_str" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6050c3a16ddab2e412160b31f2c871015704239bca62f72f6e5f0be631d3f644" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "serde", + "static_assertions", +] + +[[package]] +name = "config" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68578f196d2a33ff61b27fae256c3164f65e36382648e30666dde05b8cc9dfdf" +dependencies = [ + "async-trait", + "convert_case", + "json5", + "nom", + "pathdiff", + "ron", + "rust-ini", + "serde", + "serde_json", + "toml", + "yaml-rust2", +] + +[[package]] +name = "console" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e1f83fc076bd6dd27517eacdf25fef6c4dfe5f1d7448bafaaf3a26f13b5e4eb" +dependencies = [ + "encode_unicode", + "lazy_static", + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "const_format" +version = "0.2.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50c655d81ff1114fb0dcdea9225ea9f0cc712a6f8d189378e82bdf62a473a64b" +dependencies = [ + "const_format_proc_macros", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eff1a44b93f47b1bac19a27932f5c591e43d1ba357ee4f61526c8a25603f0eb1" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ca741a962e1b0bff6d724a1a0958b686406e853bb14061f218562e1896f95e6" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "futures-core", + "mio", + "parking_lot", + "rustix", + "serde", + "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 = "crunchy" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.87", +] + +[[package]] +name = "darling_macro" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.87", +] + +[[package]] +name = "deranged" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.87", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.87", +] + +[[package]] +name = "derive_deref" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcdbcee2d9941369faba772587a565f4f534e42cb8d17e5295871de730163b2b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "directories" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.87", +] + +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "edit" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f364860e764787163c8c8f58231003839be31276e821e2ad2092ddf496b1aa09" +dependencies = [ + "tempfile", + "which", +] + +[[package]] +name = "either" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" + +[[package]] +name = "encode_unicode" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + +[[package]] +name = "errno" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "eyre" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" +dependencies = [ + "indenter", + "once_cell", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "faster-hex" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2a2b11eda1d40935b26cf18f6833c526845ae8c41e58d09af6adeb6f0269183" + +[[package]] +name = "fastrand" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "486f806e73c5707928240ddc295403b1b93c96a02038563881c4a2fd84b81ac4" + +[[package]] +name = "filetime" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35c0522e981e68cbfa8c3f978441a5f34b30b96e146b33cd3359176b50fe8586" +dependencies = [ + "cfg-if", + "libc", + "libredox", + "windows-sys 0.59.0", +] + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "flate2" +version = "1.0.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c936bfdafb507ebbf50b8074c54fa31c5be9a1e7e5f467dd659697041407d07c" +dependencies = [ + "crc32fast", + "miniz_oxide 0.8.0", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f81ec6369c545a7d40e4589b5597581fa1c441fe1cce96dd1de43159910a36a2" + +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.87", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "gimli" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" + +[[package]] +name = "git2" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b903b73e45dc0c6c596f2d37eccece7c1c8bb6e4407b001096387c63d0d93724" +dependencies = [ + "bitflags", + "libc", + "libgit2-sys", + "log", + "openssl-probe", + "openssl-sys", + "url", +] + +[[package]] +name = "gix" +version = "0.66.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9048b8d1ae2104f045cb37e5c450fc49d5d8af22609386bfc739c11ba88995eb" +dependencies = [ + "gix-actor", + "gix-commitgraph", + "gix-config", + "gix-date", + "gix-diff", + "gix-discover", + "gix-features", + "gix-fs", + "gix-glob", + "gix-hash", + "gix-hashtable", + "gix-index", + "gix-lock", + "gix-object", + "gix-odb", + "gix-pack", + "gix-path", + "gix-ref", + "gix-refspec", + "gix-revision", + "gix-revwalk", + "gix-sec", + "gix-tempfile", + "gix-trace", + "gix-traverse", + "gix-url", + "gix-utils", + "gix-validate", + "once_cell", + "parking_lot", + "signal-hook", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-actor" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc19e312cd45c4a66cd003f909163dc2f8e1623e30a0c0c6df3776e89b308665" +dependencies = [ + "bstr", + "gix-date", + "gix-utils", + "itoa", + "thiserror", + "winnow", +] + +[[package]] +name = "gix-bitmap" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10f78312288bd02052be5dbc2ecbc342c9f4eb791986d86c0a5c06b92dc72efa" +dependencies = [ + "thiserror", +] + +[[package]] +name = "gix-chunk" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28b58ba04f0c004722344390af9dbc85888fbb84be1981afb934da4114d4cf" +dependencies = [ + "thiserror", +] + +[[package]] +name = "gix-commitgraph" +version = "0.24.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "133b06f67f565836ec0c473e2116a60fb74f80b6435e21d88013ac0e3c60fc78" +dependencies = [ + "bstr", + "gix-chunk", + "gix-features", + "gix-hash", + "memmap2", + "thiserror", +] + +[[package]] +name = "gix-config" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78e797487e6ca3552491de1131b4f72202f282fb33f198b1c34406d765b42bb0" +dependencies = [ + "bstr", + "gix-config-value", + "gix-features", + "gix-glob", + "gix-path", + "gix-ref", + "gix-sec", + "memchr", + "once_cell", + "smallvec", + "thiserror", + "unicode-bom", + "winnow", +] + +[[package]] +name = "gix-config-value" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3de3fdca9c75fa4b83a76583d265fa49b1de6b088ebcd210749c24ceeb74660" +dependencies = [ + "bitflags", + "bstr", + "gix-path", + "libc", + "thiserror", +] + +[[package]] +name = "gix-date" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d10d543ac13c97292a15e8e8b7889cd006faf739777437ed95362504b8fe81a0" +dependencies = [ + "bstr", + "itoa", + "jiff", + "thiserror", +] + +[[package]] +name = "gix-diff" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92c9afd80fff00f8b38b1c1928442feb4cd6d2232a6ed806b6b193151a3d336c" +dependencies = [ + "bstr", + "gix-hash", + "gix-object", + "thiserror", +] + +[[package]] +name = "gix-discover" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0577366b9567376bc26e815fd74451ebd0e6218814e242f8e5b7072c58d956d2" +dependencies = [ + "bstr", + "dunce", + "gix-fs", + "gix-hash", + "gix-path", + "gix-ref", + "gix-sec", + "thiserror", +] + +[[package]] +name = "gix-features" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac7045ac9fe5f9c727f38799d002a7ed3583cd777e3322a7c4b43e3cf437dc69" +dependencies = [ + "crc32fast", + "flate2", + "gix-hash", + "gix-trace", + "gix-utils", + "libc", + "once_cell", + "prodash", + "sha1_smol", + "thiserror", + "walkdir", +] + +[[package]] +name = "gix-fs" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2bfe6249cfea6d0c0e0990d5226a4cb36f030444ba9e35e0639275db8f98575" +dependencies = [ + "fastrand", + "gix-features", + "gix-utils", +] + +[[package]] +name = "gix-glob" +version = "0.16.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74908b4bbc0a0a40852737e5d7889f676f081e340d5451a16e5b4c50d592f111" +dependencies = [ + "bitflags", + "bstr", + "gix-features", + "gix-path", +] + +[[package]] +name = "gix-hash" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f93d7df7366121b5018f947a04d37f034717e113dcf9ccd85c34b58e57a74d5e" +dependencies = [ + "faster-hex", + "thiserror", +] + +[[package]] +name = "gix-hashtable" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ddf80e16f3c19ac06ce415a38b8591993d3f73aede049cb561becb5b3a8e242" +dependencies = [ + "gix-hash", + "hashbrown 0.14.5", + "parking_lot", +] + +[[package]] +name = "gix-index" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cd4203244444017682176e65fd0180be9298e58ed90bd4a8489a357795ed22d" +dependencies = [ + "bitflags", + "bstr", + "filetime", + "fnv", + "gix-bitmap", + "gix-features", + "gix-fs", + "gix-hash", + "gix-lock", + "gix-object", + "gix-traverse", + "gix-utils", + "gix-validate", + "hashbrown 0.14.5", + "itoa", + "libc", + "memmap2", + "rustix", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-lock" +version = "14.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bc7fe297f1f4614774989c00ec8b1add59571dc9b024b4c00acb7dedd4e19d" +dependencies = [ + "gix-tempfile", + "gix-utils", + "thiserror", +] + +[[package]] +name = "gix-object" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f5b801834f1de7640731820c2df6ba88d95480dc4ab166a5882f8ff12b88efa" +dependencies = [ + "bstr", + "gix-actor", + "gix-date", + "gix-features", + "gix-hash", + "gix-utils", + "gix-validate", + "itoa", + "smallvec", + "thiserror", + "winnow", +] + +[[package]] +name = "gix-odb" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3158068701c17df54f0ab2adda527f5a6aca38fd5fd80ceb7e3c0a2717ec747" +dependencies = [ + "arc-swap", + "gix-date", + "gix-features", + "gix-fs", + "gix-hash", + "gix-object", + "gix-pack", + "gix-path", + "gix-quote", + "parking_lot", + "tempfile", + "thiserror", +] + +[[package]] +name = "gix-pack" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3223aa342eee21e1e0e403cad8ae9caf9edca55ef84c347738d10681676fd954" +dependencies = [ + "clru", + "gix-chunk", + "gix-features", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-path", + "memmap2", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-path" +version = "0.10.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c04e5a94fdb56b1e91eb7df2658ad16832428b8eeda24ff1a0f0288de2bce554" +dependencies = [ + "bstr", + "gix-trace", + "home", + "once_cell", + "thiserror", +] + +[[package]] +name = "gix-quote" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f89f9a1525dcfd9639e282ea939f5ab0d09d93cf2b90c1fc6104f1b9582a8e49" +dependencies = [ + "bstr", + "gix-utils", + "thiserror", +] + +[[package]] +name = "gix-ref" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae0d8406ebf9aaa91f55a57f053c5a1ad1a39f60fdf0303142b7be7ea44311e5" +dependencies = [ + "gix-actor", + "gix-features", + "gix-fs", + "gix-hash", + "gix-lock", + "gix-object", + "gix-path", + "gix-tempfile", + "gix-utils", + "gix-validate", + "memmap2", + "thiserror", + "winnow", +] + +[[package]] +name = "gix-refspec" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebb005f82341ba67615ffdd9f7742c87787544441c88090878393d0682869ca6" +dependencies = [ + "bstr", + "gix-hash", + "gix-revision", + "gix-validate", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-revision" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba4621b219ac0cdb9256883030c3d56a6c64a6deaa829a92da73b9a576825e1e" +dependencies = [ + "bstr", + "gix-date", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-revwalk", + "gix-trace", + "thiserror", +] + +[[package]] +name = "gix-revwalk" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b41e72544b93084ee682ef3d5b31b1ba4d8fa27a017482900e5e044d5b1b3984" +dependencies = [ + "gix-commitgraph", + "gix-date", + "gix-hash", + "gix-hashtable", + "gix-object", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-sec" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2007538eda296445c07949cf04f4a767307d887184d6b3e83e2d636533ddc6e" +dependencies = [ + "bitflags", + "gix-path", + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "gix-tempfile" +version = "14.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046b4927969fa816a150a0cda2e62c80016fe11fb3c3184e4dddf4e542f108aa" +dependencies = [ + "gix-fs", + "libc", + "once_cell", + "parking_lot", + "signal-hook", + "signal-hook-registry", + "tempfile", +] + +[[package]] +name = "gix-trace" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04bdde120c29f1fc23a24d3e115aeeea3d60d8e65bab92cc5f9d90d9302eb952" + +[[package]] +name = "gix-traverse" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "030da39af94e4df35472e9318228f36530989327906f38e27807df305fccb780" +dependencies = [ + "bitflags", + "gix-commitgraph", + "gix-date", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-revwalk", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-url" +version = "0.27.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd280c5e84fb22e128ed2a053a0daeacb6379469be6a85e3d518a0636e160c89" +dependencies = [ + "bstr", + "gix-features", + "gix-path", + "home", + "thiserror", + "url", +] + +[[package]] +name = "gix-utils" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba427e3e9599508ed98a6ddf8ed05493db114564e338e41f6a996d2e4790335f" +dependencies = [ + "fastrand", + "unicode-normalization", +] + +[[package]] +name = "gix-validate" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e187b263461bc36cea17650141567753bc6207d036cedd1de6e81a52f277ff68" +dependencies = [ + "bstr", + "thiserror", +] + +[[package]] +name = "h2" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e8ac6999421f49a846c2d4411f337e53497d8ec55d67753beffa43c5d9205" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.6.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + +[[package]] +name = "hashbrown" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a9bfc1af68b1726ea47d3d5109de126281def866b33970e10fbab11b5dafab3" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + +[[package]] +name = "home" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "http" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" +dependencies = [ + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d71d3574edd2771538b901e6549113b4006ece66150fb69c0fb6d9a2adae946" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "human-panic" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80b84a66a325082740043a6c28bbea400c129eac0d3a27673a1de971e44bf1f7" +dependencies = [ + "anstream", + "anstyle", + "backtrace", + "os_info", + "serde", + "serde_derive", + "toml", + "uuid", +] + +[[package]] +name = "hyper" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97818827ef4f364230e16705d4706e2897df2bb60617d6ca15d598025a3c481f" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df2dcfbe0677734ab2f3ffa7fa7bfd4706bfdc1ef393f2ee30184aed67e631b4" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locid" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locid_transform" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_locid_transform_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locid_transform_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e" + +[[package]] +name = "icu_normalizer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "write16", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516" + +[[package]] +name = "icu_properties" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locid_transform", + "icu_properties_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569" + +[[package]] +name = "icu_provider" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_provider_macros", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_provider_macros" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.87", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indenter" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683" + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", +] + +[[package]] +name = "indexmap" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da" +dependencies = [ + "equivalent", + "hashbrown 0.15.1", +] + +[[package]] +name = "indoc" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5" + +[[package]] +name = "instability" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b829f37dead9dc39df40c2d3376c179fdfd2ac771f53f55d3c30dc096a3c0c6e" +dependencies = [ + "darling", + "indoc", + "pretty_assertions", + "proc-macro2", + "quote", + "syn 2.0.87", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + +[[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.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" + +[[package]] +name = "jiff" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d9d414fc817d3e3d62b2598616733f76c4cc74fbac96069674739b881295c8" +dependencies = [ + "jiff-tzdb-platform", + "windows-sys 0.59.0", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91335e575850c5c4c673b9bd467b0e025f164ca59d0564f69d0c2ee0ffad4653" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9835f0060a626fe59f160437bc725491a6af23133ea906500027d1bd2f8f4329" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "jobserver" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48d1dbcbbeb6a7fec7e059840aa538bd62aaccf972c7346c4d9d2059312853d0" +dependencies = [ + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a88f1bda2bd75b0452a14784937d796722fdebfe50df998aeb3f0b7603019a9" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "json5" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" +dependencies = [ + "pest", + "pest_derive", + "serde", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.164" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "433bfe06b8c75da9b2e3fbea6e5329ff87748f0b144ef75306e674c3f6f7c13f" + +[[package]] +name = "libgit2-sys" +version = "0.17.0+1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10472326a8a6477c3c20a64547b0059e4b0d086869eee31e6d7da728a8eb7224" +dependencies = [ + "cc", + "libc", + "libssh2-sys", + "libz-sys", + "openssl-sys", + "pkg-config", +] + +[[package]] +name = "libredox" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +dependencies = [ + "bitflags", + "libc", + "redox_syscall", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libssh2-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dc8a030b787e2119a731f1951d6a773e2280c660f8ec4b0f5e1505a386e71ee" +dependencies = [ + "cc", + "libc", + "libz-sys", + "openssl-sys", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libz-sys" +version = "1.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2d16453e800a8cf6dd2fc3eb4bc99b786a9b90c663b8559a5b1a041bf89e472" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" + +[[package]] +name = "litemap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "643cb0b8d4fcc284004d5fd0d67ccf61dfffadb7f75e1e71bc420f4688a3a704" + +[[package]] +name = "lock_api" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.1", +] + +[[package]] +name = "matchers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +dependencies = [ + "regex-automata 0.1.10", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "memmap2" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3f7eed9d3848f8b98834af67102b720745c4ec028fcd0aa0239277e7de374f" +dependencies = [ + "libc", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8a240ddb74feaf34a79a7add65a741f3167852fba007066dcac1ca548d89c08" +dependencies = [ + "adler", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2d80299ef12ff69b16a84bb182e3b9df68b5a91574d3d4fa6e41b65deec4df1" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" +dependencies = [ + "hermit-abi", + "libc", + "log", + "wasi", + "windows-sys 0.52.0", +] + +[[package]] +name = "multimap" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "defc4c55412d89136f966bbb339008b474350e5e6e78d2714439c386b3137a03" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "object" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" + +[[package]] +name = "openssl-probe" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" + +[[package]] +name = "openssl-sys" +version = "0.9.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45abf306cbf99debc8195b66b7346498d7b10c210de50418b5ccd7ceba08c741" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + +[[package]] +name = "os_info" +version = "3.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae99c7fa6dd38c7cafe1ec085e804f8f555a2f8659b0dbe03f1f9963a9b51092" +dependencies = [ + "log", + "serde", + "windows-sys 0.52.0", +] + +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + +[[package]] +name = "owo-colors" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1b04fb49957986fdce4d6ee7a65027d55d4b6d2265e5848bbb507b58ccfdb6f" + +[[package]] +name = "parking_lot" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets 0.52.6", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pathdiff" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d61c5ce1153ab5b689d0c074c4e7fc613e942dfb7dd9eea5ab202d2ad91fe361" + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "pest" +version = "2.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879952a81a83930934cbf1786752d6dedc3b1f29e8f8fb2ad1d0a36f377cf442" +dependencies = [ + "memchr", + "thiserror", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d214365f632b123a47fd913301e14c946c61d1c183ee245fa76eb752e59a02dd" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb55586734301717aea2ac313f50b2eb8f60d2fc3dc01d190eefa2e625f60c4e" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.87", +] + +[[package]] +name = "pest_meta" +version = "2.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75da2a70cf4d9cb76833c990ac9cd3923c9a8905a8929789ce347c84564d03d" +dependencies = [ + "once_cell", + "pest", + "sha2", +] + +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset", + "indexmap 2.6.0", +] + +[[package]] +name = "pin-project" +version = "1.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be57f64e946e500c8ee36ef6331845d40a93055567ec57e8fae13efd33759b95" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c0f5fad0874fc7abcd4d750e76917eaebbecaa2c20bde22e1dbeeba8beb758c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.87", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915a1e146535de9163f3987b8944ed8cf49a18bb0056bcebcdcece385cece4ff" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + +[[package]] +name = "prettyplease" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64d1ec885c64d0457d564db4ec299b2dae3f9c02808b8ad9c3a089c591b18033" +dependencies = [ + "proc-macro2", + "syn 2.0.87", +] + +[[package]] +name = "proc-macro2" +version = "1.0.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f139b0662de085916d1fb67d2b4169d1addddda1919e696f3252b740b629986e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prodash" +version = "28.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "744a264d26b88a6a7e37cbad97953fa233b94d585236310bcbc88474b4092d79" + +[[package]] +name = "prost" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b0487d90e047de87f984913713b85c601c05609aad5b0df4b4573fbf69aa13f" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1318b19085f08681016926435853bbf7858f9c082d0999b80550ff5d9abe15" +dependencies = [ + "bytes", + "heck", + "itertools", + "log", + "multimap", + "once_cell", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "regex", + "syn 2.0.87", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9552f850d5f0964a4e4d0bf306459ac29323ddfbae05e35a7c0d35cb0803cc5" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.87", +] + +[[package]] +name = "prost-types" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4759aa0d3a6232fb8dbdb97b61de2c20047c68aca932c7ed76da9d788508d670" +dependencies = [ + "prost", +] + +[[package]] +name = "quote" +version = "1.0.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "serde", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + +[[package]] +name = "redox_syscall" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b6dfecf2c74bce2466cabf93f6664d6998a69eb21e39f4207930065b27b771f" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom", + "libredox", + "thiserror", +] + +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata 0.4.9", + "regex-syntax 0.8.5", +] + +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +dependencies = [ + "regex-syntax 0.6.29", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax 0.8.5", +] + +[[package]] +name = "regex-syntax" +version = "0.6.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "ring" +version = "0.17.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "spin", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "ron" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94" +dependencies = [ + "base64 0.21.7", + "bitflags", + "serde", + "serde_derive", +] + +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink 0.9.1", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rust-ini" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0698206bcb8882bf2a9ecb4c1e7785db57ff052297085a6efd4fe42302068a" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" + +[[package]] +name = "rustix" +version = "0.38.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7f649912bc1495e167a6edee79151c84b1bad49748cb4f1f1167f459f6224f6" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls" +version = "0.23.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5065c3f250cbd332cd894be57c40fa52387247659b14a2d6041d121547903b1b" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f1201b3c9a7ee8039bcadc17b7e605e2945b27eee7631788c1bd2b0643674b" + +[[package]] +name = "rustls-webpki" +version = "0.102.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e819f2bc632f285be6d7cd36e25940d45b2391dd6d9b939e79de557f7014248" + +[[package]] +name = "ryu" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" +dependencies = [ + "serde", +] + +[[package]] +name = "serde" +version = "1.0.215" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6513c1ad0b11a9376da888e3e0baa0077f1aed55c17f50e7b2397136129fb88f" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.215" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad1e866f866923f252f05c889987993144fb74e722403468a4ebd70c3cd756c0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.87", +] + +[[package]] +name = "serde_json" +version = "1.0.133" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7fceb2473b9166b2294ef05efcb65a3db80803f0b03ef86a5fc88a2b85ee377" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_spanned" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" +dependencies = [ + "serde", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sha2" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" +dependencies = [ + "libc", +] + +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + +[[package]] +name = "smallvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" + +[[package]] +name = "socket2" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strip-ansi-escapes" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55ff8ef943b384c414f54aefa961dd2bd853add74ec75e7ac74cf91dba62bcfa" +dependencies = [ + "vte", +] + +[[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.87", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25aa4ce346d03a6dcd68dd8b4010bcb74e54e62c90c573f394c46eae99aba32d" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + +[[package]] +name = "sync_wrapper" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7065abeca94b6a8a577f9bd45aa0867a2238b74e8eb67cf10d492bc39351394" + +[[package]] +name = "synstructure" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.87", +] + +[[package]] +name = "tempfile" +version = "3.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28cce251fcbc87fac86a866eeb0d6c2d536fc16d06f184bb61aeae11aa4cee0c" +dependencies = [ + "cfg-if", + "fastrand", + "once_cell", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "terminal_size" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f599bd7ca042cfdf8f4512b277c02ba102247820f9d9d4a9f521f496751a6ef" +dependencies = [ + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.87", +] + +[[package]] +name = "thread_local" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" +dependencies = [ + "cfg-if", + "once_cell", +] + +[[package]] +name = "time" +version = "0.3.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" +dependencies = [ + "deranged", + "itoa", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" + +[[package]] +name = "time-macros" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tinystr" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "445e881f4f6d382d5f27c034e25eb92edd7c784ceab92a0937db7f2e9471b938" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.41.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfb5bee7a6a52939ca9224d6ac897bb669134078daa8735560897f69de4d33" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.52.0", +] + +[[package]] +name = "tokio-macros" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.87", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4" +dependencies = [ + "rustls", + "rustls-pki-types", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f4e6ce100d0eb49a2734f8c0812bcd324cf357d21810932c5df6b96ef2b86f1" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61e7c3654c13bcd040d4a03abee2c75b1d14a37b423cf5a813ceae1cc903ec6a" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1ed1f98e3fdc28d6d910e6737ae6ab1a93bf1985935a1193e68f93eeb68d24e" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" +dependencies = [ + "indexmap 2.6.0", + "serde", + "serde_spanned", + "toml_datetime", + "winnow", +] + +[[package]] +name = "tonic" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52" +dependencies = [ + "async-stream", + "async-trait", + "axum", + "base64 0.22.1", + "bytes", + "flate2", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "prost", + "rustls-pemfile", + "socket2", + "tokio", + "tokio-rustls", + "tokio-stream", + "tower 0.4.13", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-build" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9557ce109ea773b399c9b9e5dca39294110b74f1f342cb347a80d1fce8c26a11" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "prost-types", + "quote", + "syn 2.0.87", +] + +[[package]] +name = "tonic-health" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1eaf34ddb812120f5c601162d5429933c9b527d901ab0e7f930d3147e33a09b2" +dependencies = [ + "async-stream", + "prost", + "tokio", + "tokio-stream", + "tonic", +] + +[[package]] +name = "tonic-reflection" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "878d81f52e7fcfd80026b7fdb6a9b578b3c3653ba987f87f0dce4b64043cba27" +dependencies = [ + "prost", + "prost-types", + "tokio", + "tokio-stream", + "tonic", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "indexmap 1.9.3", + "pin-project", + "pin-project-lite", + "rand", + "slab", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2873938d487c3cfb9aed7546dc9f2711d867c9f90c46b889989a2cb84eba6b4f" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper 0.1.2", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.87", +] + +[[package]] +name = "tracing-core" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-error" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d686ec1c0f384b1277f097b2f279a2ecc11afe8c133c1aabf036a27cb4cd206e" +dependencies = [ + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex", + "serde", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tuxtape-poc" +version = "0.1.0" +dependencies = [ + "anyhow", + "better-panic", + "chrono", + "clap", + "color-eyre", + "config", + "const_format", + "crossterm", + "derive_deref", + "directories", + "edit", + "futures", + "git2", + "human-panic", + "lazy_static", + "libc", + "pretty_assertions", + "prost", + "ratatui", + "rusqlite", + "serde", + "serde_derive", + "serde_json", + "signal-hook", + "strip-ansi-escapes", + "strum", + "tokio", + "tokio-util", + "toml", + "tonic", + "tonic-build", + "tonic-health", + "tonic-reflection", + "tracing", + "tracing-error", + "tracing-subscriber", + "ureq", + "vergen-gix", +] + +[[package]] +name = "typenum" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicase" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e51b68083f157f853b6379db119d1c1be0e6e4dec98101079dec41f6f5cf6df" + +[[package]] +name = "unicode-bom" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eec5d1121208364f6793f7d2e222bf75a915c19557537745b195b253dd64217" + +[[package]] +name = "unicode-ident" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" + +[[package]] +name = "unicode-normalization" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[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 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74fc6b57825be3373f7054754755f03ac3a8f5d70015ccad699ba2029956f4a" +dependencies = [ + "base64 0.22.1", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "url", + "webpki-roots", +] + +[[package]] +name = "url" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d157f1b96d14500ffdc1f10ba712e780825526c03d9a49b4d0324b0d9113ada" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8c5f0a0af699448548ad1a2fbf920fb4bee257eae39953ba95cb84891a0446a" +dependencies = [ + "getrandom", +] + +[[package]] +name = "valuable" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "vergen" +version = "9.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349ed9e45296a581f455bc18039878f409992999bc1d5da12a6800eb18c8752f" +dependencies = [ + "anyhow", + "cargo_metadata", + "derive_builder", + "regex", + "rustversion", + "time", + "vergen-lib", +] + +[[package]] +name = "vergen-gix" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02ef5d49e57c96e025770171c1c7ee0e30cd6f712f21a1fe501a58be6d069192" +dependencies = [ + "anyhow", + "derive_builder", + "gix", + "rustversion", + "time", + "vergen", + "vergen-lib", +] + +[[package]] +name = "vergen-lib" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "229eaddb0050920816cf051e619affaf18caa3dd512de8de5839ccbc8e53abb0" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vte" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5022b5fbf9407086c180e9557be968742d839e68346af7792b8592489732197" +dependencies = [ + "utf8parse", + "vte_generate_state_changes", +] + +[[package]] +name = "vte_generate_state_changes" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e369bee1b05d510a7b4ed645f5faa90619e05437111783ea5848f28d97d3c2e" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasm-bindgen" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128d1e363af62632b8eb57219c8fd7877144af57558fb2ef0368d0087bddeb2e" +dependencies = [ + "cfg-if", + "once_cell", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb6dd4d3ca0ddffd1dd1c9c04f94b868c37ff5fac97c30b97cff2d74fce3a358" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.87", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79384be7f8f5a9dd5d7167216f022090cf1f9ec128e6e6a482a2cb5c5422c56" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26c6ab57572f7a24a4985830b120de1594465e5d500f24afe89e16b4e833ef68" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.87", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65fc09f10666a9f147042251e0dda9c18f166ff7de300607007e96bdebc1068d" + +[[package]] +name = "webpki-roots" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841c67bff177718f1d4dfefde8d8f0e78f9b6589319ba88312f567fc5841a958" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix", +] + +[[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-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys 0.59.0", +] + +[[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-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.6.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c1fec1a2bb5866f07c25f68c26e565c4c200aebb96d7e55710c19d3e8ac49b" +dependencies = [ + "memchr", +] + +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + +[[package]] +name = "writeable" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" + +[[package]] +name = "yaml-rust2" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8902160c4e6f2fb145dbe9d6760a75e3c9522d8bf796ed7047c85919ac7115f8" +dependencies = [ + "arraydeque", + "encoding_rs", + "hashlink 0.8.4", +] + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "yoke" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5b1314b079b0930c31e3af543d8ee1757b1951ae1e1565ec704403a7240ca5" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28cc31741b18cb6f1d5ff12f5b7523e3d6eb0852bbbad19d73905511d9849b95" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.87", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +dependencies = [ + "byteorder", + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.87", +] + +[[package]] +name = "zerofrom" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91ec111ce797d0e0784a1116d0ddcdbea84322cd79e5d5ad173daeba4f93ab55" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ea7b4a3637ea8669cedf0f1fd5c286a17f3de97b8dd5a70a6c167a1730e63a5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.87", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" + +[[package]] +name = "zerovec" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.87", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..7ba65d6 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,72 @@ +[package] +name = "tuxtape-poc" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "tuxtape-cve-parser" +path = "src/parser/parser.rs" + +[[bin]] +name = "tuxtape-server" +path = "src/server/server.rs" + +[[bin]] +name = "tuxtape-kernel-builder" +path = "src/kernel_builder/main.rs" + +[[bin]] +name = "tuxtape-dashboard" +path = "src/dashboard/main.rs" + +[dependencies] +anyhow = "1.0" +chrono = "0.4" +clap = { version = "4.5", features = [ + "derive", + "cargo", + "wrap_help", + "unicode", + "string", + "unstable-styles" +] } +const_format = "0.2" +git2 = "0.19" +prost = "0.13" +rusqlite = { version = "0.32", features = ["bundled", "array"] } +serde = { version = "1.0", features = ["derive", "rc"] } +serde_derive = "1.0" +serde_json = "1.0" +tokio = { version = "1.40", features = ["full"] } +tonic = {version = "0.12", features = ["tls", "gzip"] } +tonic-reflection = "0.12" +tonic-health = "0.12" +ureq = { version = "2.10", features = ["json"] } + +# Dependencies specific to tuxtape-dashboard +better-panic = "0.3" +color-eyre = "0.6" +config = "0.14" +crossterm = { version = "0.28", features = ["serde", "event-stream"] } +derive_deref = "1.1" +directories = "5.0" +edit = "0.1" +futures = "0.3" +human-panic = "2.0" +lazy_static = "1.5" +libc = "0.2" +pretty_assertions = "1.4" +ratatui = { version = "0.29", features = ["serde", "macros", "unstable-rendered-line-info"] } +signal-hook = "0.3" +strip-ansi-escapes = "0.2" +strum = { version = "0.26", features = ["derive"] } +toml = "0.8" +tokio-util = "0.7" +tracing = "0.1" +tracing-error = "0.2" +tracing-subscriber = { version = "0.3", features = ["env-filter", "serde"] } + +[build-dependencies] +tonic-build = "0.12" +# dependencies specific to tuxtape-dashboard +vergen-gix = { version = "1.0", features = ["build", "cargo"] } diff --git a/README.md b/README.md new file mode 100644 index 0000000..c21ca8e --- /dev/null +++ b/README.md @@ -0,0 +1,190 @@ +# TuxTape + +TuxTape is an ecosystem for generating, compiling, deploying, and installing Linux kernel livepatches. It is a toolchain for simplifying the workflow of [kpatch](https://github.com/dynup/kpatch). + +Kernel livepatching is a service provided by many large companies (Canonical, Red Hat, Oracle, SuSE, TuxCare, etc), but as of today, no open source toolchain exists to allow individuals to self manage such a service. Additionally, most of these companies (with the exception of TuxCare) only provide livepatching services for their own custom kernel, e.g. Red Hat will only provide livepatches for the RHEL kernel. + +The mission of TuxTape is not to invalidate these services. Reviewing patches, monitoring the success of patch application, and maintaining infrastructure to distribute patches are tasks that will make sense for many system administrators to outsource. + +One should consider TuxTape if they, whether for security reasons, cost reasons, or requirements to maintain custom kernels, have the need to maintain their own livepatching solution. + +## Development Status + +⚠️ **WARNING: This branch currently contains the proof-of-concept (PoC) of TuxTape. This is not meant to be utilized in production, and the project is expected to change dramatically from this state in the upcoming months. The PoC code is only shared for reference.** ⚠️ + +At this point in time, planning for the minimum viable product (MVP) is still in progress so implementation specifics are not yet available. + +For more information on TuxTape, please review our FOSDEM 2025 talk [here](https://fosdem.org/2025/schedule/event/fosdem-2025-5689-tuxtape-a-kernel-livepatching-solution/). + +## Pieces + +The full livepatch solution, once developed, will consist of the following pieces: + +1. Common Vulnerabilities and Exposures (CVE) Scanner: The kernel community is its own CVE Numbering Authority (CNA) and publishes all CVE information in a [public mailing list](https://lore.kernel.org/linux-cve-announce/) and in [a git tree](https://git.kernel.org/pub/scm/linux/security/vulns.git). The CVE scanner will monitor this list for vulnerabilities which affect files which are compiled into our kernel. Fortunately, each email lists links to the patches fixing the vulnerability. The scanner can be run as a cronjob. + +1. CVE Prioritizer: Unfortunately, since the kernel community believes that every bug is a possible security bug, the mailing list is very active. A method of prioritizing CVEs is still being devised. + +1. Applicability Gauge: For any CVE which is deemed high enough priority, we must also decide whether it is applicable. This step is separated from the prioritizer because a basic priority applies for the CVE across all kernels, while applicability is per kernel. Since TuxTape is built to support multiple kernel configurations and distributions besides just mainline, some CVEs will stem from source files which are built into some but not all kernels. The applicability gauge will determine, for each kernel, whether a CVE is applicable. + +1. Patch Generator: Once a CVE has been identified as worthy of live-patching, the Patch Generator will fetch the fixing commits and automatically generate a loadable module for the fix. In case the generator is unable to do so, it will send a notice to the system administrators to manually attempt to generate a livepatch module. Patches which are auto-generated will need to be carefully vetted through some combination of CI, heuristics, AI review, and human review. + +1. Kernel Log Parser: Analyzes kernel warnings to determine whether a livepatch module has misbehaved. + +1. Patch Archive: There is a need to publish all livepatch modules, as well as per-kernel and per-version lists of applicable modules. We are considering signing these using the [The Update Framework (TUF)](https://theupdateframework.io/) approach – signing using short-lived keys so that clients can be sure not to be handed stale data. The final state of the Patch Archive is still in discussion. + +1. Fleet Client: Every node in the fleet will run a lightweight client which tracks the kernel version and livepatch status of the node on which it runs. It will periodically fetch the latest information from the Patch Archive. See below for details about how we intend to handle cases like [a system being buggy after a livepatch update](https://web.archive.org/web/20240913235734/https://ubuntu.com/security/livepatch/docs/livepatch/reference/what_if_my_system_crashes). + +--- + +# tuxtape-poc + +This repo contains a proof of concept for TuxTape: a Linux kernel livepatching solution. + +> Note: TuxTape only supports kernels based on minor versions currently supported by the mainline kernel maintainers. Do not expect TuxTape to provide backported LTS-like support to non-LTS kernels. + +This branch does not contain all of the future aspects of TuxTape which will compile the patches and distribute them to clients, nor the client which makes requests for and installs those patches. + +The proof of concept builds four different binaries, which are detailed below. + +## tuxtape-cve-parser + +Parses the CVEs catalogued by the Linux kernel maintainers and generates a sqlite database of patch files. +Since this project requires the full Linux Stable branch to be pulled and thousands of patches to be generated and CVE data pulled from NIST APIs, +the first run will take a decent amount of time to complete (likely over an hour). Each successive run takes less time as the commit history of the kernel will only be pulled on first run, and successive runs only build patches +from the diff of the `HEAD` of the `vulns` repo at the last run and the current `HEAD`. +This should be run as a cronjob to update the database periodically. This database can be used in livepatching +solutions. + +The database is hardcoded to reside at `~/.cache/tuxtape-server/db.db3`. + +> WARNING: Since the patch files are automatically generated, this program should undergo extensive testing +which has not yet been done before being used in production. + +## tuxtape-server + +The server is used to query the sqlite database created by `tuxtape-cve-parser` and provide a gRPC API for clients like `tuxtape-dashboard` to utilize for the creation of `kpatch`-compatible patches (referred to as "deployable" patches). + +## tuxtape-kernel-builder + +This is an additional server that registers itself to `tuxtape-server` upon startup and serves requests to build kernels from configs generated by `tuxtape-dashboard`. Once it is done, it reports the build profile (what files were included in the build) back to `tuxtape-server` and it gets added to the database. + +> Note: This also requires the full git history of the Linux kernel to be pulled, so the first open will take a rather long time. If you already have the repo cloned, feel free to copy it to `~/.cache/tuxtape-kernel-builder/git/linux`. + +## tuxtape-dashboard + +A TUI dashboard used to create deployable patches from the "raw" patches stored in `tuxtape-server` and added to its database created by `tuxtape-cve-parser`. It will also be used to review deployable patches written by other kernel developers and deploy them to the fleet once approved. This dashboard is also used to create new kernel configs. + +> Note: This also requires the full git history of the Linux kernel to be pulled, so the first open will take a rather long time. If you already have the repo cloned, feel free to copy it to `~/.cache/tuxtape-dashboard/git/linux`. + +More detailed information about the TUI architecture can be found at `src/dashboard/README.md`. + +## Dependencies (Ubuntu 24.04) + +Build dependencies: +``` +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh + +sudo apt install build-essential pkg-config libssl-dev protobuf-compiler +``` + +Runtime dependencies for both `tuxtape-kernel-builder` and `tuxtape-dashboard` +``` +sudo apt install libncurses-dev bison flex libelf-dev +``` + +Additional runtime dependency for `tuxtape-kernel-builder` +``` +sudo apt install remake +``` + +## Running instructions + +``` +# To build +cargo build --all + +# For the parser +cargo run --bin tuxtape-cve-parser + +# For the server +cargo run --bin tuxtape-server + +# For the kernel builder +cargo run --bin tuxtape-kernel-builder + +# For the dashboard +cargo run --bin tuxtape-dashboard +``` + +## Testing TLS + +If you wish to test TLS, you will need certificates. To create self-signed certificates, follow the directions below: + +> Note: The following contains instructions for running only `tuxtape-server` and `tuxtape-dashboard` with TLS. To run `tuxtape-kernel-builder` with TLS, follow the same instructions, but keep in mind that you will need to generate a new CA with a different domain (like `tuxtape-kernel-builder.com`) as that is also a server, and you will need to create another entry for that domain in `/etc/hosts`. + +1. Create an encrypted certificate authority + +``` +openssl genrsa -aes256 -out ca-key.pem 4096 +``` + +2. Decrypt the certificate authority + +``` +openssl req -new -x509 -sha256 -days 365 -key ca-key.pem -out ca.pem +``` + +3. Extract the public certificate from the cert key + +``` +openssl genrsa -out cert-key.pem 4096 +``` + +4. Create a certificate signing request + +``` +openssl req -new -sha256 -subj "/CN=tuxtapecn" -key cert-key.pem -out cert.csr +``` + +5. Create an extfile + +``` +echo "subjectAltName=DNS:tuxtape-server.com,IP:127.0.0.1" >> extfile.cnf +``` + +6. Create a complete certificate authority + +``` +openssl x509 -req -sha256 -days 365 -in cert.csr -CA ca.pem -CAkey ca-key.pem -out cert.pem -extfile extfile.cnf -CAcreateserial +``` + +7. Create a full chain + +``` +cat cert.pem > fullchain.pem +cat ca.pem >> fullchain.pem +``` + +8. Create a local domain name for the server in `/etc/hosts`. + +``` +sudo sh -c "echo '127.0.0.1 tuxtape-server.com' >> /etc/hosts" +``` + +9. Modify the `tuxtape-dashboard` config file at `.config/tuxtape-dashboard-config.toml` (this will eventually be moved to a config directory that doesn't reside in the source code) to enable TLS by setting the following values: + +``` +[database] +server_url = "tuxtape-server.com:50051" +use_tls = true +tls_cert_path = "ca.pem" +``` + + +10. Run the server and client with the following arguments. + +``` +cargo run --bin tuxtape-server -- -t --tls-cert-path fullchain.pem --tls-key-path cert-key.pem --tls-ca-path ca.pem + +cargo run --bin tuxtape-dashboard +``` diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..4c86777 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,15 @@ +## Reporting a Vulnerability + +If you identify a potential security vulnerability in this project, please adhere to the following guidelines to ensure it is handled responsibly: + +1. **Do not disclose the vulnerability publicly.** Avoid creating a GitHub issue or sharing details in public forums to prevent exploitation before a fix is implemented. +2. **Contact us directly via email** at [OpenSourceSoftwareSecurity@geico.com](mailto:OpenSourceSoftwareSecurity@geico.com) with the following information: + - A detailed description of the vulnerability. + - Steps to reproduce the issue (if applicable). + - Any relevant logs, screenshots, or supporting data. + - Your contact information for follow-up (optional). +3. **Allow us sufficient time to address the issue.** We will acknowledge receipt of your report and work to resolve it as quickly as possible. +4. **Adhere to responsible disclosure practices.** Please refrain from publicly disclosing the issue until we confirm it has been mitigated. + +We greatly appreciate your contribution to improving the security of this project. + diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..97a6cb7 --- /dev/null +++ b/build.rs @@ -0,0 +1,26 @@ +use tonic_build::Config; +use vergen_gix::{BuildBuilder, CargoBuilder, Emitter, GixBuilder}; + +fn main() -> Result<(), Box> { + let build = BuildBuilder::all_build()?; + let gix = GixBuilder::all_git()?; + let cargo = CargoBuilder::all_cargo()?; + Emitter::default() + .add_instructions(&build)? + .add_instructions(&gix)? + .add_instructions(&cargo)? + .emit()?; + + let mut config = Config::new(); + config.type_attribute( + ".", + "#[derive(serde::Serialize, serde::Deserialize)] #[serde(rename_all = \"snake_case\")]", + ); + let descriptor_path = std::path::PathBuf::from(std::env::var("OUT_DIR").unwrap()) + .join("tuxtape_server_descriptor.bin"); + tonic_build::configure() + .file_descriptor_set_path(&descriptor_path) + .compile_protos_with_config(config, &["proto/tuxtape_server.proto"], &["proto/"])?; + + Ok(()) +} diff --git a/proto/tuxtape_server.proto b/proto/tuxtape_server.proto new file mode 100644 index 0000000..54b7814 --- /dev/null +++ b/proto/tuxtape_server.proto @@ -0,0 +1,154 @@ +syntax = "proto3"; +package tuxtape_server; + +service Database { + // Fetch CVEs sorted by base score. + rpc FetchCves(FetchCvesRequest) returns (FetchCvesReponse); + // Fetch all kernel configs metadata in the database. + rpc FetchKernelConfigsMetadata(FetchKernelConfigsMetadataRequest) returns (FetchKernelConfigsMetadataResponse); + // Fetch a kernel config from the database. + rpc FetchKernelConfig(FetchKernelConfigRequest) returns (FetchKernelConfigResponse); + // Put a kernel config into the database. + rpc PutKernelConfig(PutKernelConfigRequest) returns (PutKernelConfigResponse); + // Register a kernel builder. Called by tuxtape-kernel-builder. + rpc RegisterKernelBuilder(RegisterKernelBuilderRequest) returns (RegisterKernelBuilderResponse); +} + +service Builder { + // Builds a kernel then calls PutKernelBuild on the database server. + rpc BuildKernel(BuildKernelRequest) returns (BuildKernelResponse); +} + +message FetchCvesRequest { + // The metadata for the KernelConfigs which you are requesting CVEs for. + // If empty, returns CVEs for all KernelConfig in the database. + repeated KernelConfigMetadata kernel_configs_metadata = 1; + // If true, excludes CVEs which never received a patch in a later KernelVersion. + bool exclude_unpatched = 2; + // If true, excludes CVEs which already have a deployable patch. + bool exclude_deployable_patched = 3; +} + +message FetchCvesReponse { + repeated Cve cves = 1; +} + +message FetchKernelConfigsMetadataRequest { +} + +message FetchKernelConfigsMetadataResponse { + repeated KernelConfigMetadata metadata = 1; +} + +message FetchKernelConfigRequest { + KernelConfigMetadata metadata = 1; +} + +message FetchKernelConfigResponse { + KernelConfig kernel_config = 1; +} + +message KernelConfig { + // Metadata about the kernel config + KernelConfigMetadata metadata = 1; + // The config file itself. + string config_file = 2; +} + +message KernelConfigMetadata { + // The name of the kernel config file. + string config_name = 1; + // The kernel version that this config is to be built on. + KernelVersion kernel_version = 2; +} + +message Cve { + // The ID of the CVE. + string id = 1; + // Will be null if the CVE has not yet been evaluated by NIST. + optional float severity = 2; + // Will be null if the CVE has not yet been evaluated by NIST. + optional string attack_vector = 3; + // Will be null if the CVE has not yet been evaluated by NIST. + optional string attack_complexity = 4; + // Will be null if the CVE has not yet been evaluated by NIST. + optional string privileges_required = 5; + // Will be null if the CVE has not yet been evaluated by NIST. + optional string user_interaction = 6; + // Will be null if the CVE has not yet been evaluated by NIST. + optional string scope = 7; + // Will be null if the CVE has not yet been evaluated by NIST. + optional string confidentiality_impact = 8; + // Will be null if the CVE has not yet been evaluated by NIST. + optional string integrity_impact = 9; + // Will be null if the CVE has not yet been evaluated by NIST. + optional string availability_impact = 10; + // Will be null if the CVE has not yet been evaluated by NIST. + optional string description = 11; + // Instances of this CVE across different KernelVersions. + repeated CveInstance instances = 12; +} + +message CveInstance { + // The unique title for this specific instance of the CVE. + // Formatted {cve_id}-{introduced}-{fixed_commit-prefix}. + string title = 1; + // The KernelVersion in which the CVE was introduced. + KernelVersion introduced = 2; + // The KernelVersion that patched the CVE. + // Will be null if the CVE was not patched in a later KernelVersion. + optional KernelVersion fixed = 3; + // The prefix of the commit hash (first 12 characters) that + // patched the CVE in the fixed KernelVersion. + // Will be null if the CVE was not patched in a later KernelVersion. + optional string fixed_commit_prefix = 4; + // All files affected in this instance of the CVE. + // Will be empty if the CVE was not patched in a later KernelVersion. + repeated string affected_files = 5; + // Metadata on all kernel configs affected by this CveInstance. + // Will be empty if the CVE was not patched in a later KernelVerision or if no + // kernel build on the fleet contains the affected_files. + repeated KernelConfigMetadata affected_configs = 6; + // The raw git diff of the commit that patched the CVE in the fixed KernelVersion. + // Will be null if the CVE was not patched in a later KernelVersion. + optional string raw_patch = 7; + // The kpatch-compatible patch approved for deployment to the fleet. + // Will be null if no deployable patch was approved. + optional string deployable_patch = 8; +} + +message KernelVersion { + uint32 major = 1; + uint32 minor = 2; + optional uint32 patch = 3; +} + +message PutKernelConfigRequest { + // The kernel_config you wish to add to the database. + KernelConfig kernel_config = 1; +} + +message PutKernelConfigResponse { +} + +message PutKernelBuildResponse { +} + +message BuildKernelRequest { + // The KernelConfig to be built. + KernelConfig kernel_config = 1; +} + +message BuildKernelResponse { + // A list of file paths (from the root of the kernel source tree) + // that were included in this build. + repeated string included_files = 1; +} + +message RegisterKernelBuilderRequest { + // The address to the kernel builder. + string builder_address = 1; +} + +message RegisterKernelBuilderResponse { +} diff --git a/src/dashboard/README.md b/src/dashboard/README.md new file mode 100644 index 0000000..feffe35 --- /dev/null +++ b/src/dashboard/README.md @@ -0,0 +1,72 @@ +# tuxtape-dashboard + +A TUI dashboard for creating, reviewing, and submitting kpatch-compatible livepatches. + +## Design + +This project is based on the [`ratatui` Component template](https://ratatui.rs/templates/component/), but has some modifications to make the solution more domain specific and easier to maintain based on the needs of this project. Before contributing to this project, I suggest reading through the linked documentation as it breaks down most of the project structure of this program. The following documentation in this README is mostly meant to supplement that documentation and explain the changes made to that template. + +### Component + +A `Component` is an object that can be rendered onto the terminal (or to use `ratatui` lingo, `Frame`, which refers to the visible portion in your terminal emulator). + +> Note: the term `Widget` is used in the `ratatui` ecosystem to refer to something similar. For our purposes in this project, just consider a `Widget` to be a `Component` designed by the `ratatui` community. + +### Page + +A "Page" is a term which is specific to this project. It refers to a collection of `Component`s that work together to create something akin to a webpage. For example, the `HomePage` consists of the table of CVEs that the user utilizes to select CVEs they wish to edit. + +### Background + +Also specific to this project, the `Background` is the "anchor point" that all Pages are rendered onto. The `Background` should _always_ be rendered, so it's treated specially here. + +The `Background` visually consists of three things: + +1. `PageTabs` (used to navigate between the different Pages) +2. `PageManager::current_page` (the main thing the user is looking at and operating on) +3. `Footer` (used to display keybindings) + +The `Background` is the parent of `PageManager` which instantiates all the Pages and forwards `draw()` calls to the current Page. + +### Popup + +A `Popup` is a special kind of component that takes priority over everything. When a `Popup` is displayed, it intercepts all `Actions` that would be consumed by either the `Background` or current Page. It gets rendered on top of the Page as well. + +## Message passing + +We should avoid blocking for long times on the TUI thread as it will make the program feel unresponsive. To get around this, we use a message passing scheme. The root `Action` enum is located at `action.rs` and this represents any action that can occur in the program, be that a tick, call to render, an event from the terminal resizing, the user wishing to quit, a request to query the database, etc. When a module in the program has an `Action` that is specific to itself (e.g. `DatabaseAction`), it gets declared in that module (`database.rs::DatabaseAction`) and wrapped in a global `Action` in `actions.rs` (e.g. `Database(DatabaseAction)`). + +In the Components boilerplate, `KeyEvent`s (the user pressing a key) are interpereted into `Action`s in `App::handle_events()`. The `Action`s are received by `App::handle_actions()` and get sent "downstream" to things that can consume them. The stream looks as follows: + +``` +App -> + Background -> + (if exists) Popup -> + PageTabs -> + PageManager -> + PageManager[current_page] -> +Database (if DatabaseAction) +``` + +When writing a consumer of `Action`s, it should either fully consume an `Action` in its `update()`, change some of its own state based on the `Action` and forward the `Action` downstream, or ignore the `Action` entirely and forward it downstream. **Always** make sure that you are not fully consuming `Action`s that your `Component` doesn't need or that other `Component`s expect as they may be downstream from your consumer. + +For example, `HomePage::update()` may look like this: + +``` +fn update(&mut self, action: Action) -> Result> { + match &action { + Action::ScrollDown => self.table_state.scroll_down_by(1), + Action::ScrollUp => self.table_state.scroll_up_by(1), + Action::Database(DatabaseAction::Response(response)) => match response { + Response::PopulateTable(cve_instances) => self.build_table(cve_instances.clone()), + }, + Action::Select => self.edit_patch()?, + _ => return Ok(Some(action)), + } + Ok(None) +} +``` + +It cares about only four types of `Action`s: `ScrollDown`/`ScrollUp`/`Select` (used for navigation) and `Database(DatabaseAction::Response(PopulateTable))` (sent by `Database` after `HomePage` requests the information it needs to draw its table). The only time it returns `Ok(None)` is when it has fully consumed the action (since `HomePage` is the only thing in the stream that consumes for those four `Actions`, it doesn't need to forward them on). If it receives an `Action` it *doesn't* consume, the `_ => return Ok(Some(action))` case gets hit and the `Action` is forwarded down the stream. + +Every `Component` should have a member `command_tx: Option>` (it's an `Option` because the Components template registers all of the `command_tx` after the TUI is fully running, which I'm not sure is entirely necessary and may remove later). When an `Action` needs to be emitted by a `Component` (e.g. `HomePage` emitting an `Action::Database(DatabaseAction::Request))`), it should be sent via `self.command_tx.send()`. There is a single listener for the commands in `App` that will push all `Action`s emitted since the previous loop downstream in the main loop at `App::run()`. diff --git a/src/dashboard/action.rs b/src/dashboard/action.rs new file mode 100644 index 0000000..d4493fa --- /dev/null +++ b/src/dashboard/action.rs @@ -0,0 +1,33 @@ +use crate::database::DatabaseAction; +use crate::tui::pages::configs::ConfigsPageAction; +use crate::tui::pages::home::HomePageAction; +use crate::tui::pages::PageType; +use crate::tui::popups::PopupType; +use serde::Deserialize; +use strum::Display; + +#[derive(Debug, Clone, PartialEq, Display, Deserialize)] +pub enum Action { + Tick, + Render, + Resize(u16, u16), + Suspend, + Resume, + Quit, + ClearScreen, + Error(String), + Help, + TabLeft, + TabRight, + PaneLeft, + PaneRight, + ScrollDown, + ScrollUp, + Select, + ChangePage(PageType), + Database(DatabaseAction), + EditPatchAtPath(String), + Popup(PopupType), + HomePage(HomePageAction), + ConfigsPage(ConfigsPageAction), +} diff --git a/src/dashboard/app.rs b/src/dashboard/app.rs new file mode 100644 index 0000000..92ce758 --- /dev/null +++ b/src/dashboard/app.rs @@ -0,0 +1,243 @@ +use crate::{ + action::Action, + config::Config, + database::{Database, DatabaseAction}, + tui::{ + background::Background, components::Component, pages::configs::ConfigsPageAction, Event, + Tui, + }, +}; +use color_eyre::Result; +use crossterm::event::KeyEvent; +use ratatui::prelude::Rect; +use serde::{Deserialize, Serialize}; +use std::{path::Path, process::Command, sync::Arc}; +use tokio::sync::mpsc; +use tracing::{debug, info}; + +pub const CACHE_PATH: &str = concat!(env!("HOME"), "/.cache/tuxtape-dashboard"); +pub const GIT_PATH: &str = const_format::concatcp!(CACHE_PATH, "/git"); +pub const LINUX_REPO_PATH: &str = const_format::concatcp!(GIT_PATH, "/linux"); +pub const LINUX_REPO_URL: &str = "https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git"; + +pub struct App { + config: Arc, + db: Database, + background: Background, + should_quit: bool, + mode: Mode, + last_tick_key_events: Vec, + action_tx: mpsc::UnboundedSender, + action_rx: mpsc::UnboundedReceiver, +} + +#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Mode { + #[default] + /// The Background and Pages are displayed + Normal, + /// The TUI aspects of this program are suspended in favor of + /// displaying another program (such as a text editor) in the terminal + Suspended, +} + +impl App { + pub async fn new() -> Result { + init_linux_repo()?; + + let (action_tx, action_rx) = mpsc::unbounded_channel(); + let config = Arc::new(Config::new()?); + let db = Database::new(config.clone(), action_tx.clone()).await?; + Ok(Self { + config: config.clone(), + db, + background: Background::new(config)?, + should_quit: false, + mode: Mode::Normal, + last_tick_key_events: Vec::new(), + action_tx, + action_rx, + }) + } + + pub async fn run(&mut self) -> Result<()> { + let mut tui = Tui::new()?; + tui.enter()?; + + self.background + .register_action_handler(self.action_tx.clone())?; + + self.background.init(tui.size()?)?; + + loop { + self.handle_events(&mut tui).await?; + self.handle_actions(&mut tui)?; + if self.should_quit { + tui.stop()?; + break; + } + } + tui.exit()?; + Ok(()) + } + + async fn handle_events(&mut self, tui: &mut Tui) -> Result<()> { + let Some(event) = tui.next_event().await else { + return Ok(()); + }; + let action_tx = self.action_tx.clone(); + match event { + Event::Quit => action_tx.send(Action::Quit)?, + Event::Tick => action_tx.send(Action::Tick)?, + Event::Render => action_tx.send(Action::Render)?, + Event::Resize(x, y) => action_tx.send(Action::Resize(x, y))?, + Event::Key(key) => self.handle_key_event(key)?, + _ => {} + } + if let Some(action) = self.background.handle_events(Some(event.clone()))? { + action_tx.send(action)?; + } + Ok(()) + } + + fn handle_key_event(&mut self, key: KeyEvent) -> Result<()> { + let action_tx = self.action_tx.clone(); + let Some(keymap) = self.config.keybindings.get(&self.mode) else { + return Ok(()); + }; + match keymap.get(&vec![key]) { + Some(action) => { + info!("Got action: {action:?}"); + action_tx.send(action.clone())?; + } + _ => { + // If the key was not handled as a single key action, + // then consider it for multi-key combinations. + self.last_tick_key_events.push(key); + + // Check for multi-key combinations + if let Some(action) = keymap.get(&self.last_tick_key_events) { + info!("Got action: {action:?}"); + action_tx.send(action.clone())?; + } + } + } + Ok(()) + } + + fn handle_actions(&mut self, tui: &mut Tui) -> Result<()> { + while let Ok(action) = self.action_rx.try_recv() { + if action != Action::Tick && action != Action::Render { + debug!("{action:?}"); + } + + // Handle Actions that require TUI state changes before being pushed downstream + // (such as suspending the TUI) + // + // NOTE: Remove this clippy override when we add a second condition to this block. + // I anticipate CreateNewConfig will not be the only thing we need to check for here. + #[allow(clippy::single_match)] + match action { + Action::ConfigsPage(ref action) => match action { + ConfigsPageAction::CreateNewConfig(_) => { + self.suspend(tui)?; + } + }, + _ => {} + } + + // Forward Action to Background first and see if anything downstream consumes it. + let unconsumed_action = match self.background.update(action)? { + Some(action) => action, + None => continue, + }; + + // Handle Actions that were not consumed by downstream or returned from downstream + match unconsumed_action { + Action::Tick => { + self.last_tick_key_events.drain(..); + } + Action::Suspend => { + self.suspend(tui)?; + } + Action::Resume => { + self.resume(tui)?; + } + Action::ClearScreen => { + tui.terminal.clear()?; + } + Action::Resize(w, h) => { + self.handle_resize(tui, w, h)?; + } + Action::Render => { + self.render(tui)?; + } + Action::Database(DatabaseAction::Request(ref request)) => { + self.db.handle_request(request)?; + } + Action::EditPatchAtPath(ref path) => { + // TODO - I see no use in Modes right now. Maybe remove? + self.mode = Mode::Suspended; + tui.exit()?; + edit::edit_file(Path::new(path))?; + self.mode = Mode::Normal; + tui.enter()?; + tui.terminal.clear()?; + } + Action::Quit => self.should_quit = true, + _ => {} + } + } + + Ok(()) + } + + fn handle_resize(&mut self, tui: &mut Tui, w: u16, h: u16) -> Result<()> { + tui.resize(Rect::new(0, 0, w, h))?; + self.render(tui)?; + Ok(()) + } + + fn render(&mut self, tui: &mut Tui) -> Result<()> { + tui.draw(|frame| { + if let Err(err) = self.background.draw(frame, frame.area()) { + let _ = self + .action_tx + .send(Action::Error(format!("Failed to draw: {:?}", err))); + } + })?; + Ok(()) + } + + fn suspend(&mut self, tui: &mut Tui) -> Result<()> { + self.mode = Mode::Suspended; + tui.exit() + } + + fn resume(&mut self, tui: &mut Tui) -> Result<()> { + self.mode = Mode::Normal; + tui.enter()?; + tui.terminal.clear()?; + Ok(()) + } +} + +fn init_linux_repo() -> Result<()> { + if !Path::new(LINUX_REPO_PATH).exists() { + println!( + "Linux repo dir '{}' does not exist. Creating.", + LINUX_REPO_PATH + ); + std::fs::create_dir_all(LINUX_REPO_PATH)?; + } + + if !Path::new(format!("{}/.git", LINUX_REPO_PATH).as_str()).exists() { + Command::new("git") + .current_dir(LINUX_REPO_PATH) + .args(["clone", LINUX_REPO_URL]) + .spawn()? + .wait()?; + } + + Ok(()) +} diff --git a/src/dashboard/cli.rs b/src/dashboard/cli.rs new file mode 100644 index 0000000..4b7b69d --- /dev/null +++ b/src/dashboard/cli.rs @@ -0,0 +1,35 @@ +use clap::Parser; + +use crate::config::{get_config_dir, get_data_dir}; + +#[derive(Parser, Debug)] +#[command(author, version = version(), about)] +pub struct Cli { + // todo +} + +const VERSION_MESSAGE: &str = concat!( + env!("CARGO_PKG_VERSION"), + "-", + env!("VERGEN_GIT_DESCRIBE"), + " (", + env!("VERGEN_BUILD_DATE"), + ")" +); + +pub fn version() -> String { + let author = clap::crate_authors!(); + + let config_dir_path = get_config_dir().display().to_string(); + let data_dir_path = get_data_dir().display().to_string(); + + format!( + "\ +{VERSION_MESSAGE} + +Authors: {author} + +Config directory: {config_dir_path} +Data directory: {data_dir_path}" + ) +} diff --git a/src/dashboard/config.rs b/src/dashboard/config.rs new file mode 100644 index 0000000..e56d01e --- /dev/null +++ b/src/dashboard/config.rs @@ -0,0 +1,603 @@ +// TODO - remove this once we're out of PoC +#![allow(dead_code)] + +use crate::{action::Action, app::Mode}; +use color_eyre::Result; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use derive_deref::{Deref, DerefMut}; +use directories::ProjectDirs; +use lazy_static::lazy_static; +use ratatui::style::{Color, Modifier, Style}; +use serde::{de::Deserializer, Deserialize}; +use std::{collections::HashMap, env, path::PathBuf}; + +const CONFIG: &str = include_str!("../../.config/tuxtape-dashboard-config.toml"); + +#[derive(Clone, Debug, Deserialize, Default)] +pub struct AppConfig { + #[serde(default)] + pub data_dir: PathBuf, + #[serde(default)] + pub config_dir: PathBuf, +} + +#[derive(Clone, Debug, Deserialize, Default)] +pub struct DatabaseConfig { + #[serde(default)] + pub server_url: String, + #[serde(default)] + pub use_tls: bool, + #[serde(default)] + pub tls_cert_path: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +pub struct Config { + #[serde(default, flatten)] + pub config: AppConfig, + #[serde(default)] + pub keybindings: KeyBindings, + #[serde(default)] + pub styles: Styles, + #[serde(default)] + pub database: DatabaseConfig, +} + +lazy_static! { + pub static ref PROJECT_NAME: String = env!("CARGO_CRATE_NAME").to_uppercase().to_string(); + pub static ref DATA_FOLDER: Option = + env::var(format!("{}_DATA", PROJECT_NAME.clone())) + .ok() + .map(PathBuf::from); + pub static ref CONFIG_FOLDER: Option = + env::var(format!("{}_CONFIG", PROJECT_NAME.clone())) + .ok() + .map(PathBuf::from); +} + +impl Config { + pub fn new() -> Result { + let config_file = "config.toml"; + let config_format = config::FileFormat::Toml; + + let default_config: Config = + toml::de::from_str(CONFIG).expect(".config/tuxtape-dashboard-config.toml should exist"); + let data_dir = get_data_dir(); + let config_dir = get_config_dir(); + let mut builder = config::Config::builder() + .set_default("data_dir", data_dir.to_str().unwrap())? + .set_default("config_dir", config_dir.to_str().unwrap())?; + + let source = config::File::from(config_dir.join(config_file)) + .format(config_format) + .required(false); + builder = builder.add_source(source); + + let mut cfg: Self = builder.build()?.try_deserialize()?; + + for (mode, default_bindings) in default_config.keybindings.iter() { + let user_bindings = cfg.keybindings.entry(*mode).or_default(); + for (key, cmd) in default_bindings.iter() { + user_bindings + .entry(key.clone()) + .or_insert_with(|| cmd.clone()); + } + } + for (mode, default_styles) in default_config.styles.iter() { + let user_styles = cfg.styles.entry(*mode).or_default(); + for (style_key, style) in default_styles.iter() { + user_styles.entry(style_key.clone()).or_insert(*style); + } + } + + // TODO - don't overwrite user config with default config + // Should probably just force copy the default config to user config destination + // if it doesn't exist and then only read from that. + cfg.database.server_url = default_config.database.server_url; + cfg.database.use_tls = default_config.database.use_tls; + cfg.database.tls_cert_path = default_config.database.tls_cert_path; + + Ok(cfg) + } +} + +pub fn get_data_dir() -> PathBuf { + let directory = if let Some(s) = DATA_FOLDER.clone() { + s + } else if let Some(proj_dirs) = project_directory() { + proj_dirs.data_local_dir().to_path_buf() + } else { + PathBuf::from(".").join(".data") + }; + directory +} + +pub fn get_config_dir() -> PathBuf { + let directory = if let Some(s) = CONFIG_FOLDER.clone() { + s + } else if let Some(proj_dirs) = project_directory() { + proj_dirs.config_local_dir().to_path_buf() + } else { + PathBuf::from(".").join(".config") + }; + directory +} + +fn project_directory() -> Option { + ProjectDirs::from("com", "tuxtape", env!("CARGO_PKG_NAME")) +} + +#[derive(Clone, Debug, Default, Deref, DerefMut)] +pub struct KeyBindings(pub HashMap, Action>>); + +impl<'de> Deserialize<'de> for KeyBindings { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let parsed_map = HashMap::>::deserialize(deserializer)?; + + let keybindings = parsed_map + .into_iter() + .map(|(mode, inner_map)| { + let converted_inner_map = inner_map + .into_iter() + .map(|(key_str, cmd)| (parse_key_sequence(&key_str).unwrap(), cmd)) + .collect(); + (mode, converted_inner_map) + }) + .collect(); + + Ok(KeyBindings(keybindings)) + } +} + +fn parse_key_event(raw: &str) -> Result { + let raw_lower = raw.to_ascii_lowercase(); + let (remaining, modifiers) = extract_modifiers(&raw_lower); + parse_key_code_with_modifiers(remaining, modifiers) +} + +fn extract_modifiers(raw: &str) -> (&str, KeyModifiers) { + let mut modifiers = KeyModifiers::empty(); + let mut current = raw; + + loop { + match current { + rest if rest.starts_with("ctrl-") => { + modifiers.insert(KeyModifiers::CONTROL); + current = &rest[5..]; + } + rest if rest.starts_with("alt-") => { + modifiers.insert(KeyModifiers::ALT); + current = &rest[4..]; + } + rest if rest.starts_with("shift-") => { + modifiers.insert(KeyModifiers::SHIFT); + current = &rest[6..]; + } + _ => break, // break out of the loop if no known prefix is detected + }; + } + + (current, modifiers) +} + +fn parse_key_code_with_modifiers( + raw: &str, + mut modifiers: KeyModifiers, +) -> Result { + let c = match raw { + "esc" => KeyCode::Esc, + "enter" => KeyCode::Enter, + "left" => KeyCode::Left, + "right" => KeyCode::Right, + "up" => KeyCode::Up, + "down" => KeyCode::Down, + "home" => KeyCode::Home, + "end" => KeyCode::End, + "pageup" => KeyCode::PageUp, + "pagedown" => KeyCode::PageDown, + "backtab" => { + modifiers.insert(KeyModifiers::SHIFT); + KeyCode::BackTab + } + "backspace" => KeyCode::Backspace, + "delete" => KeyCode::Delete, + "insert" => KeyCode::Insert, + "f1" => KeyCode::F(1), + "f2" => KeyCode::F(2), + "f3" => KeyCode::F(3), + "f4" => KeyCode::F(4), + "f5" => KeyCode::F(5), + "f6" => KeyCode::F(6), + "f7" => KeyCode::F(7), + "f8" => KeyCode::F(8), + "f9" => KeyCode::F(9), + "f10" => KeyCode::F(10), + "f11" => KeyCode::F(11), + "f12" => KeyCode::F(12), + "space" => KeyCode::Char(' '), + "hyphen" => KeyCode::Char('-'), + "minus" => KeyCode::Char('-'), + "tab" => KeyCode::Tab, + c if c.len() == 1 => { + let mut c = c.chars().next().unwrap(); + if modifiers.contains(KeyModifiers::SHIFT) { + c = c.to_ascii_uppercase(); + } + KeyCode::Char(c) + } + _ => return Err(format!("Unable to parse {raw}")), + }; + Ok(KeyEvent::new(c, modifiers)) +} + +pub fn key_event_to_string(key_event: &KeyEvent) -> String { + let char; + let key_code = match key_event.code { + KeyCode::Backspace => "backspace", + KeyCode::Enter => "enter", + KeyCode::Left => "left", + KeyCode::Right => "right", + KeyCode::Up => "up", + KeyCode::Down => "down", + KeyCode::Home => "home", + KeyCode::End => "end", + KeyCode::PageUp => "pageup", + KeyCode::PageDown => "pagedown", + KeyCode::Tab => "tab", + KeyCode::BackTab => "backtab", + KeyCode::Delete => "delete", + KeyCode::Insert => "insert", + KeyCode::F(c) => { + char = format!("f({c})"); + &char + } + KeyCode::Char(' ') => "space", + KeyCode::Char(c) => { + char = c.to_string(); + &char + } + KeyCode::Esc => "esc", + KeyCode::Null => "", + KeyCode::CapsLock => "", + KeyCode::Menu => "", + KeyCode::ScrollLock => "", + KeyCode::Media(_) => "", + KeyCode::NumLock => "", + KeyCode::PrintScreen => "", + KeyCode::Pause => "", + KeyCode::KeypadBegin => "", + KeyCode::Modifier(_) => "", + }; + + let mut modifiers = Vec::with_capacity(3); + + if key_event.modifiers.intersects(KeyModifiers::CONTROL) { + modifiers.push("ctrl"); + } + + if key_event.modifiers.intersects(KeyModifiers::SHIFT) { + modifiers.push("shift"); + } + + if key_event.modifiers.intersects(KeyModifiers::ALT) { + modifiers.push("alt"); + } + + let mut key = modifiers.join("-"); + + if !key.is_empty() { + key.push('-'); + } + key.push_str(key_code); + + key +} + +pub fn parse_key_sequence(raw: &str) -> Result, String> { + if raw.chars().filter(|c| *c == '>').count() != raw.chars().filter(|c| *c == '<').count() { + return Err(format!("Unable to parse `{}`", raw)); + } + let raw = if !raw.contains("><") { + let raw = raw.strip_prefix('<').unwrap_or(raw); + let raw = raw.strip_prefix('>').unwrap_or(raw); + raw + } else { + raw + }; + let sequences = raw + .split("><") + .map(|seq| { + if let Some(s) = seq.strip_prefix('<') { + s + } else if let Some(s) = seq.strip_suffix('>') { + s + } else { + seq + } + }) + .collect::>(); + + sequences.into_iter().map(parse_key_event).collect() +} + +#[derive(Clone, Debug, Default, Deref, DerefMut)] +pub struct Styles(pub HashMap>); + +impl<'de> Deserialize<'de> for Styles { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let parsed_map = HashMap::>::deserialize(deserializer)?; + + let styles = parsed_map + .into_iter() + .map(|(mode, inner_map)| { + let converted_inner_map = inner_map + .into_iter() + .map(|(str, style)| (str, parse_style(&style))) + .collect(); + (mode, converted_inner_map) + }) + .collect(); + + Ok(Styles(styles)) + } +} + +pub fn parse_style(line: &str) -> Style { + let (foreground, background) = + line.split_at(line.to_lowercase().find("on ").unwrap_or(line.len())); + let foreground = process_color_string(foreground); + let background = process_color_string(&background.replace("on ", "")); + + let mut style = Style::default(); + if let Some(fg) = parse_color(&foreground.0) { + style = style.fg(fg); + } + if let Some(bg) = parse_color(&background.0) { + style = style.bg(bg); + } + style = style.add_modifier(foreground.1 | background.1); + style +} + +fn process_color_string(color_str: &str) -> (String, Modifier) { + let color = color_str + .replace("grey", "gray") + .replace("bright ", "") + .replace("bold ", "") + .replace("underline ", "") + .replace("inverse ", ""); + + let mut modifiers = Modifier::empty(); + if color_str.contains("underline") { + modifiers |= Modifier::UNDERLINED; + } + if color_str.contains("bold") { + modifiers |= Modifier::BOLD; + } + if color_str.contains("inverse") { + modifiers |= Modifier::REVERSED; + } + + (color, modifiers) +} + +fn parse_color(s: &str) -> Option { + let s = s.trim_start(); + let s = s.trim_end(); + if s.contains("bright color") { + let s = s.trim_start_matches("bright "); + let c = s + .trim_start_matches("color") + .parse::() + .unwrap_or_default(); + Some(Color::Indexed(c.wrapping_shl(8))) + } else if s.contains("color") { + let c = s + .trim_start_matches("color") + .parse::() + .unwrap_or_default(); + Some(Color::Indexed(c)) + } else if s.contains("gray") { + let c = 232 + + s.trim_start_matches("gray") + .parse::() + .unwrap_or_default(); + Some(Color::Indexed(c)) + } else if s.contains("rgb") { + let red = (s.as_bytes()[3] as char).to_digit(10).unwrap_or_default() as u8; + let green = (s.as_bytes()[4] as char).to_digit(10).unwrap_or_default() as u8; + let blue = (s.as_bytes()[5] as char).to_digit(10).unwrap_or_default() as u8; + let c = 16 + red * 36 + green * 6 + blue; + Some(Color::Indexed(c)) + } else if s == "bold black" { + Some(Color::Indexed(8)) + } else if s == "bold red" { + Some(Color::Indexed(9)) + } else if s == "bold green" { + Some(Color::Indexed(10)) + } else if s == "bold yellow" { + Some(Color::Indexed(11)) + } else if s == "bold blue" { + Some(Color::Indexed(12)) + } else if s == "bold magenta" { + Some(Color::Indexed(13)) + } else if s == "bold cyan" { + Some(Color::Indexed(14)) + } else if s == "bold white" { + Some(Color::Indexed(15)) + } else if s == "black" { + Some(Color::Indexed(0)) + } else if s == "red" { + Some(Color::Indexed(1)) + } else if s == "green" { + Some(Color::Indexed(2)) + } else if s == "yellow" { + Some(Color::Indexed(3)) + } else if s == "blue" { + Some(Color::Indexed(4)) + } else if s == "magenta" { + Some(Color::Indexed(5)) + } else if s == "cyan" { + Some(Color::Indexed(6)) + } else if s == "white" { + Some(Color::Indexed(7)) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn test_parse_style_default() { + let style = parse_style(""); + assert_eq!(style, Style::default()); + } + + #[test] + fn test_parse_style_foreground() { + let style = parse_style("red"); + assert_eq!(style.fg, Some(Color::Indexed(1))); + } + + #[test] + fn test_parse_style_background() { + let style = parse_style("on blue"); + assert_eq!(style.bg, Some(Color::Indexed(4))); + } + + #[test] + fn test_parse_style_modifiers() { + let style = parse_style("underline red on blue"); + assert_eq!(style.fg, Some(Color::Indexed(1))); + assert_eq!(style.bg, Some(Color::Indexed(4))); + } + + #[test] + fn test_process_color_string() { + let (color, modifiers) = process_color_string("underline bold inverse gray"); + assert_eq!(color, "gray"); + assert!(modifiers.contains(Modifier::UNDERLINED)); + assert!(modifiers.contains(Modifier::BOLD)); + assert!(modifiers.contains(Modifier::REVERSED)); + } + + #[test] + fn test_parse_color_rgb() { + let color = parse_color("rgb123"); + let expected = 16 + 36 + 2 * 6 + 3; + assert_eq!(color, Some(Color::Indexed(expected))); + } + + #[test] + fn test_parse_color_unknown() { + let color = parse_color("unknown"); + assert_eq!(color, None); + } + + #[test] + fn test_config() -> Result<()> { + let c = Config::new()?; + assert_eq!( + c.keybindings + .get(&Mode::Normal) + .unwrap() + .get(&parse_key_sequence("").unwrap_or_default()) + .unwrap(), + &Action::Quit + ); + Ok(()) + } + + #[test] + fn test_simple_keys() { + assert_eq!( + parse_key_event("a").unwrap(), + KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty()) + ); + + assert_eq!( + parse_key_event("enter").unwrap(), + KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()) + ); + + assert_eq!( + parse_key_event("esc").unwrap(), + KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()) + ); + } + + #[test] + fn test_with_modifiers() { + assert_eq!( + parse_key_event("ctrl-a").unwrap(), + KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL) + ); + + assert_eq!( + parse_key_event("alt-enter").unwrap(), + KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT) + ); + + assert_eq!( + parse_key_event("shift-esc").unwrap(), + KeyEvent::new(KeyCode::Esc, KeyModifiers::SHIFT) + ); + } + + #[test] + fn test_multiple_modifiers() { + assert_eq!( + parse_key_event("ctrl-alt-a").unwrap(), + KeyEvent::new( + KeyCode::Char('a'), + KeyModifiers::CONTROL | KeyModifiers::ALT + ) + ); + + assert_eq!( + parse_key_event("ctrl-shift-enter").unwrap(), + KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL | KeyModifiers::SHIFT) + ); + } + + #[test] + fn test_reverse_multiple_modifiers() { + assert_eq!( + key_event_to_string(&KeyEvent::new( + KeyCode::Char('a'), + KeyModifiers::CONTROL | KeyModifiers::ALT + )), + "ctrl-alt-a".to_string() + ); + } + + #[test] + fn test_invalid_keys() { + assert!(parse_key_event("invalid-key").is_err()); + assert!(parse_key_event("ctrl-invalid-key").is_err()); + } + + #[test] + fn test_case_insensitivity() { + assert_eq!( + parse_key_event("CTRL-a").unwrap(), + KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL) + ); + + assert_eq!( + parse_key_event("AlT-eNtEr").unwrap(), + KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT) + ); + } +} diff --git a/src/dashboard/database.rs b/src/dashboard/database.rs new file mode 100644 index 0000000..21f9ec5 --- /dev/null +++ b/src/dashboard/database.rs @@ -0,0 +1,155 @@ +/// This file should hold the single connection to the TuxTape database needed for this dashboard. +/// Every function here should be `async` so requests to the database do not block the TUI. +mod tuxtape_server { + tonic::include_proto!("tuxtape_server"); +} + +use crate::action::Action; +use crate::config::Config; +use color_eyre::Result; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use strum::Display; +use tokio::sync::mpsc::UnboundedSender; +use tonic::{ + codec::CompressionEncoding, + transport::{Certificate, Channel, ClientTlsConfig}, +}; +use tuxtape_server::{database_client::DatabaseClient, FetchCvesRequest, PutKernelConfigRequest}; +pub use tuxtape_server::{Cve, CveInstance, KernelConfig, KernelConfigMetadata, KernelVersion}; + +#[derive(Clone)] +pub struct Database { + client: DatabaseClient, + command_tx: UnboundedSender, +} + +impl Database { + pub async fn new(config: Arc, command_tx: UnboundedSender) -> Result { + let url = match config.database.use_tls { + true => format!("https://{}", config.database.server_url), + false => format!("http://{}", config.database.server_url), + }; + + // Strip port from URL if one was provided + let domain_name = if let Some(domain_name) = config + .database + .server_url + .split(':') + .collect::>() + .first() + { + *domain_name + } else { + &config.database.server_url + }; + + let channel = + match config.database.use_tls { + true => { + // TODO - improve error handling + let pem = + std::fs::read_to_string(config.database.tls_cert_path.as_ref().expect( + "tls cert does not exist but option use_tls = true in config", + ))?; + let ca = Certificate::from_pem(pem); + + let tls = ClientTlsConfig::new() + .ca_certificate(ca) + .domain_name(domain_name); + + Channel::from_shared(url)? + .tls_config(tls)? + .connect() + .await? + } + false => Channel::from_shared(url)?.connect().await?, + }; + + let client = DatabaseClient::new(channel) + .accept_compressed(CompressionEncoding::Gzip) + .send_compressed(CompressionEncoding::Gzip) + .max_decoding_message_size(usize::MAX) + .max_encoding_message_size(usize::MAX); + + Ok(Self { client, command_tx }) + } + + pub fn handle_request(&self, request: &Request) -> Result<()> { + match request { + Request::PopulateTable() => { + tokio::task::spawn(fetch_all_relevant_cves(self.clone())); + } + Request::PutKernelConfig(kernel_config) => { + tokio::task::spawn(put_kernel_config(self.clone(), kernel_config.clone())); + } + } + + Ok(()) + } +} + +async fn fetch_all_relevant_cves(mut db: Database) { + let request = create_fetch_cves_request(None, false, false); + let response = db.client.fetch_cves(request).await.unwrap(); + let cves = response + .into_inner() + .cves + .iter() + .map(|cve| Arc::new(cve.clone())) + .collect(); + + let database_response = Response::PopulateTable(cves); + let action = Action::Database(DatabaseAction::Response(database_response)); + let _ = db.command_tx.send(action); +} + +fn create_fetch_cves_request( + kernel_configs_metadata: Option>, + exclude_unpatched: bool, + exclude_deployable_patched: bool, +) -> tonic::Request { + let kernel_configs_metadata = kernel_configs_metadata.unwrap_or_default(); + + let fetch_cve_req = FetchCvesRequest { + kernel_configs_metadata, + exclude_unpatched, + exclude_deployable_patched, + }; + tonic::Request::new(fetch_cve_req) +} + +async fn put_kernel_config(mut db: Database, kernel_config: KernelConfig) { + let request = PutKernelConfigRequest { + kernel_config: Some(kernel_config.clone()), + }; + let response = db.client.put_kernel_config(request).await; + + let database_response = Response::PutKernelConfig { + kernel_config_metadata: kernel_config.metadata, + success: response.is_ok(), + }; + let action = Action::Database(DatabaseAction::Response(database_response)); + db.command_tx.send(action).expect("Should not fail to send"); +} + +#[derive(Debug, Clone, PartialEq, Display, Serialize, Deserialize)] +pub enum DatabaseAction { + Request(Request), + Response(Response), +} + +#[derive(Debug, Clone, PartialEq, Display, Serialize, Deserialize)] +pub enum Request { + PopulateTable(), + PutKernelConfig(KernelConfig), +} + +#[derive(Debug, Clone, PartialEq, Display, Serialize, Deserialize)] +pub enum Response { + PopulateTable(Vec>), + PutKernelConfig { + kernel_config_metadata: Option, + success: bool, + }, +} diff --git a/src/dashboard/errors.rs b/src/dashboard/errors.rs new file mode 100644 index 0000000..8ebd401 --- /dev/null +++ b/src/dashboard/errors.rs @@ -0,0 +1,75 @@ +use color_eyre::Result; +use std::env; +use tracing::error; + +pub fn init() -> Result<()> { + let (panic_hook, eyre_hook) = color_eyre::config::HookBuilder::default() + .panic_section(format!( + "This is a bug. Consider reporting it at {}", + env!("CARGO_PKG_REPOSITORY") + )) + .capture_span_trace_by_default(false) + .display_location_section(false) + .display_env_section(false) + .into_hooks(); + eyre_hook.install()?; + std::panic::set_hook(Box::new(move |panic_info| { + if let Ok(mut t) = crate::tui::Tui::new() { + if let Err(r) = t.exit() { + error!("Unable to exit Terminal: {:?}", r); + } + } + + #[cfg(not(debug_assertions))] + { + use human_panic::{handle_dump, metadata, print_msg}; + let metadata = metadata!(); + let file_path = handle_dump(&metadata, panic_info); + // prints human-panic message + print_msg(file_path, &metadata) + .expect("human-panic: printing error message to console failed"); + eprintln!("{}", panic_hook.panic_report(panic_info)); // prints color-eyre stack trace to stderr + } + let msg = format!("{}", panic_hook.panic_report(panic_info)); + error!("Error: {}", strip_ansi_escapes::strip_str(msg)); + + #[cfg(debug_assertions)] + { + // Better Panic stacktrace that is only enabled when debugging. + better_panic::Settings::auto() + .most_recent_first(false) + .lineno_suffix(true) + .verbosity(better_panic::Verbosity::Full) + .create_panic_handler()(panic_info); + } + + std::process::exit(libc::EXIT_FAILURE); + })); + Ok(()) +} + +/// Similar to the `std::dbg!` macro, but generates `tracing` events rather +/// than printing to stdout. +/// +/// By default, the verbosity level for the generated events is `DEBUG`, but +/// this can be customized. +#[macro_export] +macro_rules! trace_dbg { + (target: $target:expr, level: $level:expr, $ex:expr) => {{ + match $ex { + value => { + tracing::event!(target: $target, $level, ?value, stringify!($ex)); + value + } + } + }}; + (level: $level:expr, $ex:expr) => { + trace_dbg!(target: module_path!(), level: $level, $ex) + }; + (target: $target:expr, $ex:expr) => { + trace_dbg!(target: $target, level: tracing::Level::DEBUG, $ex) + }; + ($ex:expr) => { + trace_dbg!(level: tracing::Level::DEBUG, $ex) + }; +} diff --git a/src/dashboard/logging.rs b/src/dashboard/logging.rs new file mode 100644 index 0000000..1d8e59d --- /dev/null +++ b/src/dashboard/logging.rs @@ -0,0 +1,35 @@ +use crate::config; +use color_eyre::Result; +use tracing_error::ErrorLayer; +use tracing_subscriber::{fmt, prelude::*, EnvFilter}; + +lazy_static::lazy_static! { + pub static ref LOG_ENV: String = format!("{}_LOG_LEVEL", config::PROJECT_NAME.clone()); + pub static ref LOG_FILE: String = format!("{}.log", env!("CARGO_PKG_NAME")); +} + +pub fn init() -> Result<()> { + let directory = config::get_data_dir(); + std::fs::create_dir_all(directory.clone())?; + let log_path = directory.join(LOG_FILE.clone()); + let log_file = std::fs::File::create(log_path)?; + let env_filter = EnvFilter::builder().with_default_directive(tracing::Level::INFO.into()); + // If the `RUST_LOG` environment variable is set, use that as the default, otherwise use the + // value of the `LOG_ENV` environment variable. If the `LOG_ENV` environment variable contains + // errors, then this will return an error. + let env_filter = env_filter + .try_from_env() + .or_else(|_| env_filter.with_env_var(LOG_ENV.clone()).from_env())?; + let file_subscriber = fmt::layer() + .with_file(true) + .with_line_number(true) + .with_writer(log_file) + .with_target(false) + .with_ansi(false) + .with_filter(env_filter); + tracing_subscriber::registry() + .with(file_subscriber) + .with(ErrorLayer::default()) + .try_init()?; + Ok(()) +} diff --git a/src/dashboard/main.rs b/src/dashboard/main.rs new file mode 100644 index 0000000..6615a18 --- /dev/null +++ b/src/dashboard/main.rs @@ -0,0 +1,25 @@ +use crate::app::App; +use clap::Parser; +use cli::Cli; +use color_eyre::Result; + +mod action; +mod app; +mod cli; +mod config; +mod database; +mod errors; +mod logging; +mod tui; + +#[tokio::main] +async fn main() -> Result<()> { + crate::errors::init()?; + crate::logging::init()?; + + // TODO - decide if we should remove CLI args entirely + let _args = Cli::parse(); + let mut app = App::new().await?; + app.run().await?; + Ok(()) +} diff --git a/src/dashboard/tui.rs b/src/dashboard/tui.rs new file mode 100644 index 0000000..8702680 --- /dev/null +++ b/src/dashboard/tui.rs @@ -0,0 +1,230 @@ +// TODO - remove this once we're out of PoC +#![allow(dead_code)] + +pub mod background; +pub mod components; +pub mod pages; +pub mod popups; + +use color_eyre::Result; +use crossterm::{ + cursor, + event::{ + DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, + Event as CrosstermEvent, EventStream, KeyEvent, KeyEventKind, MouseEvent, + }, + terminal::{EnterAlternateScreen, LeaveAlternateScreen}, +}; +use futures::{FutureExt, StreamExt}; +use ratatui::backend::CrosstermBackend as Backend; +use serde::{Deserialize, Serialize}; +use std::{ + io::{stdout, Stdout}, + ops::{Deref, DerefMut}, + time::Duration, +}; +use tokio::{ + sync::mpsc::{self, UnboundedReceiver, UnboundedSender}, + task::JoinHandle, + time::interval, +}; +use tokio_util::sync::CancellationToken; +use tracing::error; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum Event { + Init, + Quit, + Error, + Closed, + Tick, + Render, + FocusGained, + FocusLost, + Paste(String), + Key(KeyEvent), + Mouse(MouseEvent), + Resize(u16, u16), +} + +pub struct Tui { + pub terminal: ratatui::Terminal>, + pub task: JoinHandle<()>, + pub cancellation_token: CancellationToken, + pub event_rx: UnboundedReceiver, + pub event_tx: UnboundedSender, + pub frame_rate: f64, + pub tick_rate: f64, + pub mouse: bool, + pub paste: bool, +} + +impl Tui { + pub fn new() -> Result { + let (event_tx, event_rx) = mpsc::unbounded_channel(); + Ok(Self { + terminal: ratatui::Terminal::new(Backend::new(stdout()))?, + task: tokio::spawn(async {}), + cancellation_token: CancellationToken::new(), + event_rx, + event_tx, + frame_rate: 60.0, + tick_rate: 4.0, + mouse: false, + paste: false, + }) + } + + pub fn mouse(mut self, mouse: bool) -> Self { + self.mouse = mouse; + self + } + + pub fn paste(mut self, paste: bool) -> Self { + self.paste = paste; + self + } + + pub fn start(&mut self) { + self.cancel(); // Cancel any existing task + self.cancellation_token = CancellationToken::new(); + let event_loop = Self::event_loop( + self.event_tx.clone(), + self.cancellation_token.clone(), + self.tick_rate, + self.frame_rate, + ); + self.task = tokio::spawn(async { + event_loop.await; + }); + } + + async fn event_loop( + event_tx: UnboundedSender, + cancellation_token: CancellationToken, + tick_rate: f64, + frame_rate: f64, + ) { + let mut event_stream = EventStream::new(); + let mut tick_interval = interval(Duration::from_secs_f64(1.0 / tick_rate)); + let mut render_interval = interval(Duration::from_secs_f64(1.0 / frame_rate)); + + // if this fails, then it's likely a bug in the calling code + event_tx + .send(Event::Init) + .expect("failed to send init event"); + loop { + let event = tokio::select! { + _ = cancellation_token.cancelled() => { + break; + } + _ = tick_interval.tick() => Event::Tick, + _ = render_interval.tick() => Event::Render, + crossterm_event = event_stream.next().fuse() => match crossterm_event { + Some(Ok(event)) => match event { + CrosstermEvent::Key(key) if key.kind == KeyEventKind::Press => Event::Key(key), + CrosstermEvent::Mouse(mouse) => Event::Mouse(mouse), + CrosstermEvent::Resize(x, y) => Event::Resize(x, y), + CrosstermEvent::FocusLost => Event::FocusLost, + CrosstermEvent::FocusGained => Event::FocusGained, + CrosstermEvent::Paste(s) => Event::Paste(s), + _ => continue, // ignore other events + } + Some(Err(_)) => Event::Error, + None => break, // the event stream has stopped and will not produce any more events + }, + }; + if event_tx.send(event).is_err() { + // the receiver has been dropped, so there's no point in continuing the loop + break; + } + } + cancellation_token.cancel(); + } + + pub fn stop(&self) -> Result<()> { + self.cancel(); + let mut counter = 0; + while !self.task.is_finished() { + std::thread::sleep(Duration::from_millis(1)); + counter += 1; + if counter > 50 { + self.task.abort(); + } + if counter > 100 { + error!("Failed to abort task in 100 milliseconds for unknown reason"); + break; + } + } + Ok(()) + } + + pub fn enter(&mut self) -> Result<()> { + crossterm::terminal::enable_raw_mode()?; + crossterm::execute!(stdout(), EnterAlternateScreen, cursor::Hide)?; + if self.mouse { + crossterm::execute!(stdout(), EnableMouseCapture)?; + } + if self.paste { + crossterm::execute!(stdout(), EnableBracketedPaste)?; + } + self.start(); + Ok(()) + } + + pub fn exit(&mut self) -> Result<()> { + self.stop()?; + if crossterm::terminal::is_raw_mode_enabled()? { + self.flush()?; + if self.paste { + crossterm::execute!(stdout(), DisableBracketedPaste)?; + } + if self.mouse { + crossterm::execute!(stdout(), DisableMouseCapture)?; + } + crossterm::execute!(stdout(), LeaveAlternateScreen, cursor::Show)?; + crossterm::terminal::disable_raw_mode()?; + } + Ok(()) + } + + pub fn cancel(&self) { + self.cancellation_token.cancel(); + } + + pub fn suspend(&mut self) -> Result<()> { + self.exit()?; + #[cfg(not(windows))] + signal_hook::low_level::raise(signal_hook::consts::signal::SIGTSTP)?; + Ok(()) + } + + pub fn resume(&mut self) -> Result<()> { + self.enter()?; + Ok(()) + } + + pub async fn next_event(&mut self) -> Option { + self.event_rx.recv().await + } +} + +impl Deref for Tui { + type Target = ratatui::Terminal>; + + fn deref(&self) -> &Self::Target { + &self.terminal + } +} + +impl DerefMut for Tui { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.terminal + } +} + +impl Drop for Tui { + fn drop(&mut self) { + self.exit().unwrap(); + } +} diff --git a/src/dashboard/tui/background.rs b/src/dashboard/tui/background.rs new file mode 100644 index 0000000..86010be --- /dev/null +++ b/src/dashboard/tui/background.rs @@ -0,0 +1,285 @@ +use crate::tui::components::*; +use crate::{ + action::Action, app::Mode, config::Config, tui::pages::*, tui::popups, tui::popups::PopupType, +}; +use color_eyre::{eyre::eyre, Result}; +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::{ + prelude::*, + style::{Color, Stylize}, + widgets::*, +}; +use std::collections::HashMap; +use std::sync::Arc; +use strum::{EnumCount, IntoEnumIterator}; +use tokio::sync::mpsc::UnboundedSender; + +/// The root from which all other `Page`s will be mounted. +/// The background will run at all times. +pub struct Background { + command_tx: Option>, + tabs: PageTabs, + page_manager: PageManager, + footer: Footer, + popup: Option>, +} + +impl Background { + pub fn new(config: Arc) -> Result { + Ok(Self { + command_tx: None, + tabs: PageTabs::default(), + page_manager: PageManager::new(config.clone()), + footer: Footer::new(config), + popup: None, + }) + } + + fn display_popup(&mut self, popup_type: PopupType) -> Result<()> { + match popup_type { + PopupType::Alert(alert_text) => { + self.popup = Some(Box::new(popups::Alert::new(alert_text))); + } + PopupType::CveEditPreview(cve_instance) => { + let command_tx = match self.command_tx.as_ref() { + Some(command_tx) => command_tx, + None => { + return Err(eyre!( + "self.command_tx should always exist by the time this is called" + )) + } + }; + + self.popup = Some(Box::new(popups::CveEditPreview::new( + cve_instance, + command_tx.clone(), + ))); + } + } + + Ok(()) + } +} + +impl Component for Background { + fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { + self.command_tx = Some(tx.clone()); + self.page_manager.register_action_handler(tx.clone()); + self.tabs.register_action_handler(tx) + } + + fn update(&mut self, action: Action) -> Result> { + // If there is a Popup, let it be the first consumer of events. + let maybe_unconsumed_action = match &mut self.popup { + // If there is a Popup, check first if the user is trying to close it before forwarding the action into the Popup + Some(popup) => match action { + Action::Quit => { + self.popup = None; + // Consume the Quit event + None + } + _ => popup.update(action)?, + }, + None => Some(action), + }; + let unconsumed_action = match maybe_unconsumed_action { + Some(action) => action, + None => return Ok(None), + }; + + let unconsumed_action = match self.tabs.update(unconsumed_action)? { + Some(action) => action, + None => return Ok(None), + }; + + let unconsumed_action = match self.page_manager.update(unconsumed_action)? { + Some(action) => action, + None => return Ok(None), + }; + + // Consume Background-specific actions here + match unconsumed_action { + Action::Popup(popup_type) => { + self.display_popup(popup_type)?; + Ok(None) + } + _ => Ok(Some(unconsumed_action)), + } + } + + fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage(2), + Constraint::Percentage(98), + Constraint::Percentage(2), + ]) + .split(area); + + self.tabs.draw(frame, chunks[0])?; + self.page_manager + .get_current_page() + .draw(frame, chunks[1])?; + self.footer.draw(frame, chunks[2])?; + + if let Some(popup) = &mut self.popup { + let popup_area = chunks[1].inner(Margin::new(1, 1)); + + if let Err(err) = popup.draw(frame, popup_area) { + if let Some(command_tx) = &mut self.command_tx { + let _ = command_tx.send(Action::Error(format!("Failed to draw: {:?}", err))); + } + } + } + + Ok(()) + } +} + +// Implement extra functionality to Page enum for use in PageTabs. +impl PageType { + /// Get the next tab. If there is no next tab, loop around to first tab. + fn next(self) -> Self { + let current_index = self as usize; + const MAX_PAGE_INDEX: usize = PageType::COUNT - 1; + let next_index = match current_index { + MAX_PAGE_INDEX => 0, + _ => current_index + 1, + }; + Self::from_repr(next_index).unwrap_or(self) + } + + /// Get the previous tab. If there is no previous tab, loop around to last tab. + fn previous(self) -> Self { + let current_index = self as usize; + let previous_index = match current_index { + 0 => PageType::COUNT - 1, + _ => current_index - 1, + }; + Self::from_repr(previous_index).unwrap_or(self) + } + + /// Return tab's name as a styled `Line` + fn title(self) -> Line<'static> { + format!(" {self} ") + .fg(Color::White) + .bg(Color::DarkGray) + .into() + } +} + +#[derive(Default)] +struct PageTabs { + command_tx: Option>, + selected_tab: PageType, +} + +impl Component for PageTabs { + fn update(&mut self, action: Action) -> Result> { + match action { + Action::TabLeft => { + self.selected_tab = self.selected_tab.previous(); + Ok(Some(Action::ChangePage(self.selected_tab))) + } + Action::TabRight => { + self.selected_tab = self.selected_tab.next(); + Ok(Some(Action::ChangePage(self.selected_tab))) + } + _ => Ok(Some(action)), + } + } + + fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { + self.command_tx = Some(tx.clone()); + Ok(()) + } + + fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { + let titles = PageType::iter().map(PageType::title); + let highlight_style = (Color::White, Color::Green); + let selected_tab_index = self.selected_tab as usize; + Tabs::new(titles) + .highlight_style(highlight_style) + .select(selected_tab_index) + .padding("", "") + .divider(" ") + .render(area, frame.buffer_mut()); + + Ok(()) + } +} + +struct Footer { + text: String, +} + +impl Footer { + fn new(config: Arc) -> Self { + let keybindings = config + .keybindings + .0 + .get(&Mode::Normal) + .expect("Program should have panicked by now if config didn't exist"); + + let tab_left_keys = Footer::find_keycodes_for_action(keybindings, &Action::TabLeft); + let tab_right_keys = Footer::find_keycodes_for_action(keybindings, &Action::TabRight); + let pane_left_keys = Footer::find_keycodes_for_action(keybindings, &Action::PaneLeft); + let pane_right_keys = Footer::find_keycodes_for_action(keybindings, &Action::PaneRight); + let scroll_down_keys = Footer::find_keycodes_for_action(keybindings, &Action::ScrollDown); + let scroll_up_keys = Footer::find_keycodes_for_action(keybindings, &Action::ScrollUp); + let select_keys = Footer::find_keycodes_for_action(keybindings, &Action::Select); + let quit_keys = Footer::find_keycodes_for_action(keybindings, &Action::Quit); + + let text = format!( + "[Pane ←/→: {}/{}] [Tab ←/→: {}/{}] [Scroll ↑/↓: {}/{}] [Select: {}] [Quit: {}]", + Footer::keycodes_to_display_text(pane_left_keys), + Footer::keycodes_to_display_text(pane_right_keys), + Footer::keycodes_to_display_text(tab_left_keys), + Footer::keycodes_to_display_text(tab_right_keys), + Footer::keycodes_to_display_text(scroll_up_keys), + Footer::keycodes_to_display_text(scroll_down_keys), + Footer::keycodes_to_display_text(select_keys), + Footer::keycodes_to_display_text(quit_keys) + ); + + Self { text } + } + + /// Return the `KeyCode`(s) (plural if a key combo is used) that map to an `Action` + fn find_keycodes_for_action( + map: &HashMap, Action>, + value: &Action, + ) -> Vec { + let vec: Option> = map.iter().find_map(|(key, val)| { + if val == value { + Some(key.iter().map(|key| key.code).collect()) + } else { + None + } + }); + + vec.unwrap_or_default() + } + + /// Return a String of displayable text for all `KeyCode`(s) in the vec. + fn keycodes_to_display_text(keycodes: Vec) -> String { + let mut retval = String::new(); + for (i, keycode) in keycodes.iter().enumerate() { + if i > 0 { + retval.push('+'); + } + retval.push_str(keycode.to_string().as_str()); + } + + retval + } +} + +impl Component for Footer { + fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { + frame.render_widget(Paragraph::new(self.text.clone()).centered(), area); + + Ok(()) + } +} diff --git a/src/dashboard/tui/components.rs b/src/dashboard/tui/components.rs new file mode 100644 index 0000000..23f86b6 --- /dev/null +++ b/src/dashboard/tui/components.rs @@ -0,0 +1,108 @@ +use crate::{action::Action, tui::Event}; +use color_eyre::Result; +use crossterm::event::{KeyEvent, MouseEvent}; +use ratatui::{ + layout::{Rect, Size}, + Frame, +}; +use tokio::sync::mpsc::UnboundedSender; + +/// `Component` is a trait that represents a visual and interactive element of the user interface. +/// +/// Implementors of this trait can be registered with the main application loop and will be able to +/// receive events, update state, and be rendered on the screen. +pub trait Component { + /// Register an action handler that can send actions for processing if necessary. + /// + /// # Arguments + /// + /// * `tx` - An unbounded sender that can send actions. + /// + /// # Returns + /// + /// * `Result<()>` - An Ok result or an error. + fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { + let _ = tx; // to appease clippy + Ok(()) + } + /// Initialize the component with a specified area if necessary. + /// + /// # Arguments + /// + /// * `area` - Rectangular area to initialize the component within. + /// + /// # Returns + /// + /// * `Result<()>` - An Ok result or an error. + fn init(&mut self, area: Size) -> Result<()> { + let _ = area; // to appease clippy + Ok(()) + } + /// Handle incoming events and produce actions if necessary. + /// + /// # Arguments + /// + /// * `event` - An optional event to be processed. + /// + /// # Returns + /// + /// * `Result>` - An action to be processed or none. + fn handle_events(&mut self, event: Option) -> Result> { + let action = match event { + Some(Event::Key(key_event)) => self.handle_key_event(key_event)?, + Some(Event::Mouse(mouse_event)) => self.handle_mouse_event(mouse_event)?, + _ => None, + }; + Ok(action) + } + /// Handle key events and produce actions if necessary. + /// + /// # Arguments + /// + /// * `key` - A key event to be processed. + /// + /// # Returns + /// + /// * `Result>` - An action to be processed or none. + fn handle_key_event(&mut self, key: KeyEvent) -> Result> { + let _ = key; // to appease clippy + Ok(None) + } + /// Handle mouse events and produce actions if necessary. + /// + /// # Arguments + /// + /// * `mouse` - A mouse event to be processed. + /// + /// # Returns + /// + /// * `Result>` - An action to be processed or none. + fn handle_mouse_event(&mut self, mouse: MouseEvent) -> Result> { + let _ = mouse; // to appease clippy + Ok(None) + } + /// Update the state of the component based on a received action. (REQUIRED) + /// + /// # Arguments + /// + /// * `action` - An action that may modify the state of the component. + /// + /// # Returns + /// + /// * `Result>` - An action to be processed or none. + fn update(&mut self, action: Action) -> Result> { + // Just pass through Action by default + Ok(Some(action)) + } + /// Render the component on the screen. (REQUIRED) + /// + /// # Arguments + /// + /// * `f` - A frame used for rendering. + /// * `area` - The area in which the component should be drawn. + /// + /// # Returns + /// + /// * `Result<()>` - An Ok result or an error. + fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()>; +} diff --git a/src/dashboard/tui/pages.rs b/src/dashboard/tui/pages.rs new file mode 100644 index 0000000..d42e1a9 --- /dev/null +++ b/src/dashboard/tui/pages.rs @@ -0,0 +1,74 @@ +use crate::{action::Action, config::Config, tui::components::*}; +use color_eyre::Result; +use configs::ConfigsPage; +use home::HomePage; +use serde::{Deserialize, Serialize}; +use std::{collections::HashMap, hash::Hash, sync::Arc}; +use strum::{Display, EnumCount, EnumIter, FromRepr}; +use tokio::sync::mpsc::UnboundedSender; + +pub mod configs; +pub mod home; + +#[derive( + Default, + Debug, + Clone, + Copy, + PartialEq, + Eq, + Hash, + Display, + FromRepr, + EnumIter, + EnumCount, + Serialize, + Deserialize, +)] +pub enum PageType { + #[default] + #[strum(to_string = "Home")] + Home, + #[strum(to_string = "Configs")] + Configs, +} + +pub struct PageManager { + pages: HashMap>, + current_page: PageType, +} + +impl PageManager { + pub fn new(config: Arc) -> Self { + let mut pages: HashMap> = HashMap::new(); + pages.insert(PageType::Home, Box::new(HomePage::new(config))); + pages.insert(PageType::Configs, Box::new(ConfigsPage::new())); + + Self { + pages, + current_page: PageType::Home, + } + } + + pub fn update(&mut self, action: Action) -> Result> { + match action { + Action::ChangePage(page_type) => self.current_page = page_type, + _ => return self.get_current_page().update(action), + } + + Ok(Some(action)) + } + + pub fn get_current_page(&mut self) -> &mut Box { + self.pages + .get_mut(&self.current_page) + .expect("Attempted to get a Page that isn't registered") + } + + pub fn register_action_handler(&mut self, tx: UnboundedSender) { + for (_, page) in self.pages.iter_mut() { + page.register_action_handler(tx.clone()) + .expect("Registering page should never fail"); + } + } +} diff --git a/src/dashboard/tui/pages/configs.rs b/src/dashboard/tui/pages/configs.rs new file mode 100644 index 0000000..22bcca0 --- /dev/null +++ b/src/dashboard/tui/pages/configs.rs @@ -0,0 +1,268 @@ +use crate::action::Action; +use crate::app::LINUX_REPO_PATH; +use crate::database::Request::PutKernelConfig; +use crate::database::{DatabaseAction, KernelConfig, KernelConfigMetadata, KernelVersion}; +use crate::tui::components::Component; +use color_eyre::eyre::eyre; +use color_eyre::Result; +use ratatui::layout::Rect; +use ratatui::prelude::*; +use ratatui::widgets::*; +use ratatui::Frame; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::process::Command; +use tokio::sync::mpsc::UnboundedSender; + +/// The page for creating new configs (and eventually for viewing already-made configs) +#[derive(Default)] +pub struct ConfigsPage { + command_tx: Option>, + versions_list_state: ListState, + kernel_versions: Vec, +} + +impl ConfigsPage { + pub fn new() -> Self { + let git_versions = Command::new("git") + .current_dir(LINUX_REPO_PATH) + .arg("--no-pager") + .arg("tag") + .args(["--sort", "-v:refname"]) + .output() + .unwrap() + .stdout; + + let mut kernel_version_strings: Vec = Vec::new(); + let mut current_string = "".to_string(); + for character in git_versions { + match character { + b'\n' => { + let binding = ¤t_string; + if !binding.contains("-") { + // Filter out strings that are in w.x.y.z format (occured in v2.x) + if current_string.chars().filter(|c| *c == '.').count() < 3 { + kernel_version_strings.push(current_string.clone()); + } + } + current_string.clear(); + } + b'v' => { + // Don't push 'v' into our array as we only want version numbers + } + _ => { + current_string.push( + char::from_u32(character.into()) + .expect("Git should never give us a bad value"), + ); + } + } + } + + let kernel_versions = Vec::from_iter( + kernel_version_strings + .iter() + .flat_map(|git_version_str| str_to_kernel_version(git_version_str)), + ); + + let mut list_state = ListState::default(); + list_state.select_first(); + + Self { + command_tx: None, + versions_list_state: list_state, + kernel_versions, + } + } +} + +impl Component for ConfigsPage { + fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { + self.command_tx = Some(tx); + + Ok(()) + } + + fn update(&mut self, action: Action) -> Result> { + match action { + Action::ScrollDown => { + self.versions_list_state.select_next(); + } + Action::ScrollUp => { + self.versions_list_state.select_previous(); + } + Action::Select => { + // Defer CreateNewConfig action because the TUI must be suspended first + if let Some(command_tx) = &self.command_tx { + let kernel_version = self.kernel_versions[self + .versions_list_state + .selected() + .expect("Something is always selected")]; + command_tx.send(Action::ConfigsPage(ConfigsPageAction::CreateNewConfig( + kernel_version, + )))?; + } + } + Action::ConfigsPage(ref action) => match action { + ConfigsPageAction::CreateNewConfig(kernel_version) => { + let config = create_new_config(kernel_version)?; + if let Some(command_tx) = &self.command_tx { + command_tx.send(Action::Resume)?; + command_tx.send(Action::Database(DatabaseAction::Request( + PutKernelConfig(config), + )))?; + } + } + }, + _ => return Ok(Some(action)), + } + Ok(Some(action)) + } + + fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { + let list: List<'_> = List::new( + self.kernel_versions + .iter() + .map(kernel_version_to_string) + .clone(), + ) + .highlight_symbol(">>") + .highlight_style(Style::new().reversed()); + frame.render_stateful_widget(list, area, &mut self.versions_list_state); + Ok(()) + } +} + +fn create_new_config(kernel_version: &KernelVersion) -> Result { + // TODO (MVP) - make this configurable + let config_name = format!( + "{}-tuxtape-poc.config", + kernel_version_to_string(kernel_version) + ); + + checkout_kernel_version(kernel_version)?; + + Command::new("make") + .arg("menuconfig") + .current_dir(LINUX_REPO_PATH) + .spawn()? + .wait()?; + + let config_path = PathBuf::from(LINUX_REPO_PATH).join(".config"); + if !config_path.is_file() { + return Err(eyre!(".config does not exist at path {:?}.", config_path)); + } + + let config_file = std::fs::read_to_string(config_path)?; + let metadata = KernelConfigMetadata { + config_name, + kernel_version: Some(*kernel_version), + }; + let kernel_config = KernelConfig { + metadata: Some(metadata), + config_file, + }; + + Ok(kernel_config) +} + +fn checkout_kernel_version(kernel_version: &KernelVersion) -> Result<()> { + // Clean repo so checkout goes smoothly + let result = Command::new("make") + .current_dir(LINUX_REPO_PATH) + .arg("distclean") + .spawn()? + .wait()?; + + match result.success() { + true => {} + false => return Err(eyre!("Failed to run make distclean on Linux repo.",)), + }; + + // Checkout version at tag matching kernel_version + let tag_string = kernel_version_to_tag_string(kernel_version); + let result = Command::new("git") + .current_dir(LINUX_REPO_PATH) + .args(["checkout", tag_string.as_str()]) + .spawn()? + .wait_with_output()?; + + match result.status.success() { + true => Ok(()), + false => Err(eyre!( + "Git failed to checkout tag {} with output: {}", + tag_string, + String::from_utf8(result.stdout)? + )), + } +} + +fn str_to_kernel_version(kernel_version_str: &str) -> Option { + let split = kernel_version_str.split('.'); + let mut major = None; + let mut minor = None; + let mut patch = None; + for part in split { + if major.is_none() { + major = if let Ok(major) = part.parse::() { + Some(major) + } else { + return None; + }; + } else if minor.is_none() { + minor = if let Ok(minor) = part.parse::() { + Some(minor) + } else { + return None; + }; + } else if patch.is_none() { + patch = if let Ok(patch) = part.parse::() { + Some(patch) + } else { + return None; + }; + } else { + // If there are more than 3 parts (major, minor, patch) in the version number, it's invalid. + // This appears to only happen on v2.x. + return None; + } + } + + let major = major?; + let minor = minor?; + + Some(KernelVersion { + major, + minor, + patch, + }) +} + +fn kernel_version_to_tag_string(kernel_version: &KernelVersion) -> String { + format!( + "v{}.{}{}", + kernel_version.major, + kernel_version.minor, + match kernel_version.patch { + Some(patch) => format!(".{patch}"), + None => "".to_string(), + } + ) +} + +fn kernel_version_to_string(kernel_version: &KernelVersion) -> String { + format!( + "{}.{}{}", + kernel_version.major, + kernel_version.minor, + match kernel_version.patch { + Some(patch) => format!(".{patch}"), + None => "".to_string(), + } + ) +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum ConfigsPageAction { + CreateNewConfig(KernelVersion), +} diff --git a/src/dashboard/tui/pages/home.rs b/src/dashboard/tui/pages/home.rs new file mode 100644 index 0000000..856b02e --- /dev/null +++ b/src/dashboard/tui/pages/home.rs @@ -0,0 +1,571 @@ +use crate::database::*; +use crate::tui::components::Component; +use crate::tui::popups::PopupType::{Alert, CveEditPreview}; +use crate::{action::Action, config::Config}; +use color_eyre::eyre::eyre; +use color_eyre::Result; +use ratatui::{prelude::*, widgets::*}; +use serde::{Deserialize, Serialize}; +use std::fs::File; +use std::io::prelude::*; +use std::sync::Arc; +use strum::{Display, EnumCount, EnumIter, FromRepr}; +use tokio::sync::mpsc::UnboundedSender; + +/// The main page for tuxtape-dashboard. This is where all CVEs will be listed and selectable for editing. +pub struct HomePage { + command_tx: Option>, + config: Arc, + cves: Option>>, + cve_list_state: ListState, + fetching_cves: bool, + cve_affects_pane: CveAffectsPane, + cve_details_pane: CveDetailsPane, + selected_pane: Pane, + selected_cve: Option>, +} + +impl HomePage { + pub fn new(config: Arc) -> Self { + let selected_cve = None; + + Self { + command_tx: None, + config, + cves: None, + cve_list_state: ListState::default(), + fetching_cves: false, + cve_affects_pane: CveAffectsPane::new(selected_cve.clone()), + cve_details_pane: CveDetailsPane::new(selected_cve.clone()), + selected_pane: Pane::CveList, + selected_cve: None, + } + } + + fn build_cve_list(&mut self, cves: Vec>) { + self.cves = Some(cves); + self.cve_list_state.select_first(); + self.update_selected_cve(); + } + + fn preview_edit_patch(&self) -> Result<()> { + if let Some(cves) = &self.cves { + if let Some(command_tx) = &self.command_tx { + let selected_row = self + .cve_list_state + .selected() + .expect("A row on the table will always be highlighted"); + let selected_cve = cves + .get(selected_row) + .expect("The selected row should always align with a CVE instance in the Vec") + .clone(); + + command_tx.send(Action::Popup(CveEditPreview(selected_cve)))?; + + return Ok(()); + } + } + + Ok(()) + } + + fn edit_patch(&self, cve_instance: &CveInstance) -> Result<()> { + let command_tx = match self.command_tx.as_ref() { + Some(command_tx) => command_tx, + None => { + return Err(eyre!( + "self.command_tx should always exist by the time this is called" + )) + } + }; + + let raw_patch = match &cve_instance.raw_patch { + Some(patch) => patch, + None => { + command_tx.send(Action::Popup(Alert( + "This CVE is missing a raw patch, so there's nothing to edit.".to_string(), + )))?; + return Ok(()); + } + }; + + let mut patch_file_path = self.config.config.data_dir.clone(); + patch_file_path.push("patches"); + + match std::fs::create_dir_all(&patch_file_path) { + Ok(()) => {} + Err(e) => { + command_tx.send(Action::Popup(Alert( + format!("Failed to create directories to {:?}.", patch_file_path).to_string(), + )))?; + return Err(e.into()); + } + } + + patch_file_path.push(format!("{}.patch", cve_instance.title)); + + let mut file = match File::create(&patch_file_path) { + Ok(file) => file, + Err(e) => { + command_tx.send(Action::Popup(Alert( + format!("Failed to create file {:?}", patch_file_path).to_string(), + )))?; + return Err(e.into()); + } + }; + + match file.write_all(raw_patch.as_bytes()) { + Ok(()) => {} + Err(e) => { + command_tx.send(Action::Popup(Alert( + format!("Failed to write patch to {:?}", patch_file_path).to_string(), + )))?; + return Err(e.into()); + } + }; + + let action = Action::EditPatchAtPath(patch_file_path.to_str().unwrap().to_string()); + command_tx.send(action)?; + + Ok(()) + } + + fn update_selected_cve(&mut self) { + let selection_index = if let Some(selection_index) = self.cve_list_state.selected() { + selection_index + } else { + self.selected_cve = None; + return; + }; + + let cves = if let Some(cves) = self.cves.as_ref() { + cves + } else { + self.selected_cve = None; + return; + }; + + if selection_index >= cves.len() { + self.selected_cve = None; + } else { + self.selected_cve = Some(cves[selection_index].clone()); + } + } +} + +impl Component for HomePage { + fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { + self.command_tx = Some(tx); + Ok(()) + } + + fn update(&mut self, action: Action) -> Result> { + match &action { + Action::HomePage(action) => match action { + HomePageAction::EditPatch(cve_instance) => self.edit_patch(cve_instance)?, + }, + Action::PaneLeft => self.selected_pane = self.selected_pane.previous(), + Action::PaneRight => self.selected_pane = self.selected_pane.next(), + Action::Database(DatabaseAction::Response(response)) => match response { + Response::PopulateTable(cve_instances) => { + self.build_cve_list(cve_instances.clone()) + } + Response::PutKernelConfig { + kernel_config_metadata: _, + success: _, + } => { + // Do nothing as the corresponding Request was not sent by HomePage + } + }, + _ => match self.selected_pane { + Pane::CveList => match &action { + Action::ScrollDown => { + if let Some(cves) = &self.cves { + self.cve_list_state.select_next(); + + if cves.len() + <= self + .cve_list_state + .selected() + .expect("Something should always be selected") + { + self.cve_list_state.select_first(); + } + + self.update_selected_cve(); + } + } + Action::ScrollUp => { + if let Some(cves) = &self.cves { + if self + .cve_list_state + .selected() + .expect("Something should always be selected") + == 0 + { + // Note: ListState::select_last() does not work as expected. + self.cve_list_state.select(Some(cves.len() - 1)); + } else { + self.cve_list_state.select_previous(); + } + + self.update_selected_cve(); + } + } + Action::Select => self.preview_edit_patch()?, + _ => return Ok(Some(action)), + }, + Pane::Affects => return self.cve_affects_pane.update(action), + Pane::Details => return self.cve_details_pane.update(action), + }, + } + + Ok(None) + } + + fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { + if let Some(cves) = self.cves.as_ref() { + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(15), + Constraint::Percentage(25), + Constraint::Percentage(60), + ]) + .split(area); + + let cve_list = List::default() + .items(cves.iter().map(|cve| cve.id.as_str())) + .style(Style::new().blue()) + .highlight_style(Style::new().reversed()) + .highlight_symbol(">>") + .block( + match self.selected_pane { + Pane::CveList => Block::bordered().style(Style::new().green()), + _ => Block::bordered().style(Style::new().white()), + } + .title("CVEs"), + ); + + frame.render_stateful_widget(cve_list, chunks[0], &mut self.cve_list_state); + + self.cve_affects_pane.draw( + frame, + chunks[1], + self.selected_cve.clone(), + matches!(self.selected_pane, Pane::Affects), + )?; + + self.cve_details_pane.draw( + frame, + chunks[2], + self.selected_cve.clone(), + matches!(self.selected_pane, Pane::Details), + )?; + + Ok(()) + } else { + // Display "Loading" text instead of CVEs until they're retrieved from the server + if !self.fetching_cves { + if let Some(command_tx) = self.command_tx.as_mut() { + command_tx.send(Action::Database(DatabaseAction::Request( + Request::PopulateTable(), + )))?; + self.fetching_cves = true; + } + } + + frame.render_widget(Paragraph::new("Loading"), area); + Ok(()) + } + } +} + +#[derive(Debug, Clone, PartialEq, Display, Serialize, Deserialize)] +pub enum HomePageAction { + EditPatch(CveInstance), +} + +#[derive(EnumIter, EnumCount, FromRepr, Clone, Copy)] +enum Pane { + CveList, + Affects, + Details, +} + +impl Pane { + /// Get the next pane. If there is no next pane, loop around to first. + fn next(self) -> Self { + let current_index = self as usize; + const MAX_INDEX: usize = Pane::COUNT - 1; + let next_index = match current_index { + MAX_INDEX => 0, + _ => current_index + 1, + }; + Self::from_repr(next_index).unwrap_or(self) + } + + /// Get the previous pane. If there is no previous pane, loop around to last. + fn previous(self) -> Self { + let current_index = self as usize; + let previous_index = match current_index { + 0 => Pane::COUNT - 1, + _ => current_index - 1, + }; + Self::from_repr(previous_index).unwrap_or(self) + } +} + +#[derive(Default)] +struct CveAffectsPane { + list_state: ListState, + selected_cve: Option>, +} + +impl CveAffectsPane { + fn new(selected_cve: Option>) -> Self { + Self { + list_state: ListState::default(), + selected_cve, + } + } + + fn update(&mut self, action: Action) -> Result> { + match &action { + Action::ScrollDown => { + self.list_state.select_next(); + Ok(None) + } + Action::ScrollUp => { + self.list_state.select_previous(); + Ok(None) + } + _ => Ok(Some(action)), + } + } + + fn draw( + &mut self, + frame: &mut Frame, + area: Rect, + selected_cve: Option>, + is_selected: bool, + ) -> Result<()> { + if self.selected_cve != selected_cve { + self.selected_cve = selected_cve; + self.list_state.select_first(); + } + + if let Some(selected_cve) = self.selected_cve.as_ref() { + let items: Vec = selected_cve + .instances + .iter() + .flat_map(|instance| { + instance + .affected_configs + .iter() + .map(|affected_config| affected_config.config_name.clone()) + .collect::>() + }) + .collect(); + + let list = List::new(items).highlight_symbol(">>").block( + match is_selected { + true => Block::bordered().style(Style::new().green()), + false => Block::bordered().style(Style::new().white()), + } + .title("Affected Configs"), + ); + frame.render_stateful_widget(list, area, &mut self.list_state); + } + + Ok(()) + } +} + +#[derive(Default)] +struct CveDetailsPane { + scrollbar_state: ScrollbarState, + scroll_position: u16, + selected_cve: Option>, +} + +impl CveDetailsPane { + fn new(selected_cve: Option>) -> Self { + Self { + scrollbar_state: ScrollbarState::default(), + scroll_position: 0, + selected_cve, + } + } + + fn update(&mut self, action: Action) -> Result> { + match &action { + Action::ScrollDown => { + self.scroll_position = self.scroll_position.saturating_add(1); + self.scrollbar_state = self.scrollbar_state.position(self.scroll_position.into()); + Ok(None) + } + Action::ScrollUp => { + self.scroll_position = self.scroll_position.saturating_sub(1); + self.scrollbar_state = self.scrollbar_state.position(self.scroll_position.into()); + Ok(None) + } + _ => Ok(Some(action)), + } + } + + fn draw( + &mut self, + frame: &mut Frame, + area: Rect, + selected_cve: Option>, + is_selected: bool, + ) -> Result<()> { + if self.selected_cve != selected_cve { + self.selected_cve = selected_cve; + self.scroll_position = 0; + self.scrollbar_state = self.scrollbar_state.position(self.scroll_position.into()); + } + + if let Some(cve) = self.selected_cve.as_ref() { + let not_available_text = "N/A".to_string(); + + let severity = if let Some(severity) = cve.severity { + format!("{:.1}", severity) + } else { + not_available_text.clone() + }; + + let items = vec![ + ListItem::new(format!("Severity: {}", severity)), + ListItem::new(format!( + "Attack Vector: {}", + cve.attack_vector.as_ref().unwrap_or(¬_available_text) + )), + ListItem::new(format!( + "Attack Complexity: {}", + cve.attack_complexity + .as_ref() + .unwrap_or(¬_available_text) + )), + ListItem::new(format!( + "Privileges Required: {}", + cve.attack_complexity + .as_ref() + .unwrap_or(¬_available_text) + )), + ListItem::new(format!( + "User Interaction: {}", + cve.user_interaction.as_ref().unwrap_or(¬_available_text) + )), + ListItem::new(format!( + "Scope: {}", + cve.scope.as_ref().unwrap_or(¬_available_text) + )), + ListItem::new(format!( + "Confidentiality Impact: {}", + cve.confidentiality_impact + .as_ref() + .unwrap_or(¬_available_text) + )), + ListItem::new(format!( + "Integrity Impact: {}", + cve.integrity_impact.as_ref().unwrap_or(¬_available_text) + )), + ListItem::new(format!( + "Availability Impact: {}", + cve.availability_impact + .as_ref() + .unwrap_or(¬_available_text) + )), + ]; + + let block = match is_selected { + true => Block::bordered().style(Style::new().green()), + false => Block::bordered().style(Style::new().white()), + } + .title("CVE Details"); + + let inner_area = block.inner(area); + frame.render_widget(block, area); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Percentage(30), Constraint::Percentage(70)]) + .split(inner_area); + + frame.render_widget( + List::new(items).block(match is_selected { + true => Block::bordered().style(Style::new().green()), + false => Block::bordered().style(Style::new().white()), + }), + chunks[0], + ); + + let cve_description = Paragraph::new(self.get_formatted_description(cve.description())) + .wrap(Wrap { trim: true }) + .block( + match is_selected { + true => Block::bordered().style(Style::new().green()), + false => Block::bordered().style(Style::new().white()), + } + .title("Description"), + ) + .scroll((self.scroll_position, 0)); + + // Note: as of ratatui 0.29.0, Paragraph::content_length() is unstable/experimental and + // is giving slightly wrong values, causing bigger scroll areas than necessary. + // Currently this is the best solution as we need to know the width of wrapped text, + // but it should be changed down the line. + self.scrollbar_state = self + .scrollbar_state + .content_length(cve_description.line_count(chunks[1].width)); + + frame.render_widget(cve_description, chunks[1]); + frame.render_stateful_widget( + Scrollbar::new(ScrollbarOrientation::VerticalRight) + .symbols(symbols::scrollbar::VERTICAL), + chunks[1], + &mut self.scrollbar_state, + ); + } + + Ok(()) + } + + fn get_formatted_description(&self, description: &str) -> String { + // Filter out single line breaks (used for email formatting but messes with our display) and replace them with spaces. + // We shouldn't filter out all line breaks as double line breaks are for splitting paragraphs. + // Note: the `regex` crate currently does not support lookaround, which is why this is done manually. + // + // Also replace tabs with spaces as tabs break the formatting in ratatui. + let mut formatted_description = String::new(); + let mut chars = description.chars(); + while let Some(char) = chars.next() { + match char { + '\n' => { + if let Some(next_char) = chars.next() { + match next_char { + '\n' => { + formatted_description.push(char); + formatted_description.push('\n'); + } + '\t' => { + formatted_description.push(' '); + } + _ => { + formatted_description.push(' '); + formatted_description.push(next_char); + } + } + } + } + '\t' => formatted_description.push(' '), + _ => formatted_description.push(char), + } + } + + formatted_description + } +} diff --git a/src/dashboard/tui/popups.rs b/src/dashboard/tui/popups.rs new file mode 100644 index 0000000..0d0d220 --- /dev/null +++ b/src/dashboard/tui/popups.rs @@ -0,0 +1,54 @@ +mod alert; +mod cve_edit_preview; + +use crate::{action::Action, database::Cve, tui::components::Component}; +pub use alert::Alert; +use color_eyre::Result; +pub use cve_edit_preview::CveEditPreview; +use ratatui::{prelude::*, widgets::*}; +use serde::Deserialize; +use std::sync::Arc; +use strum::Display; + +#[derive(Debug, Clone, PartialEq, Display, Deserialize)] +pub enum PopupType { + Alert(String), + CveEditPreview(Arc), +} + +/// The frame in which all Popups should be drawn +pub struct PopupFrame<'a> { + frame: Block<'a>, +} + +impl PopupFrame<'_> { + fn new(header_text: String, fg_color: Option) -> Self { + let fg_color = fg_color.unwrap_or(Color::White); + + let frame = Block::bordered() + .title_top(Line::from(header_text).centered()) + .borders(Borders::ALL) + .border_set(symbols::border::DOUBLE) + .fg(fg_color); + + Self { frame } + } + + /// Returns the area that the Popup content should be drawn in. + fn get_content_area(&self, area: Rect) -> Rect { + self.frame.inner(area) + } +} + +impl Component for PopupFrame<'_> { + fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { + frame.render_widget(Clear, area); + frame.render_widget(&self.frame, area); + + Ok(()) + } + + fn update(&mut self, action: Action) -> Result> { + Ok(Some(action)) + } +} diff --git a/src/dashboard/tui/popups/alert.rs b/src/dashboard/tui/popups/alert.rs new file mode 100644 index 0000000..b28057c --- /dev/null +++ b/src/dashboard/tui/popups/alert.rs @@ -0,0 +1,36 @@ +/// A generic popup used for alerting the user without panicking. +use super::PopupFrame; +use crate::{action::Action, tui::components::Component}; +use color_eyre::Result; +use ratatui::{prelude::*, widgets::*}; + +pub struct Alert<'a> { + frame: PopupFrame<'a>, + text: Paragraph<'a>, +} + +impl Alert<'_> { + pub fn new(text: String) -> Self { + let frame = PopupFrame::new("Alert".to_string(), Some(Color::Red)); + let text = Paragraph::new(text).centered(); + + Self { frame, text } + } +} + +impl Component for Alert<'_> { + fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { + self.frame.draw(frame, area)?; + frame.render_widget(&self.text, self.frame.get_content_area(area)); + + Ok(()) + } + + fn update(&mut self, action: Action) -> Result> { + // Consume navigation actions, pass through all else + match action { + Action::ScrollDown | Action::ScrollUp | Action::TabLeft | Action::TabRight => Ok(None), + _ => Ok(Some(action)), + } + } +} diff --git a/src/dashboard/tui/popups/cve_edit_preview.rs b/src/dashboard/tui/popups/cve_edit_preview.rs new file mode 100644 index 0000000..11ded91 --- /dev/null +++ b/src/dashboard/tui/popups/cve_edit_preview.rs @@ -0,0 +1,277 @@ +/// The Popup that displays when a CVE is selected to be edited. +use super::PopupFrame; +use crate::database::CveInstance; +use crate::tui::pages::home::HomePageAction::EditPatch; +use crate::{ + action::Action, + database::{Cve, KernelVersion}, + tui::components::Component, +}; +use color_eyre::Result; +use ratatui::{prelude::*, widgets::*}; +use std::cell::RefCell; +use std::rc::Rc; +use std::sync::Arc; +use strum::{EnumCount, EnumIter, FromRepr}; +use tokio::sync::mpsc::UnboundedSender; + +pub struct CveEditPreview<'a> { + frame: PopupFrame<'a>, + cve: Arc, + cve_instances_table_state: TableState, + selected_cve_instance: Rc>, + selected_pane: Pane, + affects_pane: AffectsPane, + command_tx: UnboundedSender, +} + +impl CveEditPreview<'_> { + pub fn new(cve: Arc, command_tx: UnboundedSender) -> Self { + let frame = PopupFrame::new(cve.id.clone(), None); + + let mut cve_instances_table_state = TableState::default(); + cve_instances_table_state.select_first(); + + let selected_cve_instance = Rc::new(RefCell::new(cve.instances[0].clone())); + + Self { + frame, + cve, + cve_instances_table_state, + selected_cve_instance: selected_cve_instance.clone(), + selected_pane: Pane::InstancePane, + affects_pane: AffectsPane::new(selected_cve_instance), + command_tx, + } + } +} + +impl Component for CveEditPreview<'_> { + fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { + self.frame.draw(frame, area)?; + + let cve_instances_table_rows: Vec = self + .cve + .instances + .iter() + .map(|cve_instance| { + Row::new(vec![ + cve_instance.title.clone(), + if let Some(introduced_version) = &cve_instance.introduced { + kernel_version_to_string(introduced_version) + } else { + "NULL".to_string() + }, + if let Some(fixed_version) = &cve_instance.fixed { + kernel_version_to_string(fixed_version) + } else { + "NULL".to_string() + }, + ]) + }) + .collect(); + + let cve_instances_table_widths = vec![ + Constraint::Percentage(60), + Constraint::Percentage(20), + Constraint::Percentage(20), + ]; + + let cve_instances_table = Table::new(cve_instances_table_rows, cve_instances_table_widths) + .highlight_symbol(">>") + .row_highlight_style(Style::new().reversed()) + .header( + Row::new(vec!["CVE Instance", "Introduced", "Fixed"]) + .style(Style::new().bg(Color::Blue).fg(Color::White)), + ) + .block( + Block::bordered() + .title_top(Line::from("CVE Instance").centered()) + .style(match self.selected_pane { + Pane::InstancePane => Style::new().green(), + _ => Style::new().white(), + }), + ); + + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(60), Constraint::Percentage(40)]) + .split(self.frame.get_content_area(area)); + + frame.render_stateful_widget( + cve_instances_table, + chunks[0], + &mut self.cve_instances_table_state, + ); + + self.affects_pane.draw( + frame, + chunks[1], + matches!(self.selected_pane, Pane::AffectsPane), + ) + } + + fn update(&mut self, action: Action) -> Result> { + // Consume navigation actions, pass through all else + match action { + Action::Select => { + let selected_instance = self.cve.instances[self + .cve_instances_table_state + .selected() + .expect("Something will always be selected")] + .clone(); + + self.command_tx + .send(Action::HomePage(EditPatch(selected_instance)))?; + } + Action::PaneLeft => self.selected_pane = self.selected_pane.previous(), + Action::PaneRight => self.selected_pane = self.selected_pane.next(), + Action::TabLeft | Action::TabRight => return Ok(None), + Action::ScrollDown | Action::ScrollUp => match self.selected_pane { + Pane::InstancePane => { + match action { + Action::ScrollDown => { + self.cve_instances_table_state.select_next(); + if self.cve.instances.len() + <= self.cve_instances_table_state.selected().unwrap() + { + self.cve_instances_table_state.select_first(); + } + + self.selected_cve_instance.replace( + self.cve.instances[self + .cve_instances_table_state + .selected() + .expect("An item will always be selected")] + .clone(), + ); + } + Action::ScrollUp => { + if self.cve_instances_table_state.selected().unwrap() == 0 { + // Note: ListState::select_last() does not work as expected. + self.cve_instances_table_state + .select(Some(self.cve.instances.len() - 1)); + } else { + self.cve_instances_table_state.select_previous(); + } + + self.selected_cve_instance.replace( + self.cve.instances[self + .cve_instances_table_state + .selected() + .expect("An item will always be selected")] + .clone(), + ); + } + _ => {} // This can't get hit but appeases the compiler + } + } + Pane::AffectsPane => return self.affects_pane.update(action), + }, + _ => return Ok(Some(action)), + } + Ok(None) + } +} + +#[derive(EnumIter, EnumCount, FromRepr, Clone, Copy)] +enum Pane { + InstancePane, + AffectsPane, +} + +impl Pane { + /// Get the next pane. If there is no next pane, loop around to first. + fn next(self) -> Self { + let current_index = self as usize; + const MAX_INDEX: usize = Pane::COUNT - 1; + let next_index = match current_index { + MAX_INDEX => 0, + _ => current_index + 1, + }; + Self::from_repr(next_index).unwrap_or(self) + } + + /// Get the previous pane. If there is no previous pane, loop around to last. + fn previous(self) -> Self { + let current_index = self as usize; + let previous_index = match current_index { + 0 => Pane::COUNT - 1, + _ => current_index - 1, + }; + Self::from_repr(previous_index).unwrap_or(self) + } +} + +struct AffectsPane { + cve_instance: Rc>, + affects_list_state: ListState, +} + +impl AffectsPane { + fn new(cve_instance: Rc>) -> Self { + let mut affects_list_state = ListState::default(); + affects_list_state.select_first(); + + Self { + cve_instance, + affects_list_state, + } + } + + fn draw(&mut self, frame: &mut Frame, area: Rect, is_selected: bool) -> Result<()> { + let cve_instance = self.cve_instance.borrow(); + + let cve_instance_affects_items: Vec<&str> = cve_instance + .affected_configs + .iter() + .map(|affected_config| affected_config.config_name.as_str()) + .collect(); + + let cve_instance_affects_list = List::new(cve_instance_affects_items) + .block( + match is_selected { + true => Block::bordered().style(Style::new().green()), + false => Block::bordered().style(Style::new().white()), + } + .title("Affected Configs"), + ) + .highlight_symbol(">>"); + + frame.render_stateful_widget( + cve_instance_affects_list, + area, + &mut self.affects_list_state, + ); + + Ok(()) + } + + fn update(&mut self, action: Action) -> Result> { + match action { + Action::ScrollDown => { + self.affects_list_state.select_next(); + Ok(None) + } + Action::ScrollUp => { + self.affects_list_state.select_previous(); + Ok(None) + } + _ => Ok(Some(action)), + } + } +} + +// TODO - find a more global place to put this if repeated +fn kernel_version_to_string(kernel_version: &KernelVersion) -> String { + format!( + "{}.{}{}", + kernel_version.major, + kernel_version.minor, + if let Some(patch) = kernel_version.patch { + format!(".{}", patch) + } else { + "".to_string() + } + ) +} diff --git a/src/kernel_builder/main.rs b/src/kernel_builder/main.rs new file mode 100644 index 0000000..f961d2c --- /dev/null +++ b/src/kernel_builder/main.rs @@ -0,0 +1,557 @@ +/// A kernel builder that registers itself to tuxtape-server and anticipates BuildKernelRequests. +/// +/// Note: Currently, this program caches the Linux kernel repo as its build source instead of grabbing +/// a tarball. The idea is for a builder to be relatively ephimeral so we can scale this up/down as +/// we need, and fetching the whole Linux source code history is not ideal for that. In future versions, +/// we probably should store Linux kernels in the artifactory and fetch from there instead. + +mod tuxtape_server { + tonic::include_proto!("tuxtape_server"); +} + +use clap::Parser; +use color_eyre::eyre::{eyre, Result}; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::fs::File; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::Arc; +use std::time::Duration; +use tonic::codec::CompressionEncoding; +use tonic::transport::{Certificate, Channel, ClientTlsConfig, Identity, Server, ServerTlsConfig}; +use tonic::{Request, Response, Status}; +use tonic_health::pb::{ + health_check_response::ServingStatus, + health_client::HealthClient, + health_server::{Health, HealthServer}, + HealthCheckRequest, +}; +use tuxtape_server::builder_server::{Builder, BuilderServer}; +use tuxtape_server::database_client::DatabaseClient; +use tuxtape_server::{ + BuildKernelRequest, BuildKernelResponse, KernelVersion, RegisterKernelBuilderRequest, +}; + +const CACHE_PATH: &str = concat!(env!("HOME"), "/.cache/tuxtape-kernel-builder"); +const BUILD_PATH: &str = const_format::concatcp!(CACHE_PATH, "/builds"); +const BUILD_PROFILES_PATH: &str = const_format::concatcp!(CACHE_PATH, "/build-profiles"); +const GIT_PATH: &str = const_format::concatcp!(CACHE_PATH, "/git"); +const LINUX_REPO_PATH: &str = const_format::concatcp!(GIT_PATH, "/linux"); +const LINUX_REPO_URL: &str = "https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git"; + +#[derive(Parser)] +#[command(version, about, long_about = None)] +#[command(about = "A kernel builder that connects to tuxtape-server.", long_about = None)] +struct Args { + /// The socket address for this server, either IPv4 or IPv6. + #[arg(short('a'), long, default_value = "127.0.0.1:50052")] + addr: String, + + /// The web URL for this server (like tuxtape-kernel-builder.com) + #[arg(short('u'), long, default_value = "")] + url: String, + + /// The URL (and port) to tuxtape-server, either IPv4, IPv6, or domain name. + #[arg(short('s'), long, default_value = "127.0.0.1:50051")] + tuxtape_server_url: String, + + /// Enables TLS support + #[arg(short('t'), long, requires_all(["tls_cert_path", "tls_key_path"]), default_value = "false")] + tls: bool, + + /// Path to TLS CA (requires -t) + #[arg(long, requires("tls"), default_value = "")] + tls_ca_path: String, + + /// Path to TLS certificate (requires -t) + #[arg(long, requires("tls"), default_value = "")] + tls_cert_path: String, + + /// Path to TLS key (requires -t) + #[arg(long, requires("tls"), default_value = "")] + tls_key_path: String, +} + +#[tokio::main] +async fn main() -> Result<()> { + let mut args = Args::parse(); + if args.url.is_empty() { + args.url = args.addr.clone(); + } + let args = Arc::new(args); + + if !Path::new(LINUX_REPO_PATH).exists() { + println!( + "Linux repo dir '{}' does not exist. Creating.", + LINUX_REPO_PATH + ); + std::fs::create_dir_all(LINUX_REPO_PATH)?; + } + + if !Path::new(format!("{}/.git", LINUX_REPO_PATH).as_str()).exists() { + Command::new("git") + .current_dir(LINUX_REPO_PATH) + .args(["clone", LINUX_REPO_URL]) + .spawn()? + .wait()?; + } + + loop { + println!("Attempting to connect to tuxtape-server at {}", &args.url); + + // This will return if the server crashes for any reason, so we want to keep this in a loop. + // In the future, we should log the errors should the server crash. + let result = start_server(args.clone()).await; + match result { + Ok(()) => {} + Err(e) => eprintln!("Connection to tuxtape-server failed with error: {}", e), + } + + // Wait for a bit then try to reconnect to server. + tokio::time::sleep(Duration::from_secs(5)).await; + } +} + +struct MyBuilder {} + +#[tonic::async_trait] +impl Builder for MyBuilder { + async fn build_kernel( + &self, + request: Request, + ) -> Result, Status> { + println!( + "New request to build kernel from {:?}", + request.remote_addr() + ); + + if let Some(kernel_config) = &request.into_inner().kernel_config { + if let Some(metadata) = &kernel_config.metadata { + if let Some(kernel_version) = &metadata.kernel_version { + let result = build_kernel( + &kernel_config.config_file, + &metadata.config_name, + kernel_version, + ); + + match result { + Ok(_) => { + let included_files = get_included_files(&metadata.config_name); + match included_files { + Ok(included_files) => { + Ok(Response::new(BuildKernelResponse { included_files })) + } + Err(e) => Err(Status::from_error(e.into())), + } + } + Err(e) => Err(Status::from_error(e.into())), + } + } else { + Err(Status::invalid_argument( + "Request missing kernel_config.metadata.kernel_version", + )) + } + } else { + Err(Status::invalid_argument( + "Request missing kernel_config.metadata", + )) + } + } else { + Err(Status::invalid_argument("Request missing kernel_config")) + } + } +} + +async fn start_server(args: Arc) -> Result<()> { + let builder = MyBuilder {}; + + let (mut health_reporter, health_service) = tonic_health::server::health_reporter(); + health_reporter + .set_serving::>() + .await; + + let tuxtape_server_url = match args.tls { + true => format!("https://{}", &args.tuxtape_server_url), + false => format!("http://{}", &args.tuxtape_server_url), + }; + + // Strip port from URL if one was provided + let domain_name = if let Some(domain_name) = args + .tuxtape_server_url + .split(':') + .collect::>() + .first() + { + *domain_name + } else { + &tuxtape_server_url + }; + + let channel = match args.tls { + true => { + let pem = std::fs::read_to_string(&args.tls_ca_path)?; + let ca = Certificate::from_pem(pem); + + let tls = ClientTlsConfig::new() + .ca_certificate(ca) + .domain_name(domain_name); + + Channel::from_shared(tuxtape_server_url)? + .tls_config(tls)? + .connect() + .await? + } + false => Channel::from_shared(tuxtape_server_url)?.connect().await?, + }; + + println!("Starting kernel builder server."); + + let builder_service = BuilderServer::new(builder); + + let mut join_set = tokio::task::JoinSet::new(); + join_set.spawn(register_to_tuxtape_server(args.clone(), channel.clone())); + join_set.spawn(host_server(args, health_service, builder_service)); + join_set.spawn(watch_server_health(channel)); + + while let Some(join_result) = join_set.join_next().await { + join_result?? + } + + Ok(()) +} + +async fn register_to_tuxtape_server(args: Arc, channel: Channel) -> Result<()> { + let builder_address: String = args.url.clone(); + let mut tuxtape_server_client = DatabaseClient::new(channel) + .accept_compressed(CompressionEncoding::Gzip) + .send_compressed(CompressionEncoding::Gzip) + .max_decoding_message_size(usize::MAX) + .max_encoding_message_size(usize::MAX); + + tuxtape_server_client + .register_kernel_builder(RegisterKernelBuilderRequest { builder_address }) + .await?; + + println!("Registered to tuxtape-server."); + + Ok(()) +} + +async fn host_server( + args: Arc, + health_service: HealthServer, + builder_service: BuilderServer, +) -> Result<()> { + let addr = args.addr.parse()?; + + if args.tls { + let cert = std::fs::read_to_string(&args.tls_cert_path)?; + let key = std::fs::read_to_string(&args.tls_key_path)?; + let identity = Identity::from_pem(cert, key); + + Server::builder() + .tls_config(ServerTlsConfig::new().identity(identity))? + .add_service( + health_service + .accept_compressed(CompressionEncoding::Gzip) + .send_compressed(CompressionEncoding::Gzip), + ) + .add_service( + builder_service + .accept_compressed(CompressionEncoding::Gzip) + .send_compressed(CompressionEncoding::Gzip) + .max_decoding_message_size(usize::MAX) + .max_encoding_message_size(usize::MAX), + ) + .serve(addr) + .await?; + } else { + Server::builder() + .add_service( + health_service + .accept_compressed(CompressionEncoding::Gzip) + .send_compressed(CompressionEncoding::Gzip), + ) + .add_service( + builder_service + .accept_compressed(CompressionEncoding::Gzip) + .send_compressed(CompressionEncoding::Gzip) + .max_decoding_message_size(usize::MAX) + .max_encoding_message_size(usize::MAX), + ) + .serve(addr) + .await?; + } + + Ok(()) +} + +async fn watch_server_health(channel: Channel) -> Result<()> { + let mut health_client = HealthClient::new(channel) + .accept_compressed(CompressionEncoding::Gzip) + .send_compressed(CompressionEncoding::Gzip); + + let result = health_client + .watch(HealthCheckRequest { + service: "tuxtape_server.Database".to_string(), + }) + .await; + match result { + Ok(resp) => { + let mut stream = resp.into_inner(); + while let Some(message) = stream.message().await.transpose() { + match message { + Ok(resp) => match resp.status() { + ServingStatus::Serving => {} + _ => { + return Err(eyre!("tuxtape-server stopped serving requests")); + } + }, + Err(_) => { + return Err(eyre!("Lost connection to tuxtape-server")); + } + } + } + } + Err(e) => { + return Err(eyre!( + "Could not connect to health service on tuxtape-server. Error: {}", + e + )); + } + } + + Ok(()) +} + +fn checkout_kernel_version(kernel_version: &KernelVersion) -> Result<()> { + let result = Command::new("git") + .current_dir(LINUX_REPO_PATH) + .args(["clean", "-f", "-d", "-x"]) + .spawn()? + .wait_with_output()?; + + match result.status.success() { + true => {} + false => { + return Err(eyre!( + "git clean -f -d failed with output: {}", + String::from_utf8(result.stdout)? + )) + } + } + + let result = Command::new("git") + .current_dir(LINUX_REPO_PATH) + .args(["reset", "--hard", "HEAD"]) + .spawn()? + .wait_with_output()?; + + match result.status.success() { + true => {} + false => { + return Err(eyre!( + "git reset --hard HEAD failed with output: {}", + String::from_utf8(result.stdout)? + )) + } + } + + let tag_string = format!( + "v{}.{}{}", + kernel_version.major, + kernel_version.minor, + match kernel_version.patch { + Some(patch) => format!(".{}", patch), + None => "".to_string(), + } + ); + + let result = Command::new("git") + .current_dir(LINUX_REPO_PATH) + .args(["checkout", tag_string.as_str()]) + .spawn()? + .wait_with_output()?; + + match result.status.success() { + true => Ok(()), + false => Err(eyre!( + "Git failed to checkout tag {} with output: {}", + tag_string, + String::from_utf8(result.stdout)? + )), + } +} + +fn build_kernel( + config_file: &str, + config_name: &str, + kernel_version: &KernelVersion, +) -> Result<()> { + println!("Building kernel"); + + // Sanity check before running rm -rf + let build_path = Path::new(BUILD_PATH); + let build_profiles_path = Path::new(BUILD_PROFILES_PATH); + let cache_path: &Path = Path::new(CACHE_PATH); + + if build_path + .parent() + .is_none_or(|parent| parent != cache_path) + { + return Err(eyre!( + "BUILD_PATH ({}) is not a child of CACHE_PATH ({})", + BUILD_PATH, + CACHE_PATH + )); + } + + if build_profiles_path + .parent() + .is_none_or(|parent| parent != cache_path) + { + return Err(eyre!( + "BUILD_PROFILES_PATH ({}) is not a child of CACHE_PATH ({})", + BUILD_PROFILES_PATH, + CACHE_PATH + )); + } + + let build_output_path = PathBuf::from(format!("{}/{}", BUILD_PATH, config_name)); + let build_profile_output_path = + PathBuf::from(format!("{}/{}", BUILD_PROFILES_PATH, config_name).as_str()); + + // Clear out build_output_path and build_profile_output_path if they already exist + Command::new("rm") + .arg("-r") + .arg("-f") + .arg(&build_output_path) + .spawn()? + .wait()?; + + Command::new("rm") + .arg("-r") + .arg("-f") + .arg(&build_profile_output_path) + .spawn()? + .wait()?; + + if !build_output_path.exists() { + std::fs::create_dir_all(&build_output_path)?; + } + if !build_profile_output_path.exists() { + std::fs::create_dir_all(&build_profile_output_path)?; + } + + checkout_kernel_version(kernel_version)?; + + Command::new("make") + .arg("distclean") + .current_dir(LINUX_REPO_PATH) + .spawn()? + .wait()?; + + let config_file_path = build_output_path.join(".config"); + std::fs::write(config_file_path, config_file)?; + + // For some reason, Command is sometimes capturing a " character, so this filters + // out all non-numeric characters + let nproc_retval = Command::new("nproc") + .output()? + .stdout + .iter() + .filter(|char| char.is_ascii_digit()) + .copied() + .collect(); + let threads = String::from_utf8(nproc_retval)?; + let threads_arg = format!("-j{threads}"); + + Command::new("make") + .args([ + "-C", + LINUX_REPO_PATH, + "defconfig", + format!("O={}", build_output_path.to_str().unwrap()).as_str(), + ]) + .spawn()? + .wait()?; + + Command::new("remake") + .args([ + "--profile=json", + format!( + "--profile-directory={}", + &build_profile_output_path.to_str().unwrap() + ) + .as_str(), + threads_arg.as_str(), + "-C", + LINUX_REPO_PATH, + format!("O={}", build_output_path.to_str().unwrap()).as_str(), + ]) + .spawn()? + .wait()?; + + println!("Done building kernel"); + + Ok(()) +} + +fn get_included_files(config_name: &str) -> Result> { + let build_profile_output_path = + PathBuf::from(format!("{}/{}", BUILD_PROFILES_PATH, config_name).as_str()); + if !build_profile_output_path.exists() { + std::fs::create_dir_all(&build_profile_output_path)?; + } + + let mut included_files: HashSet = HashSet::new(); + + for file in build_profile_output_path.read_dir()?.flatten() { + // Only operate on .json files + if !file.path().extension().map_or(false, |s| s == "json") { + continue; + } + + let json = File::open(file.path())?; + let kernel_profile: KernelProfileJson = serde_json::from_reader(json) + .unwrap_or_else(|_| panic!("Failed at file: {:?}", file.path())); + + for target in kernel_profile.targets { + if let Some(file) = target.file { + let stripped_file = file.strip_prefix(LINUX_REPO_PATH).unwrap_or(&file); + + if Path::new(LINUX_REPO_PATH) + .join(Path::new(stripped_file)) + .is_file() + { + included_files.insert(stripped_file.to_string()); + } + } + + for depend in target.depends { + let stripped_file = depend.strip_prefix(LINUX_REPO_PATH).unwrap_or(&depend); + + if Path::new(LINUX_REPO_PATH) + .join(Path::new(stripped_file)) + .is_file() + { + included_files.insert(stripped_file.to_string()); + } + } + } + } + + Ok(included_files.into_iter().collect()) +} + +#[derive(Serialize, Deserialize)] +struct KernelProfileJson { + targets: Vec, +} + +#[derive(Serialize, Deserialize)] +struct Target { + file: Option, + depends: Vec, +} diff --git a/src/parser/nist_api_types.rs b/src/parser/nist_api_types.rs new file mode 100644 index 0000000..6d69e07 --- /dev/null +++ b/src/parser/nist_api_types.rs @@ -0,0 +1,66 @@ +/// JSON types for the NIST CVE API +/// https://csrc.nist.gov/schema/nvd/api/2.0/cve_api_json_2.0.schema +use serde_derive::{Deserialize, Serialize}; + +pub type NistResponse = Root; + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Root { + pub total_results: usize, + pub vulnerabilities: Vec, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Vulnerability { + pub cve: Cve, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Cve { + pub id: String, + pub metrics: Metrics, + pub published: String, + pub last_modified: String, + pub descriptions: Vec, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Description { + pub lang: String, + pub value: String, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Metrics { + pub cvss_metric_v31: Option>, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CvssMetricV31 { + pub cvss_data: CvssData, + pub exploitability_score: f64, + pub impact_score: f64, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CvssData { + pub version: String, + pub vector_string: String, + pub attack_vector: String, + pub attack_complexity: String, + pub privileges_required: String, + pub user_interaction: String, + pub scope: String, + pub confidentiality_impact: String, + pub integrity_impact: String, + pub availability_impact: String, + pub base_score: f64, + pub base_severity: String, +} diff --git a/src/parser/parser.rs b/src/parser/parser.rs new file mode 100644 index 0000000..4ae89f0 --- /dev/null +++ b/src/parser/parser.rs @@ -0,0 +1,725 @@ +mod nist_api_types; +mod rate_limiter; + +use color_eyre::{eyre::eyre, Result}; +use git2::{Oid, Repository}; +use nist_api_types::{NistResponse, Vulnerability}; +use rate_limiter::RateLimiter; +use std::collections::HashMap; +use std::env; +use std::fs::File; +use std::io::{BufRead, BufReader}; +use std::path::Path; +use std::process::Command; +use std::time::Duration; + +const CACHE_PATH: &str = concat!(env!("HOME"), "/.cache/tuxtape-server"); +const GIT_PATH: &str = const_format::concatcp!(CACHE_PATH, "/git"); +const CVE_REPO_PATH: &str = const_format::concatcp!(GIT_PATH, "/vulns"); +const LINUX_REPO_PATH: &str = const_format::concatcp!(GIT_PATH, "/linux"); +const DB_PATH: &str = const_format::concatcp!(CACHE_PATH, "/db.db3"); +const NIST_API_URL: &str = "https://services.nvd.nist.gov/rest/json/cves/2.0"; + +fn main() -> anyhow::Result<(), anyhow::Error> { + println!("Opening database"); + if !Path::new(CACHE_PATH).exists() { + println!("Cache dir '{}' does not exist. Creating.", CACHE_PATH); + std::fs::create_dir_all(CACHE_PATH)?; + } + let mut db = rusqlite::Connection::open(DB_PATH)?; + + let db_initializing = !db_tables_exist(&db); + if db_initializing { + println!("Database not found. Creating."); + create_database(&db)?; + } + println!("Database exists."); + + sync_repos(); + update_database(&mut db, db_initializing)?; + + println!("Done."); + + Ok(()) +} + +fn update_database( + db: &mut rusqlite::Connection, + db_initializing: bool, +) -> anyhow::Result<(), anyhow::Error> { + let based_on_vulns_commit = Repository::open(CVE_REPO_PATH)? + .revparse("HEAD")? + .from() + .unwrap() + .id() + .to_string(); + + let last_vulns_commit = fetch_last_vulns_commit(db)?; + let mut cves = fetch_cves(last_vulns_commit.as_deref())?; + fetch_nist(&mut cves, db_initializing, None); + write_to_database(db, &cves, based_on_vulns_commit.as_str()) +} + +/// Fetch the last vulns commit this was ran against from the database +fn fetch_last_vulns_commit(db: &mut rusqlite::Connection) -> rusqlite::Result> { + db.prepare("SELECT based_on_vulns_commit FROM meta")? + .query_map([], |row| row.get::<_, String>(0))? + .last() + .transpose() +} + +fn write_to_database( + db: &mut rusqlite::Connection, + cves: &Vec, + based_on_vulns_commit: &str, +) -> anyhow::Result<(), anyhow::Error> { + println!("Updating database"); + + let tx = db.transaction()?; + + for cve in cves { + tx.execute( + "REPLACE INTO cve (name, severity, attack_vector, attack_complexity, privileges_required, user_interaction, scope, confidentiality_impact, integrity_impact, availability_impact, description) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, $9, $10, $11)", + rusqlite::params![cve.name, cve.severity, cve.attack_vector, cve.attack_complexity, cve.privileges_required, cve.user_interaction, cve.scope, cve.confidentiality_impact, cve.integrity_impact, cve.availability_impact, cve.description], + )?; + + for instance in &cve.instances { + let instance_title = format!( + "{}-{}.{}.{}-{}", + cve.name, + instance.introduced.major, + instance.introduced.minor, + instance.introduced.patch, + instance + .fixed_commit_prefix + .as_ref() + .unwrap_or(&"".to_string()) + ); + + tx.execute( + "REPLACE INTO cve_instance + (title, cve, version_introduced_major, version_introduced_minor, version_introduced_patch, version_fixed_major, version_fixed_minor, version_fixed_patch, fixed_commit_prefix, patch) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + rusqlite::params![ + instance_title, + cve.name, + instance.introduced.major, + instance.introduced.minor, + instance.introduced.patch, + instance.fixed.as_ref().map(|fixed| fixed.major), + instance.fixed.as_ref().map(|fixed| fixed.minor), + instance.fixed.as_ref().map(|fixed| fixed.patch), + instance.fixed_commit_prefix, + instance.patch + ] + )?; + + if let Some(affected_files) = instance.affected_files.as_ref() { + for file_path in affected_files { + tx.execute( + "REPLACE INTO cve_instance_affected_file + (cve_instance, file_path) + VALUES (?1, ?2)", + rusqlite::params![instance_title, file_path], + )?; + } + } + } + } + + // Remove previous meta entry + tx.execute("DELETE FROM meta", [])?; + + let last_run_utc = chrono::Utc::now().to_string(); + tx.execute( + "REPLACE INTO meta (based_on_vulns_commit, last_run_utc) VALUES (?1, ?2)", + rusqlite::params![based_on_vulns_commit, last_run_utc], + )?; + + tx.commit()?; + + Ok(()) +} + +fn db_tables_exist(db: &rusqlite::Connection) -> bool { + // Assume tables exist, disprove with AND + let mut tables_exist = true; + + // If any of the following are errors, we know that the database is invalid + tables_exist = db.prepare("SELECT * FROM cve;").is_ok() && tables_exist; + tables_exist = db.prepare("SELECT * FROM cve_instance;").is_ok() && tables_exist; + tables_exist = db + .prepare("SELECT * FROM cve_instance_affected_file;") + .is_ok() + && tables_exist; + tables_exist = db.prepare("SELECT * FROM meta;").is_ok() && tables_exist; + tables_exist = db.prepare("SELECT * FROM kernel_config;").is_ok() && tables_exist; + tables_exist = db.prepare("SELECT * FROM kernel_file;").is_ok() && tables_exist; + + tables_exist +} + +fn create_database(db: &rusqlite::Connection) -> Result<(), rusqlite::Error> { + // TODO - error handling + let sql = "CREATE TABLE IF NOT EXISTS cve ( + name TEXT NOT NULL PRIMARY KEY, + severity REAL, + attack_vector TEXT, + attack_complexity TEXT, + privileges_required TEXT, + user_interaction TEXT, + scope TEXT, + confidentiality_impact TEXT, + integrity_impact TEXT, + availability_impact TEXT, + description TEXT + )"; + let _ = db.execute(sql, ())?; + + let sql = "CREATE TABLE IF NOT EXISTS cve_instance ( + title TEXT NOT NULL PRIMARY KEY, + cve TEXT NOT NULL, + version_introduced_major INTEGER NOT NULL, + version_introduced_minor INTEGER NOT NULL, + version_introduced_patch INTEGER, + version_fixed_major INTEGER, + version_fixed_minor INTEGER, + version_fixed_patch INTEGER, + fixed_commit_prefix TEXT, + patch BLOB, + FOREIGN KEY(cve) REFERENCES cve(name) + )"; + let _ = db.execute(sql, ())?; + + let sql = "CREATE TABLE IF NOT EXISTS cve_instance_affected_file ( + cve_instance TEXT NOT NULL, + file_path TEXT NOT NULL, + FOREIGN KEY(cve_instance) REFERENCES cve_instance(title) + )"; + let _ = db.execute(sql, ())?; + + let sql = "CREATE TABLE IF NOT EXISTS meta ( + based_on_vulns_commit TEXT NOT NULL PRIMARY KEY, + last_run_utc TEXT NOT NULL + )"; + let _ = db.execute(sql, ())?; + + let sql = "CREATE TABLE IF NOT EXISTS kernel_config ( + config_name TEXT NOT NULL PRIMARY KEY, + major INTEGER NOT NULL, + minor INTEGER NOT NULL, + patch INTEGER, + config_file BLOB NOT NULL + )"; + let _ = db.execute(sql, ())?; + + let sql = "CREATE TABLE IF NOT EXISTS kernel_file ( + file_path TEXT NOT NULL, + config_name TEXT NOT NULL, + FOREIGN KEY(config_name) REFERENCES kernel_config(config_name) + )"; + let _ = db.execute(sql, ())?; + + Ok(()) +} + +fn sync_repos() { + println!("Syncing repos"); + + let cve_repo_path = Path::new(CVE_REPO_PATH); + match cve_repo_path.try_exists() { + Ok(true) => { + checkout_repo_head(cve_repo_path).expect("Should be able to checkout CVE repo"); + } + Ok(false) => { + println!("Cloning CVE repo"); + let cve_repo_url = "https://git.kernel.org/pub/scm/linux/security/vulns.git"; + Repository::clone(cve_repo_url, cve_repo_path) + .expect("Should be able to clone CVE repo"); + } + Err(e) => { + panic!("Unexpected failure: {}", e); + } + }; + + let linux_repo_url = "https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git"; + let linux_repo_path = Path::new(&LINUX_REPO_PATH); + match linux_repo_path.try_exists() { + Ok(true) => { + checkout_repo_head(linux_repo_path).expect("Should be able to checkout Linux repo") + } + Ok(false) => { + println!( + "Cloning Linux repo. (This will take a long time without printouts. Please wait.)" + ); + Repository::clone(linux_repo_url, linux_repo_path) + .expect("Should be able to clone Linux repo"); + } + Err(e) => { + panic!("Unexpected failure: {}", e); + } + } + + println!("Repos done syncing"); +} + +fn checkout_repo_head(path: &Path) -> Result<()> { + let result = Command::new("git") + .current_dir(path) + .args(["clean", "-f", "-d", "-x"]) + .spawn()? + .wait_with_output()?; + + if !result.status.success() { + return Err(eyre!( + "git clean -f -d -x failed with output: {}", + String::from_utf8(result.stdout)? + )); + } + + let result = Command::new("git") + .current_dir(path) + .args(["reset", "--hard", "HEAD"]) + .spawn()? + .wait_with_output()?; + + if !result.status.success() { + return Err(eyre!( + "git reset --hard HEAD failed with output: {}", + String::from_utf8(result.stdout)? + )); + } + + let result = Command::new("git") + .current_dir(path) + .args(["checkout", "master"]) + .spawn()? + .wait_with_output()?; + + if !result.status.success() { + return Err(eyre!( + "git checkout master failed with output: {}", + String::from_utf8(result.stdout)? + )); + } + + let result = Command::new("git") + .current_dir(path) + .arg("pull") + .spawn()? + .wait_with_output()?; + + if !result.status.success() { + return Err(eyre!( + "git pull failed with output: {}", + String::from_utf8(result.stdout)? + )); + } + + Ok(()) +} + +#[derive(Debug)] +struct KernelVersion { + major: u8, + minor: u8, + patch: u16, +} + +impl TryFrom<&str> for KernelVersion { + type Error = anyhow::Error; + + fn try_from(value: &str) -> Result { + let split_value: Vec<&str> = value.split('.').collect(); + if split_value.len() < 2 { + return Err(anyhow::anyhow!( + "Kernel version must contain at least a major and minor" + )); + } + + let major = split_value[0]; + let minor = split_value[1]; + // Patch may or may not exist + let patch = split_value.get(2); + + let major = major.parse::()?; + let minor = minor.parse::()?; + let patch = if patch.is_some() { + patch.unwrap().parse::()? + } else { + 0 + }; + + Ok(KernelVersion { + major, + minor, + patch, + }) + } +} + +/// An instance of a CVE which was introduced in a particular kernel version. +/// This CVE may or may not have been fixed in a future version. If it has, +/// then a fix should exist. +#[derive(Debug)] +struct CveInstance { + introduced: KernelVersion, + fixed: Option, + fixed_commit_prefix: Option, + patch: Option, + affected_files: Option>, +} + +/// A particular CVE and all instances of it occuring within different versions of the kernel. +#[derive(Debug)] +struct Cve { + name: String, + severity: Option, + attack_vector: Option, + attack_complexity: Option, + privileges_required: Option, + user_interaction: Option, + scope: Option, + confidentiality_impact: Option, + integrity_impact: Option, + availability_impact: Option, + description: Option, + instances: Vec, +} + +fn generate_patch( + linux_repo: &Repository, + fixed_commit_hash: &str, +) -> Result { + let fixed_commit = linux_repo.find_commit(Oid::from_str(fixed_commit_hash)?)?; + let parent_commit = fixed_commit.parent(0)?; + let diff = linux_repo.diff_tree_to_tree( + Some(&parent_commit.tree()?), + Some(&fixed_commit.tree()?), + None, + )?; + + let mut patch = Patch::default(); + for i in 0..diff.stats()?.files_changed() { + let file_path = diff + .get_delta(i) + .expect("delta[i] should exist if files_changed() goes up to i") + .old_file() + .path(); + + if let Some(file_path) = file_path { + patch.affected_files.push( + file_path + .to_str() + .expect("File path should be UTF-8 valid") + .to_string(), + ); + } + + let file_patch = git2::Patch::from_diff(&diff, i)?; + if let Some(mut file_patch) = file_patch { + if let Ok(file_patch_buf) = file_patch.to_buf() { + if let Some(file_patch_str) = file_patch_buf.as_str() { + patch.patch_file.push_str(file_patch_str); + } + } + } + } + + Ok(patch) +} + +#[derive(Default)] +struct Patch { + patch_file: String, + affected_files: Vec, +} + +/// Fetches all new CVEs and patches since the commit specified by last_vulns_commit up until to_commit. +/// If last_vulns_commit is None, this will fetch all CVEs and patches. +fn fetch_cves(from_commit: Option<&str>) -> anyhow::Result, anyhow::Error> { + println!("Fetching CVEs"); + + let mut cves: Vec = Vec::new(); + let linux_repo = Repository::open(LINUX_REPO_PATH).expect("Linux repo should exist"); + let vulns_repo = Repository::open(CVE_REPO_PATH).expect("Vulns repo should exist"); + + let vulns_initial_commit = "9bcaccade458ba4cc2e44e8a84b4be6fa36d46aa"; + let from_commit_id = git2::Oid::from_str(from_commit.unwrap_or(vulns_initial_commit))?; + let to_commit_id = vulns_repo.revparse("HEAD")?.from().unwrap().id(); + + let from_commit = vulns_repo.find_commit(from_commit_id)?; + let to_commit = vulns_repo.find_commit(to_commit_id)?; + + // We only care about .dyad files regarding published CVEs + let mut opts = git2::DiffOptions::new(); + opts.pathspec("cve/published/*/*.dyad"); + + let diff = vulns_repo.diff_tree_to_tree( + Some(&from_commit.tree()?), + Some(&to_commit.tree()?), + Some(&mut opts), + )?; + for delta in diff.deltas() { + let dyad_relative_path = delta.new_file().path().unwrap().to_str().unwrap(); + let dyad_real_path = format!("{}/{}", CVE_REPO_PATH, dyad_relative_path); + let dyad_real_path = Path::new(&dyad_real_path); + + if !dyad_real_path.exists() { + // Just because a dyad is mentioned in the delta doesn't mean it was created: it could have been moved. + continue; + } + + if let Some(cve) = get_patch_commits_from_dyad(Path::new(&dyad_real_path), &linux_repo) { + println!("Found new CVE: {}", &cve.name); + cves.push(cve); + } + } + println!("Done fetching CVEs"); + + Ok(cves) +} + +fn get_patch_commits_from_dyad(dyad_path: &Path, linux_repo: &Repository) -> Option { + let file = File::open(dyad_path).unwrap(); + let dyad = BufReader::new(file); + + let cve_name = dyad_path.file_stem()?.to_str()?; + let mut cve = Cve { + name: cve_name.to_string(), + // NIST-specific fields to be added later + severity: None, + attack_vector: None, + attack_complexity: None, + privileges_required: None, + user_interaction: None, + scope: None, + confidentiality_impact: None, + integrity_impact: None, + availability_impact: None, + description: None, + // End NIST-specific fields + instances: Vec::new(), + }; + + for line in dyad.lines() { + let line = line.unwrap(); + let trimmed_line = line.trim(); + + if trimmed_line.contains("#") { + // Dyad uses # for comment lines. Skip those. + continue; + } + + // Dyad is in the following format: + // {introuced_ver}:{introduced_commit_hash}:{fixed_ver}:{fixed_commit_hash} + // + // If {introduced_ver} or {introduced_commit_hash} == 0, the issue was introduced before + // the Linux stable Git repository history (version 2.6.12, commit 1da177e4c3f4). + // If {fixed_ver} or {fixed_commit} == 0, there is no fix for that kernel version. + let split_line: Vec<&str> = trimmed_line.split_terminator(':').collect(); + + if split_line.len() < 4 { + println!("Improperly formatted .dyad received for: {}", cve.name); + continue; + } + + let introduced_ver = if split_line[0] == "0" { + "2.6.12" + } else { + split_line[0] + }; + let fixed_ver = split_line[2]; + let fixed_commit_hash = split_line[3]; + let fixed_commit_prefix = if fixed_commit_hash.len() >= 12 { + &fixed_commit_hash[..12] + } else { + "0" + }; + + let Ok(cve_introduced) = KernelVersion::try_from(introduced_ver) else { + println!("Unsupported kernel version received: {}", introduced_ver); + continue; + }; + + if fixed_commit_hash == "0" { + cve.instances.push(CveInstance { + introduced: cve_introduced, + fixed: None, + fixed_commit_prefix: None, + patch: None, + affected_files: None, + }); + continue; + } + + let Ok(fixed_ver) = KernelVersion::try_from(fixed_ver) else { + println!("Unsupported kernel version received: {}", fixed_ver); + continue; + }; + + let patch = generate_patch(linux_repo, fixed_commit_hash).unwrap(); + cve.instances.push(CveInstance { + introduced: cve_introduced, + fixed: Some(fixed_ver), + fixed_commit_prefix: Some(fixed_commit_prefix.to_string()), + patch: Some(patch.patch_file), + affected_files: Some(patch.affected_files), + }); + } + + Some(cve) +} + +/// Fetches the metadata for the CVEs from the NIST API and adds it to the argument if it exists. +/// +/// `full_fetch` should be `true` if this program's database is being fully initialized and `false` otherwise. +/// For speed purposes, this program does batch requests to the API when initializing its database and iteritive +/// requests when updating its database. +fn fetch_nist(cves: &mut Vec, full_fetch: bool, api_key: Option<&str>) { + println!("Fetching CVE severities from NIST."); + + if full_fetch { + fetch_nist_batch(cves, api_key); + } else { + fetch_nist_iteritive(cves, api_key); + } +} + +/// Pulls the entire CVE database from NIST for initialization of this program's database. +fn fetch_nist_batch(cves: &mut Vec, api_key: Option<&str>) { + // Set window duration to >30s even though NIST suggests 30s as it will 403 if it's spot on. + let mut rate_limiter = if api_key.is_some() { + RateLimiter::new(50, Duration::from_secs(32)) + } else { + RateLimiter::new(5, Duration::from_secs(32)) + }; + + // NIST API has a maximum of 2000 results-per-page when pulling in batch. + let results_per_page = 2000; + let results_per_page_string = results_per_page.to_string(); + + let mut vulnerabilities: HashMap = HashMap::new(); + + // last_index will later become the `totalResults` field from the response. + let mut last_index = 1; + let mut current_index = 0; + while current_index <= last_index { + rate_limiter.limit(); + + let response = ureq::get(NIST_API_URL) + .query("resultsPerPage", results_per_page_string.as_str()) + .query("startIndex", current_index.to_string().as_str()) + .call(); + + match response { + Ok(response) => { + let nist_response: Result = response.into_json(); + match nist_response { + Ok(nist_response) => { + last_index = nist_response.total_results; + for response_vuln in nist_response.vulnerabilities { + vulnerabilities.insert(response_vuln.cve.id.clone(), response_vuln); + } + } + Err(e) => { + eprintln!("Could not parse NIST API response: {}", &e); + } + } + // This print is after the match block as last_index will not be correct + // until the first response. + println!("Fetching CVE metadata: [{current_index}/{last_index}]"); + } + Err(e) => { + eprintln!("Bad response from NIST API: {}", &e); + println!("Requesting index {} again.", current_index); + // Try requesting this index again + continue; + } + } + + current_index += results_per_page; + } + + for cve in cves { + if let Some(vulnerability) = vulnerabilities.get(&cve.name) { + println!("Fetched new metadata for {}", &cve.name); + update_cve_from_nist_vulnerability(cve, vulnerability); + } + } +} + +/// Fetches NIST data by making individual requests to the NIST API. +fn fetch_nist_iteritive(cves: &mut [Cve], api_key: Option<&str>) { + // Set window duration to >30s even though NIST suggests 30s as it will 403 if it's spot on. + let mut rate_limiter = if api_key.is_some() { + RateLimiter::new(50, Duration::from_secs(32)) + } else { + RateLimiter::new(5, Duration::from_secs(32)) + }; + + let mut index = 0; + while index < cves.len() { + // SAFETY: Use of [] addressing is safe as `cves` will never be resized. + let cve = &mut cves[index]; + + rate_limiter.limit(); + + let response = ureq::get(NIST_API_URL).query("cveId", &cve.name).call(); + match response { + Ok(response) => { + let nist_response: Result = response.into_json(); + match nist_response { + Ok(nist_response) => { + if let Some(vulnerability) = nist_response.vulnerabilities.first() { + println!("Fetched new metadata for {}", &cve.name); + update_cve_from_nist_vulnerability(cve, vulnerability); + } + } + Err(e) => { + eprintln!("Could not parse NIST API response: {}", &e); + } + } + } + Err(e) => { + eprintln!("Bad response from NIST API: {}", &e); + println!("Requesting metadata for {} again.", &cve.name); + continue; + } + } + + index += 1; + } +} + +/// Update a single CVE from a single NIST vulnerability. +fn update_cve_from_nist_vulnerability(cve: &mut Cve, vulnerability: &Vulnerability) { + // There should only be <=1 english description, but it'll be deserialized into a vec + let english_description: Vec<&nist_api_types::Description> = vulnerability + .cve + .descriptions + .iter() + .filter(|desc| desc.lang == "en") + .collect(); + if let Some(description) = english_description.first() { + cve.description = Some(description.value.clone()); + } + + if let Some(metric) = &vulnerability.cve.metrics.cvss_metric_v31 { + if let Some(cvss_metric_v31) = metric.first() { + let cvss_data = cvss_metric_v31.cvss_data.clone(); + cve.severity = Some(cvss_data.base_score); + cve.attack_vector = Some(cvss_data.attack_vector); + cve.attack_complexity = Some(cvss_data.attack_complexity); + cve.privileges_required = Some(cvss_data.privileges_required); + cve.user_interaction = Some(cvss_data.user_interaction); + cve.scope = Some(cvss_data.scope); + cve.confidentiality_impact = Some(cvss_data.confidentiality_impact); + cve.integrity_impact = Some(cvss_data.integrity_impact); + cve.availability_impact = Some(cvss_data.availability_impact); + } + } else { + // If vulnerabilities/cve/metrics/cvss_metric_v31 is empty, this CVE has not yet been rated. + // Ignore this one. + } +} diff --git a/src/parser/rate_limiter.rs b/src/parser/rate_limiter.rs new file mode 100644 index 0000000..3095215 --- /dev/null +++ b/src/parser/rate_limiter.rs @@ -0,0 +1,62 @@ +use std::collections::VecDeque; +use std::time::{Duration, SystemTime}; + +/// Used to allow a rolling-window type rate limit. +/// For example, the NIST API allows up to 5 requests in a 30 second window without an API key, so +/// RateLimiter would be initialized with `RateLimiter::new(window_size: 5, window_duration: Duration::from_secs(30))` +pub struct RateLimiter { + /// A LIFO queue containing the SystemTime that each run of limit() was called + run_times: VecDeque, + /// The amount of runs that should be allowed within window_duration + window_size: usize, + /// The duration of time each + window_duration: Duration, + /// Initially `false`, but becomes `true` once the window is saturated. + window_saturated: bool, +} + +impl RateLimiter { + pub fn new(window_size: usize, window_duration: Duration) -> Self { + let window_size = if window_size == 0 { + eprintln!( + "RateLimiter::window_size must be >0. Returning an instance with a window_size of 1" + ); + 1 + } else { + window_size + }; + + Self { + run_times: VecDeque::new(), + window_size, + window_duration, + window_saturated: false, + } + } + + /// If the window is saturated, calls `thread::sleep` for the amount of time that needs to be limited. + pub fn limit(&mut self) { + if self.run_times.len() == (self.window_size - 1) { + self.window_saturated = true; + } + + if self.window_saturated { + let oldest_run_time = self + .run_times + .pop_front() + .expect("run_times will always have a >1 length if this block is hit"); + + let current_time = SystemTime::now(); + let duration_since_oldest = current_time + .duration_since(oldest_run_time) + .expect("Time never flows backwards"); + + if duration_since_oldest < self.window_duration { + let sleep_duration = self.window_duration - duration_since_oldest; + std::thread::sleep(sleep_duration); + } + } + + self.run_times.push_back(SystemTime::now()); + } +} diff --git a/src/server/build_queue.rs b/src/server/build_queue.rs new file mode 100644 index 0000000..efc8af7 --- /dev/null +++ b/src/server/build_queue.rs @@ -0,0 +1,479 @@ +use crate::{ + get_db_connection, + tuxtape_server::{ + builder_client::BuilderClient, BuildKernelRequest, BuildKernelResponse, KernelConfig, + PutKernelConfigRequest, RegisterKernelBuilderRequest, + }, + Args, +}; +use std::{ + collections::{HashMap, VecDeque}, + path::Path, + sync::Arc, + time::SystemTime, +}; +use tokio::sync::{ + mpsc::{self, UnboundedReceiver, UnboundedSender}, + RwLock, +}; +use tonic::{ + codec::CompressionEncoding, + transport::{Certificate, Channel, ClientTlsConfig}, + Status, +}; +use tonic_health::pb::{ + health_check_response::ServingStatus, health_client::HealthClient, HealthCheckRequest, +}; + +pub enum BuildAction { + RegisterKernelBuilder { + request: RegisterKernelBuilderRequest, + }, + RemoveKernelBuilder { + builder_address: String, + }, + AddJob { + request: PutKernelConfigRequest, + }, + BuildCompleted { + builder_address: String, + resp: BuildKernelResponse, + }, + BuildFailed { + builder_address: String, + status: Status, + }, +} + +pub struct BuildQueue { + args: Arc, + builders: Arc>>>, + job_queue: Arc>>, + pub rx: UnboundedReceiver, + pub tx: UnboundedSender, +} + +struct Builder { + client: Arc>>, + address: String, + job: Arc>>, + tx: UnboundedSender, +} + +#[derive(Clone)] +struct Job { + kernel_config: KernelConfig, + // TODO (MVP) - rename _time_started to time_started and + // use it in monitoring messages + _time_started: SystemTime, +} + +impl BuildQueue { + pub fn new(args: Arc) -> Self { + let (tx, rx) = mpsc::unbounded_channel::(); + + Self { + args, + builders: Arc::new(RwLock::new(HashMap::new())), + job_queue: Arc::new(RwLock::new(VecDeque::new())), + rx, + tx, + } + } + + pub fn handle_action(&mut self, action: &BuildAction) { + match action { + BuildAction::AddJob { request } => { + let kernel_config = if let Some(kernel_config) = &request.kernel_config { + kernel_config + } else { + eprintln!("Attempted to add build job from PutKernelConfigRequest with no kernel_config field"); + return; + }; + + tokio::spawn(enqueue_job(self.job_queue.clone(), kernel_config.clone())); + } + BuildAction::BuildCompleted { + builder_address, + resp, + } => { + tokio::spawn(write_build_profile_to_db( + self.args.clone(), + self.builders.clone(), + builder_address.clone(), + resp.clone(), + )); + } + BuildAction::BuildFailed { + builder_address, + status, + } => { + eprintln!( + "Build failed! Removing kernel builder with address {}. Status: {}", + builder_address, status + ); + + tokio::spawn(remove_kernel_builder( + self.builders.clone(), + self.job_queue.clone(), + builder_address.to_string(), + )); + } + BuildAction::RegisterKernelBuilder { request } => { + tokio::spawn(register_kernel_builder( + self.args.clone(), + self.builders.clone(), + self.tx.clone(), + request.clone(), + )); + } + BuildAction::RemoveKernelBuilder { builder_address } => { + tokio::spawn(remove_kernel_builder( + self.builders.clone(), + self.job_queue.clone(), + builder_address.clone(), + )); + } + } + } + + pub async fn assign_jobs(&mut self) { + let mut job_queue = self.job_queue.write().await; + let mut builders = self.builders.write().await; + 'jobs: while let Some(job) = job_queue.pop_front() { + for (address, builder) in builders.iter_mut() { + if builder.job.read().await.is_none() { + println!( + "Assigning job {} to builder {}", + job.kernel_config + .metadata + .as_ref() + .expect("metadata must exist here") + .config_name, + address + ); + + tokio::spawn(build_kernel(builder.clone(), job)); + continue 'jobs; + } + } + + // We weren't able to assign the popped job to a builder. Put it back at highest priority. + job_queue.push_front(job); + return; + } + } +} + +async fn register_kernel_builder( + args: Arc, + builders: Arc>>>, + tx: UnboundedSender, + request: RegisterKernelBuilderRequest, +) { + let url = match args.tls { + true => format!("https://{}", request.builder_address), + false => format!("http://{}", request.builder_address), + }; + println!("Getting connection to KernelBuilder at URL: {}", url); + + // Strip port from URL if one was provided + let domain_name = if let Some(domain_name) = request + .builder_address + .split(':') + .collect::>() + .first() + { + *domain_name + } else { + &url + }; + + let channel = if args.tls { + let ca_path = &args.tls_ca_path; + let pem = std::fs::read_to_string(ca_path).expect("ca_path does not exist"); + let ca = Certificate::from_pem(pem); + + let tls = ClientTlsConfig::new() + .ca_certificate(ca) + .domain_name(domain_name); + + let endpoint = match Channel::from_shared(url.clone()) { + Ok(endpoint) => endpoint, + Err(e) => { + eprintln!("{}", e); + return; + } + }; + let endpoint = match endpoint.tls_config(tls) { + Ok(endpoint) => endpoint, + Err(e) => { + eprintln!("Failed to create endpoint: {}", e); + return; + } + }; + + endpoint.connect().await + } else { + let endpoint = match Channel::from_shared(url.clone()) { + Ok(endpoint) => endpoint, + Err(e) => { + eprintln!("Failed to create endpoint: {}", e); + return; + } + }; + + endpoint.connect().await + }; + + let channel = match channel { + Ok(channel) => channel, + Err(e) => { + eprintln!("Failed to create endpoint: {}", e); + return; + } + }; + + let client = Arc::new(RwLock::new( + BuilderClient::new(channel.clone()) + .accept_compressed(CompressionEncoding::Gzip) + .send_compressed(CompressionEncoding::Gzip) + .max_decoding_message_size(usize::MAX) + .max_encoding_message_size(usize::MAX), + )); + + let job = Arc::new(RwLock::new(None)); + + let builder = Arc::new(Builder { + client, + address: url.clone(), + job, + tx: tx.clone(), + }); + + { + let mut builders = builders.write().await; + builders.insert(url.clone(), builder); + } + + tokio::spawn(watch_builder_health(url, channel.clone(), tx)); +} + +async fn enqueue_job(job_queue: Arc>>, kernel_config: KernelConfig) { + let metadata = if let Some(metadata) = &kernel_config.metadata { + metadata + } else { + eprintln!("Attempted to enqueue job for kernel_config without metadata field"); + return; + }; + + println!("Enqueueing job for {}", metadata.config_name); + + let job = Job { + kernel_config, + _time_started: SystemTime::now(), + }; + job_queue.write().await.push_back(job); +} + +async fn remove_kernel_builder( + builders: Arc>>>, + job_queue: Arc>>, + builder_address: String, +) { + println!("Removing builder {} from queue.", builder_address); + + let mut builders = builders.write().await; + let builder = if let Some(builder) = builders.get(&builder_address) { + builder + } else { + eprintln!("No builder matching address {} found", &builder_address); + return; + }; + + // If builder had a job, add it to the front of the queue + if let Some(job) = builder.job.write().await.take() { + let mut job_queue = job_queue.write().await; + job_queue.push_front(job); + } + + builders.remove(&builder_address); +} + +async fn build_kernel(builder: Arc, job: Job) { + println!("Building kernel"); + + let mut builder_job = builder.job.write().await; + builder_job.replace(job.clone()); + + let request = BuildKernelRequest { + kernel_config: Some(job.kernel_config.clone()), + }; + + let builder_clone = builder.clone(); + tokio::spawn(async move { + let mut builder_client = builder_clone.client.write().await; + let result = builder_client.build_kernel(request).await; + + match result { + Ok(resp) => builder_clone + .tx + .send(BuildAction::BuildCompleted { + builder_address: builder_clone.address.clone(), + resp: resp.into_inner(), + }) + .expect("Send should never fail"), + Err(status) => builder_clone + .tx + .send(BuildAction::BuildFailed { + builder_address: builder_clone.address.clone(), + status, + }) + .expect("Send should never fail"), + }; + }); +} + +async fn write_build_profile_to_db( + args: Arc, + builders: Arc>>>, + builder_address: String, + resp: BuildKernelResponse, +) { + let mut builders = builders.write().await; + let builder = if let Some(builder) = builders.get_mut(&builder_address) { + builder + } else { + eprintln!("Failed to find builder at address: {}", builder_address); + return; + }; + + // Take ownership of Job from builder + let job = builder + .job + .write() + .await + .take() + .expect("We know there was a job here"); + + let metadata = job + .kernel_config + .metadata + .as_ref() + .expect("metadata should always exist here"); + + println!( + "Build job for {} finished on builder {}", + metadata.config_name, builder_address + ); + + let included_files = resp.included_files; + let kernel_config = &job.kernel_config; + let metadata = kernel_config + .metadata + .as_ref() + .expect("metadata must exist here"); + let kernel_version = metadata + .kernel_version + .expect("kernel version must exist here"); + + let db = match get_db_connection(Path::new(&args.db_path)) { + Ok(db) => db, + Err(e) => { + eprintln!("Failed to get connection to database: {}", e); + return; + } + }; + + match db.execute( + "REPLACE INTO kernel_config (config_name, major, minor, patch, config_file) VALUES (?1, ?2, ?3, ?4, ?5)", + rusqlite::params![ + metadata.config_name, + kernel_version.major, + kernel_version.minor, + kernel_version.patch, + kernel_config.config_file + ], + ) { + Ok(_) => {}, + Err(e) => { + eprintln!("Failed to write job for {} into database. Error: {}", metadata.config_name, e); + return; + } + } + + for file in included_files { + match db.execute( + "REPLACE INTO kernel_file (file_path, config_name) VALUES (?1, ?2)", + rusqlite::params![file, metadata.config_name], + ) { + Ok(_) => {} + Err(e) => { + eprintln!( + "Failed to write job for {} into database. Error: {}", + metadata.config_name, e + ); + return; + } + } + } + + println!( + "Finished adding {} profile to database", + metadata.config_name + ) +} + +async fn watch_builder_health( + builder_address: String, + channel: Channel, + tx: UnboundedSender, +) { + let mut health_client = HealthClient::new(channel) + .accept_compressed(CompressionEncoding::Gzip) + .send_compressed(CompressionEncoding::Gzip); + + let result = health_client + .watch(HealthCheckRequest { + service: "tuxtape_server.Builder".to_string(), + }) + .await; + + match result { + Ok(resp) => { + let mut stream = resp.into_inner(); + while let Some(message) = stream.message().await.transpose() { + match message { + Ok(resp) => match resp.status() { + ServingStatus::Serving => {} + _ => { + eprintln!( + "Kernel builder at {} no longer serving requests.", + &builder_address + ); + tx.send(BuildAction::RemoveKernelBuilder { builder_address }) + .expect("Send should never fail"); + + return; + } + }, + Err(_) => { + println!("Lost connection to kernel builder at {}", &builder_address); + tx.send(BuildAction::RemoveKernelBuilder { builder_address }) + .expect("Send should never fail"); + + return; + } + } + } + } + Err(e) => { + eprintln!( + "Could not connect to health service on kernel builder at {}. Error: {}", + &builder_address, e + ); + tx.send(BuildAction::RemoveKernelBuilder { builder_address }) + .expect("Send should never fail") + } + } +} diff --git a/src/server/server.rs b/src/server/server.rs new file mode 100644 index 0000000..ca51027 --- /dev/null +++ b/src/server/server.rs @@ -0,0 +1,808 @@ +pub mod build_queue; + +use build_queue::{BuildAction, BuildQueue}; +use clap::Parser; +use color_eyre::eyre::eyre; +use color_eyre::Result; +use rusqlite::{OpenFlags, Row}; +use std::path::Path; +use std::rc::Rc; +use std::sync::Arc; +use std::time::Duration; +use tuxtape_server::database_server::{Database, DatabaseServer}; +use tuxtape_server::{ + Cve, CveInstance, FetchCvesReponse, FetchCvesRequest, FetchKernelConfigRequest, + FetchKernelConfigResponse, FetchKernelConfigsMetadataRequest, + FetchKernelConfigsMetadataResponse, KernelConfig, KernelConfigMetadata, KernelVersion, + PutKernelConfigRequest, PutKernelConfigResponse, RegisterKernelBuilderRequest, + RegisterKernelBuilderResponse, +}; +use tokio::sync::Mutex; +use tonic::codec::CompressionEncoding; +use tonic::{ + transport::{Identity, Server, ServerTlsConfig}, + Request, Response, Status, +}; + +#[derive(Parser, Clone)] +#[command(version, about, long_about = None)] +pub struct Args { + /// The path to the database + #[arg(short('d'), long, default_value = concat!(env!("HOME"), "/.cache/tuxtape-server/db.db3"))] + db_path: String, + + /// The socket address for this server, either IPv4 or IPv6. + #[arg(short('s'), long, default_value = "127.0.0.1:50051")] + addr: String, + + /// Enables TLS support + #[arg(short('t'), long, requires_all(["tls_cert_path", "tls_key_path"]), default_value = "false")] + tls: bool, + + /// Path to TLS CA (requires -t) + #[arg(long, requires("tls"), default_value = "")] + tls_ca_path: String, + + /// Path to TLS certificate (requires -t) + #[arg(long, requires("tls"), default_value = "")] + tls_cert_path: String, + + /// Path to TLS key (requires -t) + #[arg(long, requires("tls"), default_value = "")] + tls_key_path: String, +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let args = Arc::new(Args::parse()); + + check_db_tables_exist(Path::new(&args.clone().db_path))?; + + loop { + // This will return if the server crashes for any reason, so we want to keep this in a loop. + // In the future, we should log the errors should the server crash. + start_server(args.clone()).await?; + } +} + +async fn start_server(args: Arc) -> Result<(), Box> { + let addr = args.addr.parse()?; + let database = MyDatabase::new(args.clone()); + let reflection_service = tonic_reflection::server::Builder::configure() + .register_encoded_file_descriptor_set(tuxtape_server::FILE_DESCRIPTOR_SET) + .build_v1()?; + + let (mut health_reporter, health_service) = tonic_health::server::health_reporter(); + health_reporter + .set_serving::>() + .await; + + if args.tls { + let cert = std::fs::read_to_string(&args.tls_cert_path)?; + let key = std::fs::read_to_string(&args.tls_key_path)?; + let identity = Identity::from_pem(cert, key); + + Server::builder() + .tls_config(ServerTlsConfig::new().identity(identity))? + .add_service( + DatabaseServer::new(database) + .accept_compressed(CompressionEncoding::Gzip) + .send_compressed(CompressionEncoding::Gzip) + .max_decoding_message_size(usize::MAX) + .max_encoding_message_size(usize::MAX), + ) + .add_service(reflection_service) + .add_service( + health_service + .accept_compressed(CompressionEncoding::Gzip) + .send_compressed(CompressionEncoding::Gzip), + ) + .serve(addr) + .await? + } else { + Server::builder() + .add_service( + DatabaseServer::new(database) + .accept_compressed(CompressionEncoding::Gzip) + .send_compressed(CompressionEncoding::Gzip), + ) + .add_service(reflection_service) + .add_service( + health_service + .accept_compressed(CompressionEncoding::Gzip) + .send_compressed(CompressionEncoding::Gzip), + ) + .serve(addr) + .await? + } + + Ok(()) +} + +pub mod tuxtape_server { + tonic::include_proto!("tuxtape_server"); + + pub(crate) const FILE_DESCRIPTOR_SET: &[u8] = + tonic::include_file_descriptor_set!("tuxtape_server_descriptor"); +} + +pub struct MyDatabase { + args: Arc, + build_queue: Arc>, +} + +impl MyDatabase { + fn new(args: Arc) -> Self { + let build_queue = Arc::new(Mutex::new(BuildQueue::new(args.clone()))); + tokio::spawn(loop_update_build_queue(build_queue.clone())); + + Self { args, build_queue } + } +} + +async fn loop_update_build_queue(build_queue: Arc>) { + loop { + let mut build_queue = build_queue.lock().await; + while let Ok(action) = build_queue.rx.try_recv() { + build_queue.handle_action(&action); + } + + build_queue.assign_jobs().await; + + // Wait a bit before receiving new events to prevent CPU overuse + tokio::time::sleep(Duration::from_millis(100)).await; + } +} + +#[tonic::async_trait] +impl Database for MyDatabase { + async fn fetch_cves( + &self, + request: Request, + ) -> Result, Status> { + println!("New request to fetch CVEs from {:?}", request.remote_addr()); + + let fetch_cve_req = request.into_inner(); + + let db = get_db_connection(Path::new(&self.args.db_path)) + .map_err(|err| Status::from_error(Box::new(err)))?; + let cves = fetch_cves(&db, fetch_cve_req).map_err(|e| Status::from_error(Box::new(e)))?; + + let resp = FetchCvesReponse { cves }; + Ok(Response::new(resp)) + } + + async fn fetch_kernel_configs_metadata( + &self, + request: Request, + ) -> Result, Status> { + println!( + "New request to fetch kernel configs metadata from {:?}", + request.remote_addr() + ); + + let db = get_db_connection(Path::new(&self.args.db_path)) + .map_err(|e| Status::from_error(Box::new(e)))?; + + let query = "SELECT name, major, minor, patch FROM kernel_config"; + let mut stmt = db.prepare(query).unwrap(); + let kernel_configs = stmt + .query_map([], |row| { + let config_name: String = row.get(0)?; + let major: u32 = row.get(1)?; + let minor: u32 = row.get(2)?; + let patch: Option = row.get(3)?; + + let kernel_version = Some(KernelVersion { + major, + minor, + patch, + }); + + Ok(KernelConfigMetadata { + config_name, + kernel_version, + }) + }) + .map_err(|e| Status::from_error(Box::new(e)))?; + + let resp = FetchKernelConfigsMetadataResponse { + metadata: kernel_configs.flatten().collect(), + }; + Ok(Response::new(resp)) + } + + async fn fetch_kernel_config( + &self, + request: Request, + ) -> Result, Status> { + println!( + "New request to fetch kernel configs from {:?}", + request.remote_addr() + ); + + let db = get_db_connection(Path::new(&self.args.db_path)) + .map_err(|e| Status::from_error(Box::new(e)))?; + + let metadata = request + .into_inner() + .metadata + .expect("Inner should always have metadata"); + + let query = " + SELECT + config_file + FROM + kernel_config kc + WHERE + kc.config_name = :config_name + "; + let mut stmt = db + .prepare(query) + .map_err(|e| Status::from_error(Box::new(e)))?; + let mut result = stmt + .query(rusqlite::named_params! {":config_name": metadata.config_name}) + .map_err(|e| Status::from_error(Box::new(e)))?; + + let row = result.next().map_err(|e| Status::from_error(Box::new(e)))?; + if let Some(row) = row { + let config_file: String = row.get(0).map_err(|e| Status::from_error(Box::new(e)))?; + + let metadata = Some(KernelConfigMetadata { + config_name: metadata.config_name, + kernel_version: metadata.kernel_version, + }); + let kernel_config = Some(KernelConfig { + metadata, + config_file, + }); + + let resp = FetchKernelConfigResponse { kernel_config }; + return Ok(Response::new(resp)); + } else { + return Err(Status::not_found( + "KernelConfig matching provided metadata not found", + )); + } + } + + async fn put_kernel_config( + &self, + request: Request, + ) -> Result, Status> { + println!( + "New request to put kernel config from {:?}", + request.remote_addr() + ); + + let request = request.into_inner(); + + let build_queue = self.build_queue.lock().await; + build_queue + .tx + .send(BuildAction::AddJob { request }) + .expect("Send should never fail"); + + Ok(Response::new(PutKernelConfigResponse {})) + } + + async fn register_kernel_builder( + &self, + request: Request, + ) -> Result, Status> { + println!( + "New request to register kernel builder from {:?}", + request.remote_addr() + ); + + let request = request.into_inner(); + let action = BuildAction::RegisterKernelBuilder { request }; + + { + let build_queue = self.build_queue.lock().await; + build_queue.tx.send(action).expect("send should never fail"); + } + + let resp = RegisterKernelBuilderResponse {}; + Ok(Response::new(resp)) + } +} + +fn get_db_connection(db_path: &Path) -> Result { + rusqlite::Connection::open_with_flags(db_path, OpenFlags::SQLITE_OPEN_READ_WRITE) +} + +fn check_db_tables_exist(db_path: &Path) -> Result<(), rusqlite::Error> { + // TODO - update for all tables + let db = get_db_connection(db_path)?; + + let mut stmt = db.prepare("SELECT * FROM cve;")?; + let _ = stmt.query(())?; + + let mut stmt = db.prepare("SELECT * FROM cve_instance;")?; + let _ = stmt.query(())?; + + println!("Database exists"); + + Ok(()) +} + +/// Fetch the CVEs that include files that are included in the builds of the kernel_configs of the request. +/// If request.kernel_configs_metadata is empty, returns CVEs that affect all KernelConfigs in the database. +/// If request.kernel_configs_metadata is not empty, returns CVEs that affect the specified KernelConfigs. +fn fetch_cves( + db: &rusqlite::Connection, + request: FetchCvesRequest, +) -> Result, rusqlite::Error> { + // Load array module into sqlite instance + rusqlite::vtab::array::load_module(db)?; + + // Create an rarray-compatible list of kernel config names from the request + let mut config_names: Vec = Vec::new(); + for config in &request.kernel_configs_metadata { + config_names.push(config.config_name.clone()); + } + let request_configs = Rc::new( + config_names + .iter() + .cloned() + .map(rusqlite::types::Value::from) + .collect::>(), + ); + let params = if request_configs.is_empty() { + rusqlite::params![] + } else { + rusqlite::params![request_configs] + }; + + // This query is unfortunately massive and convoluted, but it is the fastest way to get the results + // that I've tested. Trying to do individual queries to flesh out data on fields of CveInstances + // is much more readable, but takes magnitudes longer to return a result. + // + // The query groups its results into a particular order so that we don't need to create hashmaps + // when building out complete Cve objects then copy them into into vectors. + // The all_relevant_cve_instances subquery first determines the most relevant CveInstance for each + // Cve that affects each KernelConfig. + // Once we know the most relevant CveInstance for each KernelConfig, it then joins that with the Cve + // metadata for each CveInstance. + // Each row that gets returned contains part of the information for us to construct a complete Cve. + // To avoid having to use hashmaps to construct the members of a Cve that are arrays + // (all CveInstances of that Cve, all files that each CveInstance affects, all KernelConfigs that + // each CveInstance affects), the query groups its output in a particular order that allows us to + // first construct a complete CveInstance, then once we've created all CveInstances for a Cve, + // we can complete the construction of that Cve and repeat the cycle on a new Cve. + let cve_instances_query = if request_configs.is_empty() { + " + WITH + all_relevant_cve_instances AS ( + SELECT + cve, + title, + version_introduced_major, + version_introduced_minor, + version_introduced_patch, + version_fixed_major, + version_fixed_minor, + version_fixed_patch, + fixed_commit_prefix, + ci.patch as patch, + config_name, + kc.major as kernel_major, + kc.minor as kernel_minor, + kc.patch as kernel_patch + FROM + cve_instance ci + INNER JOIN kernel_config kc ON ( + ( + kc.major > ci.version_introduced_major + OR ( + kc.major = ci.version_introduced_major + AND kc.minor > ci.version_introduced_minor + ) + OR ( + kc.major = ci.version_introduced_major + AND kc.minor = ci.version_introduced_minor + AND COALESCE(kc.patch, 0) >= COALESCE(ci.version_introduced_patch, 0) + ) + ) + AND ( + ci.version_fixed_major = kc.major + AND ci.version_fixed_minor = kc.minor + AND COALESCE(ci.version_fixed_patch, 0) > COALESCE(kc.patch, 0) + ) + ) + GROUP BY + cve, + config_name + ORDER BY + version_fixed_major ASC, + version_fixed_minor ASC, + version_fixed_patch ASC + ) + SELECT + cve, + title, + version_introduced_major, + version_introduced_minor, + version_introduced_patch, + version_fixed_major, + version_fixed_minor, + version_fixed_patch, + fixed_commit_prefix, + ci.patch as patch, + config_name, + kernel_major, + kernel_minor, + kernel_patch, + severity, + attack_vector, + attack_complexity, + privileges_required, + user_interaction, + scope, + confidentiality_impact, + integrity_impact, + availability_impact, + description, + ciaf.file_path AS affected_file + FROM + all_relevant_cve_instances ci + INNER JOIN cve ON ci.cve = cve.name + INNER JOIN cve_instance_affected_file ciaf ON ciaf.cve_instance = ci.title + ORDER BY + severity DESC, + title DESC, + cve DESC, + config_name ASC + " + } else { + " + WITH + filtered_kernel_config AS ( + SELECT + config_name, major, minor, patch + FROM + kernel_config + WHERE + config_name IN rarray(?1) + ), + all_relevant_cve_instances AS ( + SELECT + cve, + title, + version_introduced_major, + version_introduced_minor, + version_introduced_patch, + version_fixed_major, + version_fixed_minor, + version_fixed_patch, + fixed_commit_prefix, + ci.patch as patch, + config_name, + kc.major as kernel_major, + kc.minor as kernel_minor, + kc.patch as kernel_patch + FROM + cve_instance ci + INNER JOIN filtered_kernel_config kc ON ( + ( + kc.major > ci.version_introduced_major + OR ( + kc.major = ci.version_introduced_major + AND kc.minor > ci.version_introduced_minor + ) + OR ( + kc.major = ci.version_introduced_major + AND kc.minor = ci.version_introduced_minor + AND COALESCE(kc.patch, 0) >= COALESCE(ci.version_introduced_patch, 0) + ) + ) + AND ( + ci.version_fixed_major = kc.major + AND ci.version_fixed_minor = kc.minor + AND COALESCE(ci.version_fixed_patch, 0) > COALESCE(kc.patch, 0) + ) + ) + GROUP BY + cve, + config_name + ORDER BY + version_fixed_major ASC, + version_fixed_minor ASC, + version_fixed_patch ASC + ) + SELECT + cve, + title, + version_introduced_major, + version_introduced_minor, + version_introduced_patch, + version_fixed_major, + version_fixed_minor, + version_fixed_patch, + fixed_commit_prefix, + ci.patch as patch, + config_name, + kernel_major, + kernel_minor, + kernel_patch, + severity, + attack_vector, + attack_complexity, + privileges_required, + user_interaction, + scope, + confidentiality_impact, + integrity_impact, + availability_impact, + description, + ciaf.file_path AS affected_file + FROM + all_relevant_cve_instances ci + INNER JOIN cve ON ci.cve = cve.name + INNER JOIN cve_instance_affected_file ciaf ON ciaf.cve_instance = ci.title + ORDER BY + severity DESC, + title DESC, + cve DESC, + config_name ASC + " + }; + + let mut cves: Vec = Vec::new(); + let mut stmt = db.prepare(cve_instances_query)?; + let mut rows: rusqlite::Rows<'_> = stmt.query(params)?; + while let Ok(Some(row)) = rows.next() { + let row = FetchCvesRow::try_from(row)?; + + if let Some(last_cve) = cves.last_mut() { + if last_cve.id == row.cve_id { + // This row contains more information about the Cve from the last row. + // Check if we're on the same CveInstance. + let last_cve_instance = if let Some(last_cve_instance) = last_cve.instances.last() { + last_cve_instance + } else { + continue; + }; + + if last_cve_instance.title == row.instance_title { + // We are on the same CveInstance as the last row, check if we're on the same affected_config. + let last_affected_config = if let Some(last_affected_config) = + last_cve_instance.affected_configs.last() + { + last_affected_config + } else { + continue; + }; + + if last_affected_config.config_name == row.config_name { + // We are on the same affected_config. Push new affected file. + let affected_file = if let Some(affected_file) = row.affected_file { + affected_file + } else { + continue; + }; + + if let Some(last_cve_instance) = last_cve.instances.last_mut() { + last_cve_instance.affected_files.push(affected_file); + } + } else { + // We have a new affected_config. Push it. + if let Some(last_cve_instance) = last_cve.instances.last_mut() { + last_cve_instance + .affected_configs + .push(KernelConfigMetadata::from(&row)); + } + } + } else { + // We are on a new CveInstance but the same Cve. Create it, push it. + let cve_instance = match CveInstance::try_from(&row) { + Ok(cve_instance) => cve_instance, + Err(e) => { + println!("Bad CveInstance stored in database: {:?}", e); + continue; + } + }; + last_cve.instances.push(cve_instance); + } + } else { + // This row contains a new CVE. Create a new one, push it. + let new_cve = match Cve::try_from(&row) { + Ok(new_cve) => new_cve, + Err(e) => { + println!("Bad Cve stored in database: {:?}", e); + continue; + } + }; + cves.push(new_cve); + } + } else { + // This is our first Cve. Push it. + let new_cve = match Cve::try_from(&row) { + Ok(new_cve) => new_cve, + Err(e) => { + println!("Bad Cve stored in database: {:?}", e); + continue; + } + }; + cves.push(new_cve); + } + } + + Ok(cves) +} + +struct FetchCvesRow { + cve_id: String, + instance_title: String, + version_introduced_major: u32, + version_introduced_minor: u32, + version_introduced_patch: Option, + version_fixed_major: Option, + version_fixed_minor: Option, + version_fixed_patch: Option, + fixed_commit_prefix: Option, + patch: Option, + config_name: String, + kernel_config_major: u32, + kernel_config_minor: u32, + kernel_config_patch: Option, + severity: Option, + attack_vector: Option, + attack_complexity: Option, + privileges_required: Option, + user_interaction: Option, + scope: Option, + confidentiality_impact: Option, + integrity_impact: Option, + availability_impact: Option, + description: Option, + affected_file: Option, +} + +impl<'stmt> TryFrom<&Row<'stmt>> for FetchCvesRow { + type Error = rusqlite::Error; + + fn try_from(row: &Row<'stmt>) -> Result { + let cve_id: String = row.get(0)?; + let instance_title: String = row.get(1)?; + let version_introduced_major: u32 = row.get(2)?; + let version_introduced_minor: u32 = row.get(3)?; + let version_introduced_patch: Option = row.get(4)?; + let version_fixed_major: Option = row.get(5)?; + let version_fixed_minor: Option = row.get(6)?; + let version_fixed_patch: Option = row.get(7)?; + let fixed_commit_prefix: Option = row.get(8)?; + let patch: Option = row.get(9)?; + let config_name: String = row.get(10)?; + let kernel_config_major: u32 = row.get(11)?; + let kernel_config_minor: u32 = row.get(12)?; + let kernel_config_patch: Option = row.get(13)?; + let severity: Option = row.get(14)?; + let attack_vector: Option = row.get(15)?; + let attack_complexity: Option = row.get(16)?; + let privileges_required: Option = row.get(17)?; + let user_interaction: Option = row.get(18)?; + let scope: Option = row.get(19)?; + let confidentiality_impact: Option = row.get(20)?; + let integrity_impact: Option = row.get(21)?; + let availability_impact: Option = row.get(22)?; + let description: Option = row.get(23)?; + let affected_file: Option = row.get(24)?; + + Ok(Self { + cve_id, + instance_title, + version_introduced_major, + version_introduced_minor, + version_introduced_patch, + version_fixed_major, + version_fixed_minor, + version_fixed_patch, + fixed_commit_prefix, + patch, + config_name, + kernel_config_major, + kernel_config_minor, + kernel_config_patch, + severity, + attack_vector, + attack_complexity, + privileges_required, + user_interaction, + scope, + confidentiality_impact, + integrity_impact, + availability_impact, + description, + affected_file, + }) + } +} + +impl TryFrom<&FetchCvesRow> for Cve { + type Error = color_eyre::Report; + + fn try_from(row: &FetchCvesRow) -> Result { + let instances = match CveInstance::try_from(row) { + Ok(cve_instance) => vec![cve_instance], + Err(e) => return Err(e), + }; + + Ok(Self { + id: row.cve_id.clone(), + severity: row.severity, + attack_vector: row.attack_vector.clone(), + attack_complexity: row.attack_complexity.clone(), + privileges_required: row.privileges_required.clone(), + user_interaction: row.user_interaction.clone(), + scope: row.scope.clone(), + confidentiality_impact: row.confidentiality_impact.clone(), + integrity_impact: row.integrity_impact.clone(), + availability_impact: row.availability_impact.clone(), + description: row.description.clone(), + instances, + }) + } +} + +impl TryFrom<&FetchCvesRow> for CveInstance { + type Error = color_eyre::Report; + + fn try_from(row: &FetchCvesRow) -> Result { + let introduced = Some(KernelVersion { + major: row.version_introduced_major, + minor: row.version_introduced_minor, + patch: row.version_introduced_patch, + }); + + let fixed = if let Some(version_fixed_major) = row.version_fixed_major { + let version_fixed_minor = if let Some(version_fixed_minor) = row.version_fixed_minor { + version_fixed_minor + } else { + return Err(eyre!("row missing version_introduced_minor")); + }; + + Some(KernelVersion { + major: version_fixed_major, + minor: version_fixed_minor, + patch: row.version_fixed_patch, + }) + } else { + None + }; + + let affected_files = if let Some(affected_file) = row.affected_file.clone() { + vec![affected_file] + } else { + vec![] + }; + + Ok(Self { + title: row.instance_title.clone(), + introduced, + fixed, + fixed_commit_prefix: row.fixed_commit_prefix.clone(), + affected_files, + affected_configs: vec![KernelConfigMetadata::from(row)], + raw_patch: row.patch.clone(), + // TODO (MVP) - update once deployable_patch is implemented + deployable_patch: None, + }) + } +} + +impl From<&FetchCvesRow> for KernelConfigMetadata { + fn from(row: &FetchCvesRow) -> Self { + let kernel_version = Some(KernelVersion { + major: row.kernel_config_major, + minor: row.kernel_config_minor, + patch: row.kernel_config_patch, + }); + + Self { + config_name: row.config_name.clone(), + kernel_version, + } + } +}