diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e6c8962 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +# Ignore local build artifacts +target/ + +# Ignore git directory +.git/ + +# Ignore IDE files +.idea/ +.vscode/ + +# Ignore test artifacts +*.log +coverage/ + +# Ignore Docker build context waste +.dockerignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c6e1a20..220f7e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,39 +1,77 @@ -name: Rust CI +name: CI on: push: - branches: - - main + branches: [main] pull_request: - types: - - opened - - synchronize - - reopened + branches: [main] -jobs: - build-and-test: - name: Build and Test on ${{ matrix.os }} - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: [ubuntu-latest, macos-latest] +env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: 0 +jobs: + build: + name: Build + runs-on: ubuntu-latest steps: - - name: Checkout Code - uses: actions/checkout@v4 + - uses: actions/checkout@v4 with: submodules: recursive fetch-depth: 1 - - name: Set up Rust Toolchain - uses: actions-rust-lang/setup-rust-toolchain@v1 + - uses: actions-rust-lang/setup-rust-toolchain@v1 with: toolchain: stable - override: true components: rustfmt, clippy - - name: Build Project - run: cargo build --release --verbose + - name: Check formatting + run: cargo fmt --all -- --check + + - name: Run clippy + run: cargo clippy --all-features -- -D warnings + + - name: Build + run: cargo build --all-features + + test: + name: Test & Coverage + runs-on: ubuntu-latest + needs: build + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: stable + components: llvm-tools-preview + + - uses: taiki-e/install-action@cargo-llvm-cov - - name: Run Tests - run: cargo test --all-features --verbose + - name: Install osquery + run: | + wget -q https://github.com/osquery/osquery/releases/download/5.20.0/osquery_5.20.0-1.linux_amd64.deb + sudo dpkg -i osquery_5.20.0-1.linux_amd64.deb + osqueryi --version + + - name: Build workspace + run: cargo build --workspace + + - name: Run coverage with osquery + id: coverage + run: ./scripts/ci-test.sh --coverage + + - name: Update coverage badge + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: schneegans/dynamic-badges-action@v1.7.0 + with: + auth: ${{ secrets.GIST_TOKEN }} + gistID: 36626ec8e61a6ccda380befc41f2cae1 + filename: coverage.json + label: coverage + message: ${{ steps.coverage.outputs.coverage }}% + valColorRange: ${{ steps.coverage.outputs.coverage }} + maxColorRange: 100 + minColorRange: 0 diff --git a/.gitignore b/.gitignore index 4eec1b3..9c1b245 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,6 @@ osquery-rust.iml /examples/*/target -/Cargo.lock /target /thrift/Cargo.lock diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..c6ed973 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1134 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[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", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" +dependencies = [ + "anstyle", + "once_cell", + "windows-sys", +] + +[[package]] +name = "autocfg" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + +[[package]] +name = "bitflags" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cc" +version = "1.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d487aa071b5f64da6f19a3e848e3578944b726ee5a4854b82172f02aa876bfdc" +dependencies = [ + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" + +[[package]] +name = "chrono" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40b6887a1d8685cebccf115538db5c0efe625ccac9696ad45c409d96566e910f" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c66c08ce9f0c698cbce5c0279d0bb6ac936d8674174fe48f736533b964f59e" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2c7947ae4cc3d851207c1adb5b5e260ff0cca11446b1d6d1423788e442257ce" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" + +[[package]] +name = "colorchoice" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" + +[[package]] +name = "config-file" +version = "0.1.0" +dependencies = [ + "clap", + "env_logger", + "log", + "osquery-rust-ng", + "serde_json", + "tempfile", +] + +[[package]] +name = "config-static" +version = "0.1.0" +dependencies = [ + "clap", + "env_logger", + "log", + "osquery-rust-ng", + "serde_json", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "deranged" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "downcast" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" + +[[package]] +name = "enum_dispatch" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" +dependencies = [ + "once_cell", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "env_filter" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "error-chain" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2f06b9cac1506ece98fe3231e3cc9c4410ec3d5b1f24ae1c8946f0742cdefc" +dependencies = [ + "version_check", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fragile" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619" + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[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 = "hostname" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c731c3e10504cc8ed35cfe2f1db4c9274c3d35fa486e3b31df46f068ef3e867" +dependencies = [ + "libc", + "match_cfg", + "winapi", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "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 = "integer-encoding" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bb03732005da905c88227371639bf1ad885cc712789c011c31c5fb3ab3ccf02" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "jiff" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a064218214dc6a10fbae5ec5fa888d80c45d611aba169222fc272072bf7aef6" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde", +] + +[[package]] +name = "jiff-static" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "199b7932d97e325aff3a7030e141eafe7f2c6268e1d1b24859b753a627f45254" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "js-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.178" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "log" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "logger-file" +version = "0.1.0" +dependencies = [ + "chrono", + "clap", + "env_logger", + "log", + "osquery-rust-ng", + "tempfile", +] + +[[package]] +name = "logger-syslog" +version = "0.1.0" +dependencies = [ + "clap", + "env_logger", + "log", + "osquery-rust-ng", + "syslog", +] + +[[package]] +name = "match_cfg" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffbee8634e0d45d258acb448e7eaab3fce7a0a467395d4d9f228e3c1f01fb2e4" + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "mockall" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39a6bfcc6c8c7eed5ee98b9c3e33adc726054389233e201c95dab2d41a3839d2" +dependencies = [ + "cfg-if", + "downcast", + "fragile", + "mockall_derive", + "predicates", + "predicates-tree", +] + +[[package]] +name = "mockall_derive" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ca3004c2efe9011bd4e461bd8256445052b9615405b4f7ea43fc8ca5c20898" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn", +] + +[[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_cpus" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "osquery-rust-ng" +version = "2.0.0" +dependencies = [ + "bitflags", + "clap", + "enum_dispatch", + "log", + "mockall", + "serde_json", + "signal-hook", + "strum", + "strum_macros", + "tempfile", + "thrift", +] + +[[package]] +name = "portable-atomic" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e" + +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "predicates" +version = "3.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573" +dependencies = [ + "anstyle", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa" + +[[package]] +name = "predicates-tree" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c" +dependencies = [ + "predicates-core", + "termtree", +] + +[[package]] +name = "proc-macro2" +version = "1.0.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[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", + "regex-syntax", +] + +[[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", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.140" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[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.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad" +dependencies = [ + "libc", +] + +[[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" + +[[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", +] + +[[package]] +name = "syn" +version = "2.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syslog" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfc7e95b5b795122fafe6519e27629b5ab4232c73ebb2428f568e82b1a457ad3" +dependencies = [ + "error-chain", + "hostname", + "libc", + "log", + "time", +] + +[[package]] +name = "table-proc-meminfo" +version = "0.1.0" +dependencies = [ + "clap", + "env_logger", + "log", + "osquery-rust-ng", + "regex", +] + +[[package]] +name = "tempfile" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + +[[package]] +name = "threadpool" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa" +dependencies = [ + "num_cpus", +] + +[[package]] +name = "thrift" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e54bc85fc7faa8bc175c4bab5b92ba8d9a3ce893d0e9f42cc455c8ab16a9e09" +dependencies = [ + "byteorder", + "integer-encoding", + "log", + "ordered-float", + "threadpool", +] + +[[package]] +name = "time" +version = "0.3.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +dependencies = [ + "deranged", + "itoa", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" + +[[package]] +name = "time-macros" +version = "0.2.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "two-tables" +version = "0.1.0" +dependencies = [ + "clap", + "env_logger", + "log", + "osquery-rust-ng", + "serde_json", +] + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "writeable-table" +version = "0.1.0" +dependencies = [ + "clap", + "env_logger", + "log", + "osquery-rust-ng", + "serde_json", +] diff --git a/README.md b/README.md index 4b8719e..14525d7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ [![Crate][crate-image]][crate-link] [![Docs][docs-image]][docs-link] [![Status][test-action-image]][test-action-link] +[![Coverage][coverage-image]][coverage-link] [![Apache 2.0 Licensed][license-apache-image]][license-apache-link] [![MIT Licensed][license-mit-image]][license-mit-link] @@ -350,9 +351,13 @@ This project was initially forked from [polarlab's osquery-rust project](https:/ [docs-link]: https://docs.rs/osquery-rust-ng/ -[test-action-image]: https://github.com/withzombies/osquery-rust/workflows/Rust%20CI/badge.svg +[test-action-image]: https://github.com/withzombies/osquery-rust/workflows/CI/badge.svg -[test-action-link]: https://github.com/withzombies/osquery-rust/actions?query=workflow:Rust%20CI +[test-action-link]: https://github.com/withzombies/osquery-rust/actions?query=workflow:CI + +[coverage-image]: https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/withzombies/36626ec8e61a6ccda380befc41f2cae1/raw/coverage.json + +[coverage-link]: https://github.com/withzombies/osquery-rust/actions/workflows/ci.yml [license-apache-image]: https://img.shields.io/badge/license-Apache2.0-blue.svg diff --git a/docker/Dockerfile.test b/docker/Dockerfile.test new file mode 100644 index 0000000..0064830 --- /dev/null +++ b/docker/Dockerfile.test @@ -0,0 +1,91 @@ +# Dockerfile.test - Multi-stage build for osquery extensions testing +# +# This Dockerfile builds Rust osquery extensions and runs them alongside +# osquery inside the container. This enables testcontainers-based integration +# tests that work on all platforms (macOS, Linux, CI). +# +# The image includes Rust toolchain to support running `cargo test` inside +# the container (required for tests that need Unix socket access to osquery). +# +# Usage: +# docker build -t osquery-rust-test:latest -f docker/Dockerfile.test . +# docker run --rm osquery-rust-test:latest osqueryi "SELECT 1;" +# +# Running cargo test inside container: +# docker run --rm -v $(pwd):/workspace -w /workspace osquery-rust-test:latest \ +# sh -c 'osqueryd --ephemeral ... & sleep 5 && cargo test --test integration_test' + +# Stage 1: Build extensions using Rust +# Using rust:latest (1.85+) for edition 2024 support +FROM rust:latest AS builder + +# Install build dependencies +RUN apt-get update && apt-get install -y \ + pkg-config \ + libssl-dev \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /build + +# Copy entire workspace (BuildKit caching handles efficiency) +COPY Cargo.toml Cargo.lock ./ +COPY osquery-rust osquery-rust +COPY examples examples + +# Build all example extensions in release mode +RUN cargo build --release -p two-tables -p writeable-table -p config-static -p logger-file + +# Stage 2: Runtime with osquery AND Rust toolchain +# Start from rust:latest to keep toolchain, then add osquery +FROM rust:latest + +# Install cargo-llvm-cov for coverage measurement +RUN rustup component add llvm-tools-preview && \ + cargo install cargo-llvm-cov + +# Install osquery from GitHub releases (supports both amd64 and arm64) +ARG OSQUERY_VERSION=5.20.0 +ARG TARGETARCH + +RUN apt-get update && apt-get install -y curl ca-certificates bc && \ + # Map Docker arch to osquery arch naming + OSQUERY_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "aarch64" || echo "x86_64") && \ + curl -L "https://github.com/osquery/osquery/releases/download/${OSQUERY_VERSION}/osquery-${OSQUERY_VERSION}_1.linux_${OSQUERY_ARCH}.tar.gz" \ + -o /tmp/osquery.tar.gz && \ + tar xzf /tmp/osquery.tar.gz -C / && \ + rm /tmp/osquery.tar.gz && \ + apt-get remove -y curl && apt-get autoremove -y && \ + rm -rf /var/lib/apt/lists/* + +# osquery binaries are now at /usr/bin/osqueryi, /opt/osquery/bin/osqueryd + +# Copy built extensions from builder (with .ext suffix for osquery autoload) +COPY --from=builder /build/target/release/two-tables /opt/osquery/extensions/two-tables.ext +COPY --from=builder /build/target/release/writeable-table /opt/osquery/extensions/writeable-table.ext +COPY --from=builder /build/target/release/config_static /opt/osquery/extensions/config-static.ext +COPY --from=builder /build/target/release/logger-file /opt/osquery/extensions/logger-file.ext + +# Make extensions executable +RUN chmod +x /opt/osquery/extensions/* + +# Create directories +RUN mkdir -p /etc/osquery /var/osquery /workspace + +# Create autoload configuration with ALL extensions +# Each extension registers under its own name in osquery_extensions table +RUN printf '%s\n' \ + "/opt/osquery/extensions/two-tables.ext" \ + "/opt/osquery/extensions/logger-file.ext" \ + "/opt/osquery/extensions/config-static.ext" \ + "/opt/osquery/extensions/writeable-table.ext" \ + > /etc/osquery/extensions.load + +# Set working directory for cargo test +WORKDIR /workspace + +# Default command: start osqueryd with extensions enabled +CMD ["osqueryd", "--ephemeral", "--disable_extensions=false", \ + "--extensions_socket=/var/osquery/osquery.em", \ + "--extensions_autoload=/etc/osquery/extensions.load", \ + "--database_path=/tmp/osquery.db", \ + "--disable_watchdog", "--force", "--verbose"] diff --git a/examples/config-file/Cargo.toml b/examples/config-file/Cargo.toml index a400602..b1c02d6 100644 --- a/examples/config-file/Cargo.toml +++ b/examples/config-file/Cargo.toml @@ -15,4 +15,7 @@ osquery-rust-ng = { path = "../../osquery-rust" } clap = { version = "^4.5.40", features = ["derive"] } env_logger = "^0.11" log = "^0.4.27" -serde_json = "^1.0.140" \ No newline at end of file +serde_json = "^1.0.140" + +[dev-dependencies] +tempfile = "^3.15" \ No newline at end of file diff --git a/examples/config-file/src/main.rs b/examples/config-file/src/main.rs index b6c328b..f452180 100644 --- a/examples/config-file/src/main.rs +++ b/examples/config-file/src/main.rs @@ -99,3 +99,144 @@ fn main() -> Result<(), Box> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::{NamedTempFile, TempDir}; + + #[test] + fn test_name() { + let plugin = FileConfigPlugin::new("/tmp/config.json".into(), "/tmp/packs".into()); + assert_eq!(plugin.name(), "file_config"); + } + + #[test] + fn test_gen_config_reads_valid_json_file() { + // Create a temp config file with valid JSON + let mut config_file = NamedTempFile::new().expect("create temp file"); + writeln!( + config_file, + r#"{{"options": {{"host_identifier": "test"}}}}"# + ) + .expect("write config"); + + let plugin = FileConfigPlugin::new( + config_file.path().to_string_lossy().into_owned(), + "/tmp/packs".into(), + ); + + let result = plugin.gen_config(); + assert!(result.is_ok(), "gen_config should succeed: {:?}", result); + + let config_map = result.expect("should have config"); + assert!(config_map.contains_key("main")); + + // Verify content + let main_config = config_map.get("main").expect("should have main"); + assert!(main_config.contains("host_identifier")); + } + + #[test] + fn test_gen_config_fails_on_missing_file() { + let plugin = + FileConfigPlugin::new("/nonexistent/path/config.json".into(), "/tmp/packs".into()); + + let result = plugin.gen_config(); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Failed to read")); + } + + #[test] + fn test_gen_config_fails_on_invalid_json() { + // Create a temp config file with invalid JSON + let mut config_file = NamedTempFile::new().expect("create temp file"); + writeln!(config_file, "not valid json {{{{").expect("write config"); + + let plugin = FileConfigPlugin::new( + config_file.path().to_string_lossy().into_owned(), + "/tmp/packs".into(), + ); + + let result = plugin.gen_config(); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Invalid JSON")); + } + + #[test] + fn test_gen_pack_reads_valid_pack_file() { + // Create a temp packs directory with a pack file + let packs_dir = TempDir::new().expect("create temp dir"); + let pack_path = packs_dir.path().join("test_pack.json"); + fs::write( + &pack_path, + r#"{"queries": {"test": {"query": "SELECT 1;"}}}"#, + ) + .expect("write pack"); + + let plugin = FileConfigPlugin::new( + "/tmp/config.json".into(), + packs_dir.path().to_string_lossy().into_owned(), + ); + + let result = plugin.gen_pack("test_pack", ""); + assert!(result.is_ok(), "gen_pack should succeed: {:?}", result); + + let content = result.expect("should have content"); + assert!(content.contains("queries")); + } + + #[test] + fn test_gen_pack_fails_on_missing_pack() { + let plugin = FileConfigPlugin::new("/tmp/config.json".into(), "/tmp/packs".into()); + + let result = plugin.gen_pack("nonexistent", ""); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Failed to read pack")); + } + + #[test] + fn test_gen_pack_rejects_path_traversal_dotdot() { + let plugin = FileConfigPlugin::new("/tmp/config.json".into(), "/tmp/packs".into()); + + let result = plugin.gen_pack("../../../etc/passwd", ""); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Invalid pack name")); + } + + #[test] + fn test_gen_pack_rejects_path_traversal_slash() { + let plugin = FileConfigPlugin::new("/tmp/config.json".into(), "/tmp/packs".into()); + + let result = plugin.gen_pack("/etc/passwd", ""); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Invalid pack name")); + } + + #[test] + fn test_gen_pack_rejects_path_traversal_backslash() { + let plugin = FileConfigPlugin::new("/tmp/config.json".into(), "/tmp/packs".into()); + + let result = plugin.gen_pack("..\\..\\etc\\passwd", ""); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Invalid pack name")); + } + + #[test] + fn test_gen_pack_fails_on_invalid_json() { + // Create a temp packs directory with invalid JSON + let packs_dir = TempDir::new().expect("create temp dir"); + let pack_path = packs_dir.path().join("bad_pack.json"); + fs::write(&pack_path, "not valid json").expect("write pack"); + + let plugin = FileConfigPlugin::new( + "/tmp/config.json".into(), + packs_dir.path().to_string_lossy().into_owned(), + ); + + let result = plugin.gen_pack("bad_pack", ""); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Invalid JSON")); + } +} diff --git a/examples/config-static/Cargo.toml b/examples/config-static/Cargo.toml index d0f5109..84c9f1d 100644 --- a/examples/config-static/Cargo.toml +++ b/examples/config-static/Cargo.toml @@ -14,4 +14,7 @@ path = "src/main.rs" osquery-rust-ng = { path = "../../osquery-rust" } clap = { version = "^4.5.40", features = ["derive"] } env_logger = "^0.11" -log = "^0.4.27" \ No newline at end of file +log = "^0.4.27" + +[dev-dependencies] +serde_json = "^1.0.140" \ No newline at end of file diff --git a/examples/config-static/src/cli.rs b/examples/config-static/src/cli.rs index 0b3f62d..b958947 100644 --- a/examples/config-static/src/cli.rs +++ b/examples/config-static/src/cli.rs @@ -8,10 +8,13 @@ use clap::Parser; name = "config-static", long_about = "A config plugin that provides a static configuration enabling file events monitoring on /tmp" )] -#[command(arg_required_else_help = true)] pub struct Args { - /// Path to the osquery socket. - #[arg(long, value_name = "PATH_TO_SOCKET")] + /// Path to the osquery socket (can also be set via OSQUERY_SOCKET env var). + #[arg( + long, + env = "OSQUERY_SOCKET", + default_value = "/var/osquery/osquery.em" + )] pub socket: String, /// Delay in seconds between connectivity checks. diff --git a/examples/config-static/src/main.rs b/examples/config-static/src/main.rs index 0742e4d..b798c3e 100644 --- a/examples/config-static/src/main.rs +++ b/examples/config-static/src/main.rs @@ -15,13 +15,21 @@ impl ConfigPlugin for FileEventsConfigPlugin { } fn gen_config(&self) -> Result, String> { + // Write marker file if configured (for testing) + // Silently ignore write errors - test will detect missing marker + if let Ok(marker_path) = std::env::var("TEST_CONFIG_MARKER_FILE") { + let _ = std::fs::write(&marker_path, "Config generated"); + } + let mut config_map = HashMap::new(); // Static configuration that enables file events on /tmp + // Also includes a fast scheduled query for testing log_snapshot functionality + // The canary schedule has a unique name that proves this config was applied let config = r#"{ "options": { "host_identifier": "hostname", - "schedule_splay_percent": 10, + "schedule_splay_percent": 0, "enable_file_events": "true", "disable_events": "false", "events_expiry": "3600", @@ -31,7 +39,18 @@ impl ConfigPlugin for FileEventsConfigPlugin { "file_events": { "query": "SELECT * FROM file_events;", "interval": 10, - "removed": false + "removed": false, + "snapshot": true + }, + "osquery_info_snapshot": { + "query": "SELECT version, build_platform FROM osquery_info;", + "interval": 3, + "snapshot": true + }, + "rust_config_canary_7f3d2a": { + "query": "SELECT 'canary_value_abc123' AS canary;", + "interval": 86400, + "snapshot": true } }, "file_paths": { @@ -69,3 +88,81 @@ fn main() -> Result<(), Box> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_name() { + let plugin = FileEventsConfigPlugin; + assert_eq!(plugin.name(), "static_config"); + } + + #[test] + fn test_gen_config_returns_valid_json() { + let plugin = FileEventsConfigPlugin; + let result = plugin.gen_config(); + + assert!(result.is_ok(), "gen_config should succeed"); + let config_map = result.expect("should have config"); + + // Should have "main" key + assert!(config_map.contains_key("main")); + + // Config should be valid JSON + let main_config = config_map.get("main").expect("should have main"); + let parsed: serde_json::Value = + serde_json::from_str(main_config).expect("should be valid JSON"); + + // Verify expected structure + assert!(parsed.get("options").is_some()); + assert!(parsed.get("schedule").is_some()); + assert!(parsed.get("file_paths").is_some()); + } + + #[test] + fn test_gen_config_has_file_events_enabled() { + let plugin = FileEventsConfigPlugin; + let config_map = plugin.gen_config().expect("should succeed"); + let main_config = config_map.get("main").expect("should have main"); + let parsed: serde_json::Value = + serde_json::from_str(main_config).expect("should be valid JSON"); + + // Check file events are enabled + let enable_file_events = parsed + .get("options") + .and_then(|o| o.get("enable_file_events")) + .and_then(|v| v.as_str()); + assert_eq!(enable_file_events, Some("true")); + } + + #[test] + fn test_gen_config_has_canary_schedule() { + let plugin = FileEventsConfigPlugin; + let config_map = plugin.gen_config().expect("should succeed"); + let main_config = config_map.get("main").expect("should have main"); + let parsed: serde_json::Value = + serde_json::from_str(main_config).expect("should be valid JSON"); + + // Check canary schedule exists with expected query + let canary_query = parsed + .get("schedule") + .and_then(|s| s.get("rust_config_canary_7f3d2a")) + .and_then(|c| c.get("query")) + .and_then(|v| v.as_str()); + assert_eq!( + canary_query, + Some("SELECT 'canary_value_abc123' AS canary;") + ); + } + + #[test] + fn test_gen_pack_returns_error_for_unknown_pack() { + let plugin = FileEventsConfigPlugin; + let result = plugin.gen_pack("nonexistent", ""); + + assert!(result.is_err()); + assert!(result.unwrap_err().contains("not found")); + } +} diff --git a/examples/logger-file/Cargo.toml b/examples/logger-file/Cargo.toml index 6158d86..bab0a61 100644 --- a/examples/logger-file/Cargo.toml +++ b/examples/logger-file/Cargo.toml @@ -12,7 +12,10 @@ path = "src/main.rs" [dependencies] osquery-rust-ng = { path = "../../osquery-rust" } -clap = { version = "^4.5.40", features = ["derive"] } +clap = { version = "^4.5.40", features = ["derive", "env"] } env_logger = "^0.11" log = "^0.4.27" -chrono = "^0.4" \ No newline at end of file +chrono = "^0.4" + +[dev-dependencies] +tempfile = "^3.15" \ No newline at end of file diff --git a/examples/logger-file/src/cli.rs b/examples/logger-file/src/cli.rs index 5ae92be..825b836 100644 --- a/examples/logger-file/src/cli.rs +++ b/examples/logger-file/src/cli.rs @@ -1,13 +1,21 @@ #[derive(clap::Parser, Debug)] #[clap(author, version, about, long_about = None)] -#[clap(arg_required_else_help = true)] pub struct Args { - /// Path to the osquery socket - #[clap(long, value_name = "PATH_TO_SOCKET")] + /// Path to the osquery socket (can also be set via OSQUERY_SOCKET env var) + #[clap( + long, + env = "OSQUERY_SOCKET", + default_value = "/var/osquery/osquery.em" + )] pub socket: String, - /// Path to the log file - #[clap(short, long, default_value = "/tmp/osquery-logger.log")] + /// Path to the log file (can also be set via FILE_LOGGER_PATH env var) + #[clap( + short, + long, + env = "FILE_LOGGER_PATH", + default_value = "/tmp/osquery-logger.log" + )] pub log_file: std::path::PathBuf, /// Delay in seconds between connectivity checks. diff --git a/examples/logger-file/src/main.rs b/examples/logger-file/src/main.rs index d03c0a9..ad67ac4 100644 --- a/examples/logger-file/src/main.rs +++ b/examples/logger-file/src/main.rs @@ -148,7 +148,8 @@ impl LoggerPlugin for FileLoggerPlugin { } fn features(&self) -> i32 { - LoggerFeatures::LOG_STATUS + // Support both status logs and event logs (for scheduled query snapshots) + LoggerFeatures::LOG_STATUS | LoggerFeatures::LOG_EVENT } } @@ -189,3 +190,177 @@ fn main() { } } } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::NamedTempFile; + + #[test] + fn test_name() { + let temp_file = NamedTempFile::new().expect("create temp file"); + let logger = FileLoggerPlugin::new(temp_file.path().to_path_buf()).expect("create logger"); + assert_eq!(logger.name(), "file_logger"); + } + + #[test] + fn test_features_includes_log_status_and_log_event() { + let temp_file = NamedTempFile::new().expect("create temp file"); + let logger = FileLoggerPlugin::new(temp_file.path().to_path_buf()).expect("create logger"); + // Supports both status logs and event logs (for scheduled query snapshots) + assert_eq!( + logger.features(), + LoggerFeatures::LOG_STATUS | LoggerFeatures::LOG_EVENT + ); + } + + #[test] + fn test_log_string_writes_to_file() { + let temp_file = NamedTempFile::new().expect("create temp file"); + let logger = FileLoggerPlugin::new(temp_file.path().to_path_buf()).expect("create logger"); + + let result = logger.log_string("test message"); + assert!(result.is_ok()); + + let contents = fs::read_to_string(temp_file.path()).expect("read file"); + assert!(contents.contains("test message")); + assert!(contents.contains("]")); // Has timestamp brackets + } + + #[test] + fn test_log_status_writes_severity_and_location() { + let temp_file = NamedTempFile::new().expect("create temp file"); + let logger = FileLoggerPlugin::new(temp_file.path().to_path_buf()).expect("create logger"); + + let status = LogStatus { + severity: LogSeverity::Warning, + filename: "test.rs".to_string(), + line: 42, + message: "warning message".to_string(), + }; + + let result = logger.log_status(&status); + assert!(result.is_ok()); + + let contents = fs::read_to_string(temp_file.path()).expect("read file"); + assert!(contents.contains("WARN")); + assert!(contents.contains("test.rs")); + assert!(contents.contains("42")); + assert!(contents.contains("warning message")); + } + + #[test] + fn test_log_status_info_severity() { + let temp_file = NamedTempFile::new().expect("create temp file"); + let logger = FileLoggerPlugin::new(temp_file.path().to_path_buf()).expect("create logger"); + + let status = LogStatus { + severity: LogSeverity::Info, + filename: "info.rs".to_string(), + line: 1, + message: "info message".to_string(), + }; + + logger.log_status(&status).expect("log status"); + + let contents = fs::read_to_string(temp_file.path()).expect("read file"); + assert!(contents.contains("INFO")); + } + + #[test] + fn test_log_status_error_severity() { + let temp_file = NamedTempFile::new().expect("create temp file"); + let logger = FileLoggerPlugin::new(temp_file.path().to_path_buf()).expect("create logger"); + + let status = LogStatus { + severity: LogSeverity::Error, + filename: "error.rs".to_string(), + line: 99, + message: "error message".to_string(), + }; + + logger.log_status(&status).expect("log status"); + + let contents = fs::read_to_string(temp_file.path()).expect("read file"); + assert!(contents.contains("ERROR")); + } + + #[test] + fn test_log_snapshot_writes_snapshot_marker() { + let temp_file = NamedTempFile::new().expect("create temp file"); + let logger = FileLoggerPlugin::new(temp_file.path().to_path_buf()).expect("create logger"); + + let result = logger.log_snapshot(r#"{"data": "snapshot"}"#); + assert!(result.is_ok()); + + let contents = fs::read_to_string(temp_file.path()).expect("read file"); + assert!(contents.contains("[SNAPSHOT]")); + assert!(contents.contains(r#"{"data": "snapshot"}"#)); + } + + #[test] + fn test_init_writes_initialization_message() { + let temp_file = NamedTempFile::new().expect("create temp file"); + let logger = FileLoggerPlugin::new(temp_file.path().to_path_buf()).expect("create logger"); + + let result = logger.init("test_logger"); + assert!(result.is_ok()); + + let contents = fs::read_to_string(temp_file.path()).expect("read file"); + assert!(contents.contains("Logger initialized")); + assert!(contents.contains("test_logger")); + } + + #[test] + fn test_health_writes_health_check() { + let temp_file = NamedTempFile::new().expect("create temp file"); + let logger = FileLoggerPlugin::new(temp_file.path().to_path_buf()).expect("create logger"); + + let result = logger.health(); + assert!(result.is_ok()); + + let contents = fs::read_to_string(temp_file.path()).expect("read file"); + assert!(contents.contains("[HEALTH_CHECK]")); + assert!(contents.contains("OK")); + } + + #[test] + fn test_shutdown_writes_shutdown_message() { + let temp_file = NamedTempFile::new().expect("create temp file"); + let logger = FileLoggerPlugin::new(temp_file.path().to_path_buf()).expect("create logger"); + + logger.shutdown(); + + let contents = fs::read_to_string(temp_file.path()).expect("read file"); + assert!(contents.contains("shutting down")); + } + + #[test] + fn test_multiple_logs_append() { + let temp_file = NamedTempFile::new().expect("create temp file"); + let logger = FileLoggerPlugin::new(temp_file.path().to_path_buf()).expect("create logger"); + + logger.log_string("message 1").expect("log 1"); + logger.log_string("message 2").expect("log 2"); + logger.log_string("message 3").expect("log 3"); + + let contents = fs::read_to_string(temp_file.path()).expect("read file"); + assert!(contents.contains("message 1")); + assert!(contents.contains("message 2")); + assert!(contents.contains("message 3")); + + // Verify order (message 1 appears before message 2) + let pos1 = contents.find("message 1").expect("find message 1"); + let pos2 = contents.find("message 2").expect("find message 2"); + let pos3 = contents.find("message 3").expect("find message 3"); + assert!(pos1 < pos2); + assert!(pos2 < pos3); + } + + #[test] + fn test_new_fails_on_invalid_path() { + let result = FileLoggerPlugin::new(PathBuf::from("/nonexistent/directory/file.log")); + assert!(result.is_err()); + } +} diff --git a/examples/logger-syslog/src/main.rs b/examples/logger-syslog/src/main.rs index bdb389e..16a9a95 100644 --- a/examples/logger-syslog/src/main.rs +++ b/examples/logger-syslog/src/main.rs @@ -200,3 +200,101 @@ fn main() { } } } + +#[cfg(test)] +mod tests { + use super::*; + + // Note: Full syslog integration tests require system syslog daemon. + // These tests cover the facility parsing and plugin structure. + + #[test] + fn test_parse_facility_kern() { + let result = SyslogLoggerPlugin::parse_facility("kern"); + assert!(result.is_ok()); + } + + #[test] + fn test_parse_facility_user() { + let result = SyslogLoggerPlugin::parse_facility("user"); + assert!(result.is_ok()); + } + + #[test] + fn test_parse_facility_daemon() { + let result = SyslogLoggerPlugin::parse_facility("daemon"); + assert!(result.is_ok()); + } + + #[test] + fn test_parse_facility_auth() { + let result = SyslogLoggerPlugin::parse_facility("auth"); + assert!(result.is_ok()); + } + + #[test] + fn test_parse_facility_local0_through_7() { + for i in 0..=7 { + let result = SyslogLoggerPlugin::parse_facility(&format!("local{i}")); + assert!(result.is_ok(), "local{i} should be valid"); + } + } + + #[test] + fn test_parse_facility_case_insensitive() { + assert!(SyslogLoggerPlugin::parse_facility("DAEMON").is_ok()); + assert!(SyslogLoggerPlugin::parse_facility("Daemon").is_ok()); + assert!(SyslogLoggerPlugin::parse_facility("LOCAL0").is_ok()); + } + + #[test] + fn test_parse_facility_invalid() { + let result = SyslogLoggerPlugin::parse_facility("invalid_facility"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Unknown syslog facility")); + } + + #[test] + fn test_parse_facility_all_standard_facilities() { + let facilities = [ + "kern", "user", "mail", "daemon", "auth", "syslog", "lpr", "news", "uucp", "cron", + "authpriv", "ftp", + ]; + + for facility in &facilities { + let result = SyslogLoggerPlugin::parse_facility(facility); + assert!(result.is_ok(), "{facility} should be valid"); + } + } + + // Integration test that requires local syslog (Unix socket) + #[test] + #[cfg(unix)] + fn test_new_with_local_syslog() { + let result = SyslogLoggerPlugin::new(Facility::LOG_USER, None); + + // macOS always has /var/run/syslog + #[cfg(target_os = "macos")] + assert!( + result.is_ok(), + "macOS should have syslog socket at /var/run/syslog: {:?}", + result.err() + ); + + // On Linux/other, syslog availability varies (containers often lack /dev/log) + #[cfg(not(target_os = "macos"))] + match result { + Ok(_) => eprintln!("Syslog available on this system"), + Err(e) => eprintln!("Syslog not available: {} (expected in containers)", e), + } + } + + #[test] + fn test_name() { + // Can only test name if we have a valid logger instance + // Skip if syslog is not available + if let Ok(logger) = SyslogLoggerPlugin::new(Facility::LOG_USER, None) { + assert_eq!(logger.name(), "syslog_logger"); + } + } +} diff --git a/examples/two-tables/src/t1.rs b/examples/two-tables/src/t1.rs index 7b25cda..d680702 100644 --- a/examples/two-tables/src/t1.rs +++ b/examples/two-tables/src/t1.rs @@ -36,3 +36,32 @@ impl ReadOnlyTable for Table1 { info!("Table1 shutting down"); } } + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::indexing_slicing)] +mod tests { + use super::*; + + #[test] + fn test_table1_name() { + let table = Table1::new(); + assert_eq!(table.name(), "t1"); + } + + #[test] + fn test_table1_columns() { + let table = Table1::new(); + let cols = table.columns(); + assert_eq!(cols.len(), 2); + } + + #[test] + fn test_table1_generate() { + let table = Table1::new(); + let response = table.generate(ExtensionPluginRequest::default()); + let rows = response.response.expect("should have rows"); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].get("left"), Some(&"left".to_string())); + assert_eq!(rows[0].get("right"), Some(&"right".to_string())); + } +} diff --git a/examples/writeable-table/src/main.rs b/examples/writeable-table/src/main.rs index e90a48f..de4d833 100644 --- a/examples/writeable-table/src/main.rs +++ b/examples/writeable-table/src/main.rs @@ -156,3 +156,170 @@ fn main() -> std::io::Result<()> { Ok(()) } + +#[cfg(test)] +#[allow( + clippy::expect_used, + clippy::unwrap_used, + clippy::indexing_slicing, + clippy::panic +)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_table_name() { + let table = WriteableTable::new(); + assert_eq!(table.name(), "writeable_table"); + } + + #[test] + fn test_table_columns() { + let table = WriteableTable::new(); + let cols = table.columns(); + assert_eq!(cols.len(), 3); + } + + #[test] + fn test_generate_returns_initial_data() { + let table = WriteableTable::new(); + let response = table.generate(ExtensionPluginRequest::default()); + let rows = response.response.expect("should have rows"); + + // Initial data: foo, bar, baz + assert_eq!(rows.len(), 3); + assert_eq!(rows[0].get("name"), Some(&"foo".to_string())); + assert_eq!(rows[1].get("name"), Some(&"bar".to_string())); + assert_eq!(rows[2].get("name"), Some(&"baz".to_string())); + } + + #[test] + fn test_insert_with_auto_rowid() { + let mut table = WriteableTable::new(); + + // Insert with null rowid (auto-assign) + let row = json!([null, "alice", "smith"]); + let result = table.insert(true, &row); + + let InsertResult::Success(rowid) = result else { + panic!("Expected InsertResult::Success"); + }; + assert_eq!(rowid, 3); // Next after 0, 1, 2 + + // Verify the row was added + let response = table.generate(ExtensionPluginRequest::default()); + let rows = response.response.expect("should have rows"); + assert_eq!(rows.len(), 4); + } + + #[test] + fn test_insert_with_explicit_rowid() { + let mut table = WriteableTable::new(); + + // Insert with explicit rowid + let row = json!([100, "bob", "jones"]); + let result = table.insert(false, &row); + + let InsertResult::Success(rowid) = result else { + panic!("Expected InsertResult::Success"); + }; + assert_eq!(rowid, 100); + } + + #[test] + fn test_insert_invalid_row_returns_constraint() { + let mut table = WriteableTable::new(); + + // Invalid row format + let row = json!(["invalid"]); + let result = table.insert(false, &row); + + assert!(matches!(result, InsertResult::Constraint)); + } + + #[test] + fn test_update_existing_row() { + let mut table = WriteableTable::new(); + + // Update row 0 (foo -> updated) + let row = json!([0, "updated_name", "updated_lastname"]); + let result = table.update(0, &row); + + assert!(matches!(result, UpdateResult::Success)); + + // Verify the update + let response = table.generate(ExtensionPluginRequest::default()); + let rows = response.response.expect("should have rows"); + let row0 = rows + .iter() + .find(|r| r.get("rowid") == Some(&"0".to_string())); + assert_eq!(row0.unwrap().get("name"), Some(&"updated_name".to_string())); + } + + #[test] + fn test_update_invalid_row_returns_error() { + let mut table = WriteableTable::new(); + + // Invalid row (not an array) + let row = json!({"name": "test"}); + let result = table.update(0, &row); + + assert!(matches!(result, UpdateResult::Err(_))); + } + + #[test] + fn test_delete_existing_row() { + let mut table = WriteableTable::new(); + + // Delete row 0 + let result = table.delete(0); + assert!(matches!(result, DeleteResult::Success)); + + // Verify deletion + let response = table.generate(ExtensionPluginRequest::default()); + let rows = response.response.expect("should have rows"); + assert_eq!(rows.len(), 2); // 3 - 1 = 2 + } + + #[test] + fn test_delete_nonexistent_row_returns_error() { + let mut table = WriteableTable::new(); + + // Try to delete non-existent row + let result = table.delete(999); + + assert!(matches!(result, DeleteResult::Err(_))); + } + + #[test] + fn test_full_crud_workflow() { + let mut table = WriteableTable::new(); + + // Create + let row = json!([null, "new_user", "new_lastname"]); + let InsertResult::Success(new_rowid) = table.insert(true, &row) else { + panic!("Insert failed"); + }; + + // Read (verify exists) + let response = table.generate(ExtensionPluginRequest::default()); + let rows = response.response.expect("should have rows"); + assert_eq!(rows.len(), 4); + + // Update + let updated = json!([new_rowid, "modified", "user"]); + assert!(matches!( + table.update(new_rowid, &updated), + UpdateResult::Success + )); + + // Delete + assert!(matches!(table.delete(new_rowid), DeleteResult::Success)); + + // Verify final state + let response = table.generate(ExtensionPluginRequest::default()); + let rows = response.response.expect("should have rows"); + assert_eq!(rows.len(), 3); // Back to original count + } +} diff --git a/hooks/pre-commit b/hooks/pre-commit new file mode 100755 index 0000000..3553128 --- /dev/null +++ b/hooks/pre-commit @@ -0,0 +1,39 @@ +#!/bin/bash + +# Pre-commit hook for osquery-rust +# Runs formatting, linting, and unit tests +# +# Integration tests require osquery to be running and are executed in CI. + +set -e + +# Check formatting +echo "Checking formatting with cargo fmt..." +if ! cargo fmt --all -- --check; then + echo "Error: Code is not formatted. Please run 'cargo fmt --all' before committing." + exit 1 +fi + +# Run Clippy linter +echo "Running cargo clippy..." +if ! cargo clippy --all-targets --all-features -- -D warnings; then + echo "Error: Clippy found warnings or errors. Please fix them before committing." + exit 1 +fi + +# Run unit tests (fast, no external dependencies) +echo "Running unit tests..." +if ! cargo test --all --lib; then + echo "Error: Unit tests failed. Please fix them before committing." + exit 1 +fi + +# Run doc tests +echo "Running doc tests..." +if ! cargo test --doc; then + echo "Error: Doc tests failed. Please fix them before committing." + exit 1 +fi + +echo "All checks passed. Proceeding with commit." +exit 0 diff --git a/osquery-rust/Cargo.toml b/osquery-rust/Cargo.toml index 624d487..70d02c0 100644 --- a/osquery-rust/Cargo.toml +++ b/osquery-rust/Cargo.toml @@ -44,5 +44,10 @@ enum_dispatch = "^0.3.13" serde_json = "^1.0.140" signal-hook = "^0.3" +[features] +default = [] +osquery-tests = [] # Tests requiring running osquery with autoloaded extensions + [dev-dependencies] tempfile = "^3.14" +mockall = "0.13" diff --git a/osquery-rust/src/client.rs b/osquery-rust/src/client.rs index 345d63a..ba9d336 100644 --- a/osquery-rust/src/client.rs +++ b/osquery-rust/src/client.rs @@ -4,14 +4,45 @@ use std::os::unix::net::UnixStream; use std::time::Duration; use thrift::protocol::{TBinaryInputProtocol, TBinaryOutputProtocol}; -pub struct Client { +/// Trait for osquery daemon communication - enables mocking in tests. +/// +/// This trait exposes only the methods that `Server` actually needs to communicate +/// with the osquery daemon. Implementing this trait allows creating mock clients +/// for testing without requiring a real osquery socket connection. +#[cfg_attr(test, mockall::automock)] +pub trait OsqueryClient: Send { + /// Register this extension with the osquery daemon. + fn register_extension( + &mut self, + info: osquery::InternalExtensionInfo, + registry: osquery::ExtensionRegistry, + ) -> thrift::Result; + + /// Deregister this extension from the osquery daemon. + fn deregister_extension( + &mut self, + uuid: osquery::ExtensionRouteUUID, + ) -> thrift::Result; + + /// Ping the osquery daemon to maintain the connection. + fn ping(&mut self) -> thrift::Result; + + /// Execute a SQL query against osquery. + fn query(&mut self, sql: String) -> thrift::Result; + + /// Get column information for a SQL query without executing it. + fn get_query_columns(&mut self, sql: String) -> thrift::Result; +} + +/// Production implementation of [`OsqueryClient`] using Thrift over Unix sockets. +pub struct ThriftClient { client: osquery::ExtensionManagerSyncClient< TBinaryInputProtocol, TBinaryOutputProtocol, >, } -impl Client { +impl ThriftClient { pub fn new(socket_path: &str, _timeout: Duration) -> Result { // todo: error handling, socket could be unable to connect to // todo: use timeout @@ -21,7 +52,7 @@ impl Client { let in_proto = TBinaryInputProtocol::new(socket_tx, true); let out_proto = TBinaryOutputProtocol::new(socket_rx, true); - Ok(Client { + Ok(ThriftClient { client: osquery::ExtensionManagerSyncClient::new(in_proto, out_proto), }) } @@ -30,7 +61,7 @@ impl Client { // // Extension implements _osquery's Thrift API: trait TExtensionManagerSyncClient // -impl osquery::TExtensionManagerSyncClient for Client { +impl osquery::TExtensionManagerSyncClient for ThriftClient { fn extensions(&mut self) -> thrift::Result { self.client.extensions() } @@ -66,7 +97,7 @@ impl osquery::TExtensionManagerSyncClient for Client { // // Extension implements _osquery's Thrift API: super-trait TExtensionSyncClient // -impl osquery::TExtensionSyncClient for Client { +impl osquery::TExtensionSyncClient for ThriftClient { fn ping(&mut self) -> thrift::Result { self.client.ping() } @@ -84,3 +115,40 @@ impl osquery::TExtensionSyncClient for Client { self.client.shutdown() } } + +// +// ThriftClient implements our custom OsqueryClient trait +// +impl OsqueryClient for ThriftClient { + fn register_extension( + &mut self, + info: osquery::InternalExtensionInfo, + registry: osquery::ExtensionRegistry, + ) -> thrift::Result { + osquery::TExtensionManagerSyncClient::register_extension(&mut self.client, info, registry) + } + + fn deregister_extension( + &mut self, + uuid: osquery::ExtensionRouteUUID, + ) -> thrift::Result { + osquery::TExtensionManagerSyncClient::deregister_extension(&mut self.client, uuid) + } + + fn ping(&mut self) -> thrift::Result { + osquery::TExtensionSyncClient::ping(&mut self.client) + } + + fn query(&mut self, sql: String) -> thrift::Result { + osquery::TExtensionManagerSyncClient::query(&mut self.client, sql) + } + + fn get_query_columns(&mut self, sql: String) -> thrift::Result { + osquery::TExtensionManagerSyncClient::get_query_columns(&mut self.client, sql) + } +} + +/// Type alias for backwards compatibility. +/// +/// Existing code using `Client` will continue to work unchanged. +pub type Client = ThriftClient; diff --git a/osquery-rust/src/lib.rs b/osquery-rust/src/lib.rs index a38ce8f..303e506 100644 --- a/osquery-rust/src/lib.rs +++ b/osquery-rust/src/lib.rs @@ -3,11 +3,12 @@ // Restrict access to osquery API to osquery-rust // Users of osquery-rust are not allowed to access osquery API directly pub(crate) mod _osquery; -pub(crate) mod client; +mod client; pub mod plugin; -pub(crate) mod server; +mod server; mod util; +pub use crate::client::{Client, OsqueryClient, ThriftClient}; pub use crate::server::{Server, ServerStopHandle}; // Re-exports diff --git a/osquery-rust/src/plugin/_enums/plugin.rs b/osquery-rust/src/plugin/_enums/plugin.rs index 67d55a7..ddee84b 100644 --- a/osquery-rust/src/plugin/_enums/plugin.rs +++ b/osquery-rust/src/plugin/_enums/plugin.rs @@ -92,3 +92,223 @@ impl OsqueryPlugin for Plugin { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::plugin::logger::LogStatus; + use std::collections::{BTreeMap, HashMap}; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::Arc; + + // Test ConfigPlugin implementation with observable shutdown + struct TestConfigPlugin { + shutdown_called: Arc, + } + + impl TestConfigPlugin { + fn new() -> (Self, Arc) { + let flag = Arc::new(AtomicBool::new(false)); + ( + Self { + shutdown_called: Arc::clone(&flag), + }, + flag, + ) + } + } + + impl ConfigPlugin for TestConfigPlugin { + fn name(&self) -> String { + "test_config".to_string() + } + + fn gen_config(&self) -> Result, String> { + let mut config = HashMap::new(); + config.insert("main".to_string(), r#"{"options":{}}"#.to_string()); + Ok(config) + } + + fn gen_pack(&self, name: &str, _value: &str) -> Result { + if name == "test_pack" { + Ok(r#"{"queries":{}}"#.to_string()) + } else { + Err(format!("Pack '{name}' not found")) + } + } + + fn shutdown(&self) { + self.shutdown_called.store(true, Ordering::SeqCst); + } + } + + // Test LoggerPlugin implementation with observable shutdown + struct TestLoggerPlugin { + shutdown_called: Arc, + } + + impl TestLoggerPlugin { + fn new() -> (Self, Arc) { + let flag = Arc::new(AtomicBool::new(false)); + ( + Self { + shutdown_called: Arc::clone(&flag), + }, + flag, + ) + } + } + + impl LoggerPlugin for TestLoggerPlugin { + fn name(&self) -> String { + "test_logger".to_string() + } + + fn log_string(&self, _message: &str) -> Result<(), String> { + Ok(()) + } + + fn log_status(&self, _statuses: &LogStatus) -> Result<(), String> { + Ok(()) + } + + fn shutdown(&self) { + self.shutdown_called.store(true, Ordering::SeqCst); + } + } + + // ===== Config Plugin Dispatch Tests ===== + + #[test] + fn test_plugin_config_factory() { + let (config, _flag) = TestConfigPlugin::new(); + let plugin = Plugin::config(config); + assert!(matches!(plugin, Plugin::Config(_))); + } + + #[test] + fn test_plugin_config_name() { + let (config, _flag) = TestConfigPlugin::new(); + let plugin = Plugin::config(config); + assert_eq!(plugin.name(), "test_config"); + } + + #[test] + fn test_plugin_config_registry() { + let (config, _flag) = TestConfigPlugin::new(); + let plugin = Plugin::config(config); + assert_eq!(plugin.registry(), Registry::Config); + } + + #[test] + fn test_plugin_config_routes() { + let (config, _flag) = TestConfigPlugin::new(); + let plugin = Plugin::config(config); + let routes = plugin.routes(); + // Config plugins return empty routes + assert!(routes.is_empty()); + } + + #[test] + fn test_plugin_config_ping() { + let (config, _flag) = TestConfigPlugin::new(); + let plugin = Plugin::config(config); + let status = plugin.ping(); + assert_eq!(status.code, Some(0)); + } + + #[test] + fn test_plugin_config_handle_call() { + let (config, _flag) = TestConfigPlugin::new(); + let plugin = Plugin::config(config); + let mut request: BTreeMap = BTreeMap::new(); + request.insert("action".to_string(), "genConfig".to_string()); + + let response = plugin.handle_call(request); + let status = response.status.as_ref(); + assert_eq!(status.and_then(|s| s.code), Some(0)); + } + + #[test] + fn test_plugin_config_shutdown() { + let (config, shutdown_flag) = TestConfigPlugin::new(); + let plugin = Plugin::config(config); + + // Verify shutdown hasn't been called yet + assert!(!shutdown_flag.load(Ordering::SeqCst)); + + // Call shutdown via Plugin dispatch + plugin.shutdown(); + + // Verify shutdown was actually called on the inner plugin + assert!(shutdown_flag.load(Ordering::SeqCst)); + } + + // ===== Logger Plugin Dispatch Tests ===== + + #[test] + fn test_plugin_logger_factory() { + let (logger, _flag) = TestLoggerPlugin::new(); + let plugin = Plugin::logger(logger); + assert!(matches!(plugin, Plugin::Logger(_))); + } + + #[test] + fn test_plugin_logger_name() { + let (logger, _flag) = TestLoggerPlugin::new(); + let plugin = Plugin::logger(logger); + assert_eq!(plugin.name(), "test_logger"); + } + + #[test] + fn test_plugin_logger_registry() { + let (logger, _flag) = TestLoggerPlugin::new(); + let plugin = Plugin::logger(logger); + assert_eq!(plugin.registry(), Registry::Logger); + } + + #[test] + fn test_plugin_logger_routes() { + let (logger, _flag) = TestLoggerPlugin::new(); + let plugin = Plugin::logger(logger); + let routes = plugin.routes(); + // Logger plugins return routes with their log type + // The exact content depends on LoggerPluginWrapper implementation + assert!(routes.len() <= 1); + } + + #[test] + fn test_plugin_logger_ping() { + let (logger, _flag) = TestLoggerPlugin::new(); + let plugin = Plugin::logger(logger); + let status = plugin.ping(); + assert_eq!(status.code, Some(0)); + } + + #[test] + fn test_plugin_logger_handle_call() { + let (logger, _flag) = TestLoggerPlugin::new(); + let plugin = Plugin::logger(logger); + let mut request: BTreeMap = BTreeMap::new(); + request.insert("action".to_string(), "init".to_string()); + + let response = plugin.handle_call(request); + let status = response.status.as_ref(); + assert_eq!(status.and_then(|s| s.code), Some(0)); + } + + #[test] + fn test_plugin_logger_shutdown() { + let (logger, shutdown_flag) = TestLoggerPlugin::new(); + let plugin = Plugin::logger(logger); + + // Verify shutdown hasn't been called yet + assert!(!shutdown_flag.load(Ordering::SeqCst)); + + // Call shutdown via Plugin dispatch + plugin.shutdown(); + + // Verify shutdown was actually called on the inner plugin + assert!(shutdown_flag.load(Ordering::SeqCst)); + } +} diff --git a/osquery-rust/src/plugin/_enums/response.rs b/osquery-rust/src/plugin/_enums/response.rs index c62316c..d7a1be5 100644 --- a/osquery-rust/src/plugin/_enums/response.rs +++ b/osquery-rust/src/plugin/_enums/response.rs @@ -47,3 +47,115 @@ impl From for ExtensionResponse { ExtensionResponse::new(ExtensionStatus::new(code, None, None), vec![resp]) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn get_first_row(resp: &ExtensionResponse) -> Option<&BTreeMap> { + resp.response.as_ref().and_then(|r| r.first()) + } + + #[test] + fn test_success_response() { + let resp: ExtensionResponse = ExtensionResponseEnum::Success().into(); + + // Check status code 0 + let status = resp.status.as_ref(); + assert!(status.is_some()); + assert_eq!(status.and_then(|s| s.code), Some(0)); + + // Check response contains "status": "success" + let row = get_first_row(&resp); + assert!(row.is_some()); + assert_eq!( + row.and_then(|r| r.get("status")).map(|s| s.as_str()), + Some("success") + ); + } + + #[test] + fn test_success_with_id_response() { + let resp: ExtensionResponse = ExtensionResponseEnum::SuccessWithId(42).into(); + + let status = resp.status.as_ref(); + assert_eq!(status.and_then(|s| s.code), Some(0)); + + let row = get_first_row(&resp); + assert!(row.is_some()); + assert_eq!( + row.and_then(|r| r.get("status")).map(|s| s.as_str()), + Some("success") + ); + assert_eq!( + row.and_then(|r| r.get("id")).map(|s| s.as_str()), + Some("42") + ); + } + + #[test] + fn test_success_with_code_response() { + let resp: ExtensionResponse = ExtensionResponseEnum::SuccessWithCode(5).into(); + + // Check status code is the custom code + let status = resp.status.as_ref(); + assert_eq!(status.and_then(|s| s.code), Some(5)); + + let row = get_first_row(&resp); + assert!(row.is_some()); + assert_eq!( + row.and_then(|r| r.get("status")).map(|s| s.as_str()), + Some("success") + ); + } + + #[test] + fn test_failure_response() { + let resp: ExtensionResponse = + ExtensionResponseEnum::Failure("error msg".to_string()).into(); + + let status = resp.status.as_ref(); + assert_eq!(status.and_then(|s| s.code), Some(1)); + + let row = get_first_row(&resp); + assert!(row.is_some()); + assert_eq!( + row.and_then(|r| r.get("status")).map(|s| s.as_str()), + Some("failure") + ); + assert_eq!( + row.and_then(|r| r.get("message")).map(|s| s.as_str()), + Some("error msg") + ); + } + + #[test] + fn test_constraint_response() { + let resp: ExtensionResponse = ExtensionResponseEnum::Constraint().into(); + + let status = resp.status.as_ref(); + assert_eq!(status.and_then(|s| s.code), Some(1)); + + let row = get_first_row(&resp); + assert!(row.is_some()); + assert_eq!( + row.and_then(|r| r.get("status")).map(|s| s.as_str()), + Some("constraint") + ); + } + + #[test] + fn test_readonly_response() { + let resp: ExtensionResponse = ExtensionResponseEnum::Readonly().into(); + + let status = resp.status.as_ref(); + assert_eq!(status.and_then(|s| s.code), Some(1)); + + let row = get_first_row(&resp); + assert!(row.is_some()); + assert_eq!( + row.and_then(|r| r.get("status")).map(|s| s.as_str()), + Some("readonly") + ); + } +} diff --git a/osquery-rust/src/plugin/config/mod.rs b/osquery-rust/src/plugin/config/mod.rs index 96fd9ef..ab4eabd 100644 --- a/osquery-rust/src/plugin/config/mod.rs +++ b/osquery-rust/src/plugin/config/mod.rs @@ -59,7 +59,7 @@ impl OsqueryPlugin for ConfigPluginWrapper { } fn ping(&self) -> ExtensionStatus { - ExtensionStatus::default() + ExtensionStatus::new(0, None, None) } fn handle_call(&self, request: crate::_osquery::ExtensionPluginRequest) -> ExtensionResponse { @@ -79,7 +79,7 @@ impl OsqueryPlugin for ConfigPluginWrapper { } response.push(row); - let status = ExtensionStatus::default(); + let status = ExtensionStatus::new(0, None, None); ExtensionResponse::new(status, response) } Err(e) => ExtensionResponseEnum::Failure(e).into(), @@ -95,7 +95,7 @@ impl OsqueryPlugin for ConfigPluginWrapper { let mut row = BTreeMap::new(); row.insert("pack".to_string(), pack_content); response.push(row); - let status = ExtensionStatus::default(); + let status = ExtensionStatus::new(0, None, None); ExtensionResponse::new(status, response) } Err(e) => ExtensionResponseEnum::Failure(e).into(), @@ -110,3 +110,230 @@ impl OsqueryPlugin for ConfigPluginWrapper { self.plugin.shutdown(); } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::plugin::OsqueryPlugin; + + /// Helper to get first row from ExtensionResponse safely + fn get_first_row(resp: &ExtensionResponse) -> Option<&BTreeMap> { + resp.response.as_ref().and_then(|r| r.first()) + } + + struct TestConfig { + config: HashMap, + packs: HashMap, + fail_config: bool, + } + + impl TestConfig { + fn new() -> Self { + let mut config = HashMap::new(); + config.insert("main".to_string(), r#"{"options":{}}"#.to_string()); + Self { + config, + packs: HashMap::new(), + fail_config: false, + } + } + + fn with_pack(mut self, name: &str, content: &str) -> Self { + self.packs.insert(name.to_string(), content.to_string()); + self + } + + fn failing() -> Self { + Self { + config: HashMap::new(), + packs: HashMap::new(), + fail_config: true, + } + } + + fn empty() -> Self { + Self { + config: HashMap::new(), + packs: HashMap::new(), + fail_config: false, + } + } + } + + impl ConfigPlugin for TestConfig { + fn name(&self) -> String { + "test_config".to_string() + } + + fn gen_config(&self) -> Result, String> { + if self.fail_config { + Err("Config generation failed".to_string()) + } else { + Ok(self.config.clone()) + } + } + + fn gen_pack(&self, name: &str, _value: &str) -> Result { + self.packs + .get(name) + .cloned() + .ok_or_else(|| format!("Pack '{name}' not found")) + } + } + + #[test] + fn test_gen_config_returns_config_map() { + let config = TestConfig::new(); + let wrapper = ConfigPluginWrapper::new(config); + + let mut request: BTreeMap = BTreeMap::new(); + request.insert("action".to_string(), "genConfig".to_string()); + + let response = wrapper.handle_call(request); + + // Verify success status + let status = response.status.as_ref(); + assert!(status.is_some()); + assert_eq!(status.and_then(|s| s.code), Some(0)); + + // Verify response contains config data + let row = get_first_row(&response); + assert!(row.is_some()); + assert!(row.map(|r| r.contains_key("main")).unwrap_or(false)); + } + + #[test] + fn test_gen_config_failure_returns_error() { + let config = TestConfig::failing(); + let wrapper = ConfigPluginWrapper::new(config); + + let mut request: BTreeMap = BTreeMap::new(); + request.insert("action".to_string(), "genConfig".to_string()); + + let response = wrapper.handle_call(request); + + // Verify failure status code 1 + let status = response.status.as_ref(); + assert!(status.is_some()); + assert_eq!(status.and_then(|s| s.code), Some(1)); + + // Verify response contains failure status + let row = get_first_row(&response); + assert!(row.is_some()); + assert_eq!( + row.and_then(|r| r.get("status")).map(|s| s.as_str()), + Some("failure") + ); + } + + #[test] + fn test_gen_config_empty_map_returns_empty_response() { + let config = TestConfig::empty(); + let wrapper = ConfigPluginWrapper::new(config); + + let mut request: BTreeMap = BTreeMap::new(); + request.insert("action".to_string(), "genConfig".to_string()); + + let response = wrapper.handle_call(request); + + // Verify success status + let status = response.status.as_ref(); + assert_eq!(status.and_then(|s| s.code), Some(0)); + + // Response should have one row but it's empty + let empty_vec = vec![]; + let rows = response.response.as_ref().unwrap_or(&empty_vec); + assert_eq!(rows.len(), 1); + let row = get_first_row(&response); + assert!(row.is_some()); + assert!(row.map(|r| r.is_empty()).unwrap_or(false)); + } + + #[test] + fn test_gen_pack_returns_pack_content() { + let config = TestConfig::new().with_pack("security", r#"{"queries":{}}"#); + let wrapper = ConfigPluginWrapper::new(config); + + let mut request: BTreeMap = BTreeMap::new(); + request.insert("action".to_string(), "genPack".to_string()); + request.insert("name".to_string(), "security".to_string()); + + let response = wrapper.handle_call(request); + + let status = response.status.as_ref(); + assert_eq!(status.and_then(|s| s.code), Some(0)); + + let row = get_first_row(&response); + assert!(row.is_some()); + assert!(row.map(|r| r.contains_key("pack")).unwrap_or(false)); + assert_eq!( + row.and_then(|r| r.get("pack")).map(|s| s.as_str()), + Some(r#"{"queries":{}}"#) + ); + } + + #[test] + fn test_gen_pack_not_found_returns_error() { + let config = TestConfig::new(); // No packs + let wrapper = ConfigPluginWrapper::new(config); + + let mut request: BTreeMap = BTreeMap::new(); + request.insert("action".to_string(), "genPack".to_string()); + request.insert("name".to_string(), "nonexistent".to_string()); + + let response = wrapper.handle_call(request); + + let status = response.status.as_ref(); + assert_eq!(status.and_then(|s| s.code), Some(1)); + + let row = get_first_row(&response); + assert!(row.is_some()); + assert_eq!( + row.and_then(|r| r.get("status")).map(|s| s.as_str()), + Some("failure") + ); + } + + #[test] + fn test_unknown_action_returns_error() { + let config = TestConfig::new(); + let wrapper = ConfigPluginWrapper::new(config); + + let mut request: BTreeMap = BTreeMap::new(); + request.insert("action".to_string(), "invalidAction".to_string()); + + let response = wrapper.handle_call(request); + + let status = response.status.as_ref(); + assert_eq!(status.and_then(|s| s.code), Some(1)); + } + + #[test] + fn test_config_plugin_registry() { + let config = TestConfig::new(); + let wrapper = ConfigPluginWrapper::new(config); + assert_eq!(wrapper.registry(), Registry::Config); + } + + #[test] + fn test_config_plugin_routes_empty() { + let config = TestConfig::new(); + let wrapper = ConfigPluginWrapper::new(config); + assert!(wrapper.routes().is_empty()); + } + + #[test] + fn test_config_plugin_name() { + let config = TestConfig::new(); + let wrapper = ConfigPluginWrapper::new(config); + assert_eq!(wrapper.name(), "test_config"); + } + + #[test] + fn test_config_plugin_ping() { + let config = TestConfig::new(); + let wrapper = ConfigPluginWrapper::new(config); + let status = wrapper.ping(); + assert_eq!(status.code, Some(0)); + } +} diff --git a/osquery-rust/src/plugin/logger/mod.rs b/osquery-rust/src/plugin/logger/mod.rs index eb1c518..b5aa0cc 100644 --- a/osquery-rust/src/plugin/logger/mod.rs +++ b/osquery-rust/src/plugin/logger/mod.rs @@ -429,8 +429,8 @@ impl OsqueryPlugin for LoggerPluginWrapper { } fn ping(&self) -> ExtensionStatus { - // Health check - always return OK for now - ExtensionStatus::default() + // Health check - always return OK (status code 0) + ExtensionStatus::new(0, None, None) } fn handle_call(&self, request: crate::_osquery::ExtensionPluginRequest) -> ExtensionResponse { @@ -571,4 +571,154 @@ mod tests { // Should fall through to default (RawString) assert!(matches!(request_type, LogRequestType::RawString(_))); } + + #[test] + fn test_status_log_request_returns_success() { + let logger = TestLogger::new(); + let wrapper = LoggerPluginWrapper::new(logger); + + let mut request: BTreeMap = BTreeMap::new(); + request.insert("status".to_string(), "true".to_string()); + request.insert( + "log".to_string(), + r#"[{"s":1,"f":"test.cpp","i":42,"m":"test message"}]"#.to_string(), + ); + + let response = wrapper.handle_call(request); + + let status = response.status.as_ref(); + assert!(status.is_some()); + assert_eq!(status.and_then(|s| s.code), Some(0)); + } + + #[test] + fn test_status_log_parses_multiple_entries() { + let logger = TestLogger::new(); + let wrapper = LoggerPluginWrapper::new(logger); + + let mut request: BTreeMap = BTreeMap::new(); + request.insert("status".to_string(), "true".to_string()); + request.insert( + "log".to_string(), + r#"[{"s":0,"f":"a.cpp","i":1,"m":"info"},{"s":2,"f":"b.cpp","i":2,"m":"error"}]"# + .to_string(), + ); + + let request_type = wrapper.parse_request(&request); + assert!( + matches!(request_type, LogRequestType::StatusLog(_)), + "Expected StatusLog request type" + ); + if let LogRequestType::StatusLog(entries) = request_type { + assert_eq!(entries.len(), 2); + assert!(entries + .first() + .map(|e| matches!(e.severity, LogSeverity::Info)) + .unwrap_or(false)); + assert!(entries + .get(1) + .map(|e| matches!(e.severity, LogSeverity::Error)) + .unwrap_or(false)); + } + } + + #[test] + fn test_raw_string_request_returns_success() { + let logger = TestLogger::new(); + let wrapper = LoggerPluginWrapper::new(logger); + + let mut request: BTreeMap = BTreeMap::new(); + request.insert("string".to_string(), "test log message".to_string()); + + let response = wrapper.handle_call(request); + + let status = response.status.as_ref(); + assert!(status.is_some()); + assert_eq!(status.and_then(|s| s.code), Some(0)); + } + + #[test] + fn test_snapshot_request_returns_success() { + let logger = TestLogger::new(); + let wrapper = LoggerPluginWrapper::new(logger); + + let mut request: BTreeMap = BTreeMap::new(); + request.insert("snapshot".to_string(), r#"{"data":"snapshot"}"#.to_string()); + + let response = wrapper.handle_call(request); + + let status = response.status.as_ref(); + assert!(status.is_some()); + assert_eq!(status.and_then(|s| s.code), Some(0)); + } + + #[test] + fn test_init_request_returns_success() { + let logger = TestLogger::new(); + let wrapper = LoggerPluginWrapper::new(logger); + + let mut request: BTreeMap = BTreeMap::new(); + request.insert("init".to_string(), "test_logger".to_string()); + + let response = wrapper.handle_call(request); + + let status = response.status.as_ref(); + assert!(status.is_some()); + assert_eq!(status.and_then(|s| s.code), Some(0)); + } + + #[test] + fn test_health_request_returns_success() { + let logger = TestLogger::new(); + let wrapper = LoggerPluginWrapper::new(logger); + + let mut request: BTreeMap = BTreeMap::new(); + request.insert("health".to_string(), "".to_string()); + + let response = wrapper.handle_call(request); + + let status = response.status.as_ref(); + assert!(status.is_some()); + assert_eq!(status.and_then(|s| s.code), Some(0)); + } + + #[test] + fn test_query_result_log_request_returns_success() { + let logger = TestLogger::new(); + let wrapper = LoggerPluginWrapper::new(logger); + + // Query result - valid JSON without status=true + let mut request: BTreeMap = BTreeMap::new(); + request.insert( + "log".to_string(), + r#"{"name":"query1","data":[{"column":"value"}]}"#.to_string(), + ); + + let response = wrapper.handle_call(request); + + let status = response.status.as_ref(); + assert!(status.is_some()); + assert_eq!(status.and_then(|s| s.code), Some(0)); + } + + #[test] + fn test_logger_plugin_registry() { + let logger = TestLogger::new(); + let wrapper = LoggerPluginWrapper::new(logger); + assert_eq!(wrapper.registry(), crate::plugin::Registry::Logger); + } + + #[test] + fn test_logger_plugin_routes_empty() { + let logger = TestLogger::new(); + let wrapper = LoggerPluginWrapper::new(logger); + assert!(wrapper.routes().is_empty()); + } + + #[test] + fn test_logger_plugin_name() { + let logger = TestLogger::new(); + let wrapper = LoggerPluginWrapper::new(logger); + assert_eq!(wrapper.name(), "test_logger"); + } } diff --git a/osquery-rust/src/plugin/mod.rs b/osquery-rust/src/plugin/mod.rs index 960d7e5..3b6721f 100644 --- a/osquery-rust/src/plugin/mod.rs +++ b/osquery-rust/src/plugin/mod.rs @@ -14,7 +14,7 @@ pub use table::column_def::ColumnDef; pub use table::column_def::ColumnOptions; pub use table::column_def::ColumnType; pub use table::query_constraint::QueryConstraints; -pub use table::{DeleteResult, InsertResult, ReadOnlyTable, Table, UpdateResult}; +pub use table::{DeleteResult, InsertResult, ReadOnlyTable, Table, TablePlugin, UpdateResult}; pub use _enums::response::ExtensionResponseEnum; diff --git a/osquery-rust/src/plugin/table/mod.rs b/osquery-rust/src/plugin/table/mod.rs index 63c2a50..2ffa552 100644 --- a/osquery-rust/src/plugin/table/mod.rs +++ b/osquery-rust/src/plugin/table/mod.rs @@ -289,3 +289,501 @@ pub trait ReadOnlyTable: Send + Sync + 'static { fn generate(&self, req: crate::ExtensionPluginRequest) -> crate::ExtensionResponse; fn shutdown(&self); } + +#[cfg(test)] +mod tests { + use super::*; + use crate::_osquery::osquery; + use crate::plugin::OsqueryPlugin; + use column_def::ColumnOptions; + + // ==================== Test Mock: ReadOnlyTable ==================== + + struct TestReadOnlyTable { + test_name: String, + test_columns: Vec, + test_rows: Vec>, + } + + impl TestReadOnlyTable { + fn new(name: &str) -> Self { + Self { + test_name: name.to_string(), + test_columns: vec![ + ColumnDef::new("id", ColumnType::Integer, ColumnOptions::DEFAULT), + ColumnDef::new("value", ColumnType::Text, ColumnOptions::DEFAULT), + ], + test_rows: vec![], + } + } + + fn with_rows(mut self, rows: Vec>) -> Self { + self.test_rows = rows; + self + } + } + + impl ReadOnlyTable for TestReadOnlyTable { + fn name(&self) -> String { + self.test_name.clone() + } + + fn columns(&self) -> Vec { + self.test_columns.clone() + } + + fn generate(&self, _req: ExtensionPluginRequest) -> ExtensionResponse { + ExtensionResponse::new( + osquery::ExtensionStatus { + code: Some(0), + message: Some("OK".to_string()), + uuid: None, + }, + self.test_rows.clone(), + ) + } + + fn shutdown(&self) {} + } + + // ==================== Test Mock: Writeable Table ==================== + + struct TestWriteableTable { + test_name: String, + test_columns: Vec, + data: BTreeMap>, + next_id: u64, + } + + impl TestWriteableTable { + fn new(name: &str) -> Self { + Self { + test_name: name.to_string(), + test_columns: vec![ + ColumnDef::new("id", ColumnType::Integer, ColumnOptions::DEFAULT), + ColumnDef::new("value", ColumnType::Text, ColumnOptions::DEFAULT), + ], + data: BTreeMap::new(), + next_id: 1, + } + } + + fn with_initial_row(mut self) -> Self { + let mut row = BTreeMap::new(); + row.insert("id".to_string(), "1".to_string()); + row.insert("value".to_string(), "initial".to_string()); + self.data.insert(1, row); + self.next_id = 2; + self + } + } + + impl Table for TestWriteableTable { + fn name(&self) -> String { + self.test_name.clone() + } + + fn columns(&self) -> Vec { + self.test_columns.clone() + } + + fn generate(&self, _req: ExtensionPluginRequest) -> ExtensionResponse { + let rows: Vec> = self.data.values().cloned().collect(); + ExtensionResponse::new( + osquery::ExtensionStatus { + code: Some(0), + message: Some("OK".to_string()), + uuid: None, + }, + rows, + ) + } + + fn update(&mut self, rowid: u64, row: &serde_json::Value) -> UpdateResult { + use std::collections::btree_map::Entry; + if let Entry::Occupied(mut entry) = self.data.entry(rowid) { + let mut r = BTreeMap::new(); + r.insert("id".to_string(), rowid.to_string()); + if let Some(val) = row.get(1).and_then(|v| v.as_str()) { + r.insert("value".to_string(), val.to_string()); + } + entry.insert(r); + UpdateResult::Success + } else { + UpdateResult::Err("Row not found".to_string()) + } + } + + fn delete(&mut self, rowid: u64) -> DeleteResult { + if self.data.remove(&rowid).is_some() { + DeleteResult::Success + } else { + DeleteResult::Err("Row not found".to_string()) + } + } + + fn insert(&mut self, auto_rowid: bool, row: &serde_json::Value) -> InsertResult { + let id = if auto_rowid { + self.next_id + } else { + match row.get(0).and_then(|v| v.as_u64()) { + Some(id) => id, + None => self.next_id, + } + }; + let mut r = BTreeMap::new(); + r.insert("id".to_string(), id.to_string()); + if let Some(val) = row.get(1).and_then(|v| v.as_str()) { + r.insert("value".to_string(), val.to_string()); + } + self.data.insert(id, r); + self.next_id = id + 1; + InsertResult::Success(id) + } + + fn shutdown(&self) {} + } + + // ==================== ReadOnlyTable Tests ==================== + + #[test] + fn test_readonly_table_plugin_name() { + let table = TestReadOnlyTable::new("test_table"); + let plugin = TablePlugin::from_readonly_table(table); + assert_eq!(plugin.name(), "test_table"); + } + + #[test] + fn test_readonly_table_plugin_columns() { + let table = TestReadOnlyTable::new("test_table"); + let plugin = TablePlugin::from_readonly_table(table); + let routes = plugin.routes(); + assert_eq!(routes.len(), 2); // id and value columns + assert_eq!( + routes.first().and_then(|r| r.get("name")), + Some(&"id".to_string()) + ); + assert_eq!( + routes.get(1).and_then(|r| r.get("name")), + Some(&"value".to_string()) + ); + } + + #[test] + fn test_readonly_table_plugin_generate() { + let mut row = BTreeMap::new(); + row.insert("id".to_string(), "1".to_string()); + row.insert("value".to_string(), "test".to_string()); + let table = TestReadOnlyTable::new("test_table").with_rows(vec![row]); + let plugin = TablePlugin::from_readonly_table(table); + + let mut req = BTreeMap::new(); + req.insert("action".to_string(), "generate".to_string()); + let response = plugin.handle_call(req); + + let status = response.status.as_ref(); + assert!(status.is_some(), "response should have status"); + assert_eq!(status.and_then(|s| s.code), Some(0)); + assert_eq!(response.response.as_ref().unwrap_or(&vec![]).len(), 1); + } + + #[test] + fn test_readonly_table_routes_via_handle_call() { + let table = TestReadOnlyTable::new("test_table"); + let plugin = TablePlugin::from_readonly_table(table); + + let mut req = BTreeMap::new(); + req.insert("action".to_string(), "columns".to_string()); + let response = plugin.handle_call(req); + + let status = response.status.as_ref(); + assert!(status.is_some(), "response should have status"); + assert_eq!(status.and_then(|s| s.code), Some(0)); + assert_eq!(response.response.as_ref().unwrap_or(&vec![]).len(), 2); // 2 columns + } + + #[test] + fn test_readonly_table_registry() { + let table = TestReadOnlyTable::new("test_table"); + let plugin = TablePlugin::from_readonly_table(table); + assert_eq!(plugin.registry(), Registry::Table); + } + + // ==================== Writeable Table Tests ==================== + + #[test] + fn test_writeable_table_plugin_name() { + let table = TestWriteableTable::new("writeable_table"); + let plugin = TablePlugin::from_writeable_table(table); + assert_eq!(plugin.name(), "writeable_table"); + } + + #[test] + fn test_writeable_table_insert() { + let table = TestWriteableTable::new("test_table"); + let plugin = TablePlugin::from_writeable_table(table); + + let mut req = BTreeMap::new(); + req.insert("action".to_string(), "insert".to_string()); + req.insert("auto_rowid".to_string(), "true".to_string()); + req.insert( + "json_value_array".to_string(), + "[null, \"test_value\"]".to_string(), + ); + let response = plugin.handle_call(req); + + let status = response.status.as_ref(); + assert!(status.is_some(), "response should have status"); + assert_eq!(status.and_then(|s| s.code), Some(0)); // Success + } + + #[test] + fn test_writeable_table_update() { + let table = TestWriteableTable::new("test_table").with_initial_row(); + let plugin = TablePlugin::from_writeable_table(table); + + let mut req = BTreeMap::new(); + req.insert("action".to_string(), "update".to_string()); + req.insert("id".to_string(), "1".to_string()); + req.insert( + "json_value_array".to_string(), + "[1, \"updated\"]".to_string(), + ); + let response = plugin.handle_call(req); + + let status = response.status.as_ref(); + assert!(status.is_some(), "response should have status"); + assert_eq!(status.and_then(|s| s.code), Some(0)); // Success + } + + #[test] + fn test_writeable_table_delete() { + let table = TestWriteableTable::new("test_table").with_initial_row(); + let plugin = TablePlugin::from_writeable_table(table); + + let mut req = BTreeMap::new(); + req.insert("action".to_string(), "delete".to_string()); + req.insert("id".to_string(), "1".to_string()); + let response = plugin.handle_call(req); + + let status = response.status.as_ref(); + assert!(status.is_some(), "response should have status"); + assert_eq!(status.and_then(|s| s.code), Some(0)); // Success + } + + // ==================== Dispatch Tests ==================== + + #[test] + fn test_table_plugin_dispatch_readonly() { + let table = TestReadOnlyTable::new("readonly"); + let plugin = TablePlugin::from_readonly_table(table); + assert!(matches!(plugin, TablePlugin::Readonly(_))); + assert_eq!(plugin.registry(), Registry::Table); + } + + #[test] + fn test_table_plugin_dispatch_writeable() { + let table = TestWriteableTable::new("writeable"); + let plugin = TablePlugin::from_writeable_table(table); + assert!(matches!(plugin, TablePlugin::Writeable(_))); + assert_eq!(plugin.registry(), Registry::Table); + } + + // ==================== Error Path Tests ==================== + + #[test] + fn test_readonly_table_insert_returns_readonly_error() { + let table = TestReadOnlyTable::new("readonly"); + let plugin = TablePlugin::from_readonly_table(table); + + let mut req = BTreeMap::new(); + req.insert("action".to_string(), "insert".to_string()); + req.insert("json_value_array".to_string(), "[1, \"test\"]".to_string()); + let response = plugin.handle_call(req); + + // Readonly error returns code 1 (see ExtensionResponseEnum::Readonly) + let status = response.status.as_ref(); + assert!(status.is_some(), "response should have status"); + assert_eq!(status.and_then(|s| s.code), Some(1)); + } + + #[test] + fn test_readonly_table_update_returns_readonly_error() { + let table = TestReadOnlyTable::new("readonly"); + let plugin = TablePlugin::from_readonly_table(table); + + let mut req = BTreeMap::new(); + req.insert("action".to_string(), "update".to_string()); + req.insert("id".to_string(), "1".to_string()); + req.insert("json_value_array".to_string(), "[1, \"test\"]".to_string()); + let response = plugin.handle_call(req); + + let status = response.status.as_ref(); + assert!(status.is_some(), "response should have status"); + assert_eq!(status.and_then(|s| s.code), Some(1)); // Readonly error + } + + #[test] + fn test_readonly_table_delete_returns_readonly_error() { + let table = TestReadOnlyTable::new("readonly"); + let plugin = TablePlugin::from_readonly_table(table); + + let mut req = BTreeMap::new(); + req.insert("action".to_string(), "delete".to_string()); + req.insert("id".to_string(), "1".to_string()); + let response = plugin.handle_call(req); + + let status = response.status.as_ref(); + assert!(status.is_some(), "response should have status"); + assert_eq!(status.and_then(|s| s.code), Some(1)); // Readonly error + } + + #[test] + fn test_invalid_action_returns_error() { + let table = TestReadOnlyTable::new("test"); + let plugin = TablePlugin::from_readonly_table(table); + + let mut req = BTreeMap::new(); + req.insert("action".to_string(), "invalid_action".to_string()); + let response = plugin.handle_call(req); + + let status = response.status.as_ref(); + assert!(status.is_some(), "response should have status"); + assert_eq!(status.and_then(|s| s.code), Some(1)); // Failure + } + + #[test] + fn test_update_with_invalid_id_returns_error() { + let table = TestWriteableTable::new("test"); + let plugin = TablePlugin::from_writeable_table(table); + + let mut req = BTreeMap::new(); + req.insert("action".to_string(), "update".to_string()); + req.insert("id".to_string(), "not_a_number".to_string()); + req.insert("json_value_array".to_string(), "[1, \"test\"]".to_string()); + let response = plugin.handle_call(req); + + let status = response.status.as_ref(); + assert!(status.is_some(), "response should have status"); + assert_eq!(status.and_then(|s| s.code), Some(1)); // Failure - cannot parse id + } + + #[test] + fn test_update_with_invalid_json_returns_error() { + let table = TestWriteableTable::new("test"); + let plugin = TablePlugin::from_writeable_table(table); + + let mut req = BTreeMap::new(); + req.insert("action".to_string(), "update".to_string()); + req.insert("id".to_string(), "1".to_string()); + req.insert("json_value_array".to_string(), "not valid json".to_string()); + let response = plugin.handle_call(req); + + let status = response.status.as_ref(); + assert!(status.is_some(), "response should have status"); + assert_eq!(status.and_then(|s| s.code), Some(1)); // Failure - invalid JSON + } + + #[test] + fn test_insert_with_missing_json_returns_error() { + let table = TestWriteableTable::new("test"); + let plugin = TablePlugin::from_writeable_table(table); + + let mut req = BTreeMap::new(); + req.insert("action".to_string(), "insert".to_string()); + // Missing json_value_array + let response = plugin.handle_call(req); + + let status = response.status.as_ref(); + assert!(status.is_some(), "response should have status"); + assert_eq!(status.and_then(|s| s.code), Some(1)); // Failure + } + + #[test] + fn test_delete_with_missing_id_returns_error() { + let table = TestWriteableTable::new("test"); + let plugin = TablePlugin::from_writeable_table(table); + + let mut req = BTreeMap::new(); + req.insert("action".to_string(), "delete".to_string()); + // Missing id + let response = plugin.handle_call(req); + + let status = response.status.as_ref(); + assert!(status.is_some(), "response should have status"); + assert_eq!(status.and_then(|s| s.code), Some(1)); // Failure + } + + #[test] + fn test_delete_with_invalid_id_returns_error() { + let table = TestWriteableTable::new("test"); + let plugin = TablePlugin::from_writeable_table(table); + + let mut req = BTreeMap::new(); + req.insert("action".to_string(), "delete".to_string()); + req.insert("id".to_string(), "not_a_number".to_string()); + let response = plugin.handle_call(req); + + let status = response.status.as_ref(); + assert!(status.is_some(), "response should have status"); + assert_eq!(status.and_then(|s| s.code), Some(1)); // Failure - cannot parse id + } + + #[test] + fn test_update_with_missing_id_returns_error() { + let table = TestWriteableTable::new("test"); + let plugin = TablePlugin::from_writeable_table(table); + + let mut req = BTreeMap::new(); + req.insert("action".to_string(), "update".to_string()); + req.insert("json_value_array".to_string(), "[1, \"test\"]".to_string()); + // Missing id + let response = plugin.handle_call(req); + + let status = response.status.as_ref(); + assert!(status.is_some(), "response should have status"); + assert_eq!(status.and_then(|s| s.code), Some(1)); // Failure + } + + #[test] + fn test_update_with_missing_json_returns_error() { + let table = TestWriteableTable::new("test"); + let plugin = TablePlugin::from_writeable_table(table); + + let mut req = BTreeMap::new(); + req.insert("action".to_string(), "update".to_string()); + req.insert("id".to_string(), "1".to_string()); + // Missing json_value_array + let response = plugin.handle_call(req); + + let status = response.status.as_ref(); + assert!(status.is_some(), "response should have status"); + assert_eq!(status.and_then(|s| s.code), Some(1)); // Failure + } + + // ==================== Edge Case Tests ==================== + + #[test] + fn test_generate_with_empty_rows() { + let table = TestReadOnlyTable::new("empty_table"); + let plugin = TablePlugin::from_readonly_table(table); + + let mut req = BTreeMap::new(); + req.insert("action".to_string(), "generate".to_string()); + let response = plugin.handle_call(req); + + let status = response.status.as_ref(); + assert!(status.is_some(), "response should have status"); + assert_eq!(status.and_then(|s| s.code), Some(0)); // Success with empty rows is valid + assert_eq!(response.response.as_ref().unwrap_or(&vec![]).len(), 0); + } + + #[test] + fn test_ping_returns_default_status() { + let table = TestReadOnlyTable::new("test"); + let plugin = TablePlugin::from_readonly_table(table); + let status = plugin.ping(); + // Default ExtensionStatus should be valid + assert!(status.code.is_none() || status.code == Some(0)); + } +} diff --git a/osquery-rust/src/plugin/table/query_constraint.rs b/osquery-rust/src/plugin/table/query_constraint.rs index 0161977..cf9781e 100644 --- a/osquery-rust/src/plugin/table/query_constraint.rs +++ b/osquery-rust/src/plugin/table/query_constraint.rs @@ -16,6 +16,41 @@ pub struct ConstraintList { constraints: Vec, } +impl ConstraintList { + /// Create a new ConstraintList with the given column type + #[allow(dead_code)] + pub fn new(affinity: ColumnType) -> Self { + Self { + affinity, + constraints: Vec::new(), + } + } + + /// Add a constraint to this list + #[allow(dead_code)] + pub fn add_constraint(&mut self, op: Operator, expr: String) { + self.constraints.push(Constraint { op, expr }); + } + + /// Get the column type affinity + #[allow(dead_code)] + pub fn affinity(&self) -> &ColumnType { + &self.affinity + } + + /// Get the number of constraints + #[allow(dead_code)] + pub fn len(&self) -> usize { + self.constraints.len() + } + + /// Check if there are no constraints + #[allow(dead_code)] + pub fn is_empty(&self) -> bool { + self.constraints.is_empty() + } +} + // Constraint contains both an operator and an expression that are applied as // constraints in the query. #[allow(dead_code)] @@ -24,26 +59,158 @@ struct Constraint { expr: String, } +/// Operators for query constraints, mapping to osquery's constraint operators +#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[allow(dead_code)] -enum Operator { - // 1 - Unique, - // 2 - Equals, - // 4 - GreaterThan, - // 8 - LessThanOrEquals, - // 16 - LessThan, - // 32 - GreaterThanOrEquals, - // 64 - Match, - // 65 - Like, - // 66 - Glob, - // 67 - Regexp, +pub enum Operator { + /// Unique constraint (code 1) + Unique = 1, + /// Equality constraint (code 2) + Equals = 2, + /// Greater than constraint (code 4) + GreaterThan = 4, + /// Less than or equals constraint (code 8) + LessThanOrEquals = 8, + /// Less than constraint (code 16) + LessThan = 16, + /// Greater than or equals constraint (code 32) + GreaterThanOrEquals = 32, + /// Match constraint (code 64) + Match = 64, + /// Like constraint (code 65) + Like = 65, + /// Glob constraint (code 66) + Glob = 66, + /// Regexp constraint (code 67) + Regexp = 67, +} + +impl TryFrom for Operator { + type Error = String; + + fn try_from(value: i32) -> Result { + match value { + 1 => Ok(Operator::Unique), + 2 => Ok(Operator::Equals), + 4 => Ok(Operator::GreaterThan), + 8 => Ok(Operator::LessThanOrEquals), + 16 => Ok(Operator::LessThan), + 32 => Ok(Operator::GreaterThanOrEquals), + 64 => Ok(Operator::Match), + 65 => Ok(Operator::Like), + 66 => Ok(Operator::Glob), + 67 => Ok(Operator::Regexp), + _ => Err(format!("Unknown operator code: {value}")), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_constraint_list_creation() { + let list = ConstraintList::new(ColumnType::Text); + assert!(list.is_empty()); + assert_eq!(list.len(), 0); + assert!(matches!(list.affinity(), ColumnType::Text)); + } + + #[test] + fn test_constraint_list_with_constraints() { + let mut list = ConstraintList::new(ColumnType::Integer); + list.add_constraint(Operator::Equals, "42".to_string()); + list.add_constraint(Operator::GreaterThan, "10".to_string()); + + assert!(!list.is_empty()); + assert_eq!(list.len(), 2); + assert!(matches!(list.affinity(), ColumnType::Integer)); + } + + #[test] + fn test_operator_equality_variants() { + assert_eq!(Operator::Equals, Operator::Equals); + assert_ne!(Operator::Equals, Operator::GreaterThan); + } + + #[test] + fn test_operator_comparison_variants() { + // Test all comparison operators exist and have correct values + assert_eq!(Operator::GreaterThan as i32, 4); + assert_eq!(Operator::LessThan as i32, 16); + assert_eq!(Operator::GreaterThanOrEquals as i32, 32); + assert_eq!(Operator::LessThanOrEquals as i32, 8); + } + + #[test] + fn test_operator_pattern_variants() { + // Test pattern matching operators + assert_eq!(Operator::Match as i32, 64); + assert_eq!(Operator::Like as i32, 65); + assert_eq!(Operator::Glob as i32, 66); + assert_eq!(Operator::Regexp as i32, 67); + } + + #[test] + fn test_operator_try_from_valid() { + assert_eq!(Operator::try_from(1), Ok(Operator::Unique)); + assert_eq!(Operator::try_from(2), Ok(Operator::Equals)); + assert_eq!(Operator::try_from(4), Ok(Operator::GreaterThan)); + assert_eq!(Operator::try_from(8), Ok(Operator::LessThanOrEquals)); + assert_eq!(Operator::try_from(16), Ok(Operator::LessThan)); + assert_eq!(Operator::try_from(32), Ok(Operator::GreaterThanOrEquals)); + assert_eq!(Operator::try_from(64), Ok(Operator::Match)); + assert_eq!(Operator::try_from(65), Ok(Operator::Like)); + assert_eq!(Operator::try_from(66), Ok(Operator::Glob)); + assert_eq!(Operator::try_from(67), Ok(Operator::Regexp)); + } + + #[test] + fn test_operator_try_from_invalid() { + assert!(Operator::try_from(0).is_err()); + assert!(Operator::try_from(3).is_err()); + assert!(Operator::try_from(100).is_err()); + assert!(Operator::try_from(-1).is_err()); + } + + #[test] + fn test_query_constraints_map() { + let mut constraints: QueryConstraints = HashMap::new(); + + let mut name_constraints = ConstraintList::new(ColumnType::Text); + name_constraints.add_constraint(Operator::Equals, "test".to_string()); + + let mut age_constraints = ConstraintList::new(ColumnType::Integer); + age_constraints.add_constraint(Operator::GreaterThan, "18".to_string()); + age_constraints.add_constraint(Operator::LessThan, "65".to_string()); + + constraints.insert("name".to_string(), name_constraints); + constraints.insert("age".to_string(), age_constraints); + + assert_eq!(constraints.len(), 2); + assert!(constraints.contains_key("name")); + assert!(constraints.contains_key("age")); + + let name_list = constraints.get("name"); + assert!(name_list.is_some()); + assert_eq!(name_list.map(|l| l.len()).unwrap_or(0), 1); + + let age_list = constraints.get("age"); + assert!(age_list.is_some()); + assert_eq!(age_list.map(|l| l.len()).unwrap_or(0), 2); + } + + #[test] + fn test_constraint_list_different_column_types() { + let text_list = ConstraintList::new(ColumnType::Text); + let int_list = ConstraintList::new(ColumnType::Integer); + let bigint_list = ConstraintList::new(ColumnType::BigInt); + let double_list = ConstraintList::new(ColumnType::Double); + + assert!(matches!(text_list.affinity(), ColumnType::Text)); + assert!(matches!(int_list.affinity(), ColumnType::Integer)); + assert!(matches!(bigint_list.affinity(), ColumnType::BigInt)); + assert!(matches!(double_list.affinity(), ColumnType::Double)); + } } diff --git a/osquery-rust/src/server.rs b/osquery-rust/src/server.rs index fb482c1..fccdc63 100644 --- a/osquery-rust/src/server.rs +++ b/osquery-rust/src/server.rs @@ -10,9 +10,8 @@ use thrift::protocol::*; use thrift::transport::*; use crate::_osquery as osquery; -use crate::_osquery::{TExtensionManagerSyncClient, TExtensionSyncClient}; -use crate::client::Client; -use crate::plugin::{OsqueryPlugin, Plugin, Registry}; +use crate::client::{OsqueryClient, ThriftClient}; +use crate::plugin::{OsqueryPlugin, Registry}; use crate::util::OptionToThriftResult; const DEFAULT_PING_INTERVAL: Duration = Duration::from_millis(500); @@ -64,10 +63,11 @@ impl ServerStopHandle { } } -pub struct Server { +pub struct Server +{ name: String, socket_path: String, - client: Client, + client: C, plugins: Vec

, ping_interval: Duration, uuid: Option, @@ -80,16 +80,19 @@ pub struct Server { listen_path: Option, } -impl Server

{ +/// Implementation for `Server` using the default `ThriftClient`. +impl Server { + /// Create a new server that connects to osquery at the given socket path. + /// + /// # Arguments + /// * `name` - Optional extension name (defaults to crate name) + /// * `socket_path` - Path to osquery's extension socket + /// + /// # Errors + /// Returns an error if the connection to osquery fails. pub fn new(name: Option<&str>, socket_path: &str) -> Result { - let mut reg: HashMap> = HashMap::new(); - for var in Registry::VARIANTS { - reg.insert((*var).to_string(), HashMap::new()); - } - let name = name.unwrap_or(crate_name!()); - - let client = Client::new(socket_path, Default::default())?; + let client = ThriftClient::new(socket_path, Default::default())?; Ok(Server { name: name.to_string(), @@ -104,6 +107,33 @@ impl Server

{ listen_path: None, }) } +} + +/// Implementation for `Server` with any client type (generic over `C: OsqueryClient`). +impl Server { + /// Create a server with a pre-constructed client. + /// + /// This constructor is useful for testing, allowing injection of mock clients. + /// + /// # Arguments + /// * `name` - Optional extension name (defaults to crate name) + /// * `socket_path` - Path to osquery's extension socket (used for listener socket naming) + /// * `client` - Pre-constructed client implementing `OsqueryClient` + pub fn with_client(name: Option<&str>, socket_path: &str, client: C) -> Self { + let name = name.unwrap_or(crate_name!()); + Server { + name: name.to_string(), + socket_path: socket_path.to_string(), + client, + plugins: Vec::new(), + ping_interval: DEFAULT_PING_INTERVAL, + uuid: None, + started: false, + shutdown_flag: Arc::new(AtomicBool::new(false)), + listener_thread: None, + listen_path: None, + } + } /// /// Registers a plugin, something which implements the OsqueryPlugin trait. @@ -541,3 +571,428 @@ impl osquery::ExtensionManagerSyncHandler for Handler< )) } } + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::panic)] // Tests are allowed to panic on setup failures +mod tests { + use super::*; + use crate::client::MockOsqueryClient; + use crate::plugin::Plugin; + use crate::plugin::{ColumnDef, ColumnOptions, ColumnType, ReadOnlyTable, TablePlugin}; + + /// Simple test table for server tests + struct TestTable; + + impl ReadOnlyTable for TestTable { + fn name(&self) -> String { + "test_table".to_string() + } + + fn columns(&self) -> Vec { + vec![ColumnDef::new( + "col", + ColumnType::Text, + ColumnOptions::DEFAULT, + )] + } + + fn generate(&self, _request: crate::ExtensionPluginRequest) -> crate::ExtensionResponse { + crate::ExtensionResponse::new(osquery::ExtensionStatus::default(), vec![]) + } + + fn shutdown(&self) {} + } + + #[test] + fn test_server_with_mock_client_creation() { + let mock_client = MockOsqueryClient::new(); + let server: Server = + Server::with_client(Some("test_ext"), "/tmp/test.sock", mock_client); + + assert_eq!(server.name, "test_ext"); + assert_eq!(server.socket_path, "/tmp/test.sock"); + assert!(server.plugins.is_empty()); + } + + #[test] + fn test_server_with_mock_client_default_name() { + let mock_client = MockOsqueryClient::new(); + let server: Server = + Server::with_client(None, "/tmp/test.sock", mock_client); + + // Default name comes from crate_name!() which is "osquery-rust-ng" + assert_eq!(server.name, "osquery-rust-ng"); + } + + #[test] + fn test_server_register_plugin_with_mock_client() { + let mock_client = MockOsqueryClient::new(); + let mut server: Server = + Server::with_client(Some("test"), "/tmp/test.sock", mock_client); + + let plugin = Plugin::Table(TablePlugin::from_readonly_table(TestTable)); + server.register_plugin(plugin); + + assert_eq!(server.plugins.len(), 1); + } + + #[test] + fn test_server_register_multiple_plugins() { + let mock_client = MockOsqueryClient::new(); + let mut server: Server = + Server::with_client(Some("test"), "/tmp/test.sock", mock_client); + + server.register_plugin(Plugin::Table(TablePlugin::from_readonly_table(TestTable))); + server.register_plugin(Plugin::Table(TablePlugin::from_readonly_table(TestTable))); + + assert_eq!(server.plugins.len(), 2); + } + + #[test] + fn test_server_stop_handle_with_mock_client() { + let mock_client = MockOsqueryClient::new(); + let server: Server = + Server::with_client(Some("test"), "/tmp/test.sock", mock_client); + + assert!(server.is_running()); + + let handle = server.get_stop_handle(); + assert!(handle.is_running()); + + handle.stop(); + + assert!(!server.is_running()); + assert!(!handle.is_running()); + } + + #[test] + fn test_server_stop_method_with_mock_client() { + let mock_client = MockOsqueryClient::new(); + let server: Server = + Server::with_client(Some("test"), "/tmp/test.sock", mock_client); + + assert!(server.is_running()); + server.stop(); + assert!(!server.is_running()); + } + + #[test] + fn test_generate_registry_with_mock_client() { + let mock_client = MockOsqueryClient::new(); + let mut server: Server = + Server::with_client(Some("test"), "/tmp/test.sock", mock_client); + + server.register_plugin(Plugin::Table(TablePlugin::from_readonly_table(TestTable))); + + let registry = server.generate_registry(); + assert!(registry.is_ok()); + + let registry = registry.ok(); + assert!(registry.is_some()); + + let registry = registry.unwrap_or_default(); + // Registry should have "table" entry + assert!(registry.contains_key("table")); + } + + // ======================================================================== + // cleanup_socket() tests + // ======================================================================== + + #[test] + fn test_cleanup_socket_removes_existing_socket() { + use std::fs::File; + use tempfile::tempdir; + + let temp_dir = tempdir().expect("Failed to create temp dir"); + let socket_base = temp_dir.path().join("test.sock"); + let socket_base_str = socket_base.to_string_lossy().to_string(); + + let mock_client = MockOsqueryClient::new(); + let mut server: Server = + Server::with_client(Some("test"), &socket_base_str, mock_client); + + // Set uuid to simulate registered state + server.uuid = Some(12345); + + // Create the socket file that cleanup_socket expects + let socket_path = format!("{}.{}", socket_base_str, 12345); + File::create(&socket_path).expect("Failed to create test socket file"); + assert!(std::path::Path::new(&socket_path).exists()); + + // Call cleanup_socket + server.cleanup_socket(); + + // Verify socket was removed + assert!(!std::path::Path::new(&socket_path).exists()); + } + + #[test] + fn test_cleanup_socket_handles_missing_socket() { + let mock_client = MockOsqueryClient::new(); + let mut server: Server = + Server::with_client(Some("test"), "/nonexistent/path/test.sock", mock_client); + + // Set uuid but socket file doesn't exist + server.uuid = Some(12345); + + // Should not panic, handles NotFound gracefully + server.cleanup_socket(); + } + + #[test] + fn test_cleanup_socket_no_uuid_skips() { + let mock_client = MockOsqueryClient::new(); + let server: Server = + Server::with_client(Some("test"), "/tmp/test.sock", mock_client); + + // uuid is None by default - cleanup should return early + assert!(server.uuid.is_none()); + + // Should not panic and should not try to remove any file + server.cleanup_socket(); + } + + // ======================================================================== + // notify_plugins_shutdown() tests + // ======================================================================== + + use crate::plugin::ConfigPlugin; + use std::collections::HashMap; + + /// Test config plugin that tracks whether shutdown was called + struct ShutdownTrackingConfigPlugin { + shutdown_called: Arc, + } + + impl ShutdownTrackingConfigPlugin { + fn new() -> (Self, Arc) { + let flag = Arc::new(AtomicBool::new(false)); + ( + Self { + shutdown_called: Arc::clone(&flag), + }, + flag, + ) + } + } + + impl ConfigPlugin for ShutdownTrackingConfigPlugin { + fn name(&self) -> String { + "shutdown_tracker".to_string() + } + + fn gen_config(&self) -> Result, String> { + Ok(HashMap::new()) + } + + fn gen_pack(&self, _name: &str, _value: &str) -> Result { + Err("not implemented".to_string()) + } + + fn shutdown(&self) { + self.shutdown_called.store(true, Ordering::SeqCst); + } + } + + #[test] + fn test_notify_plugins_shutdown_single_plugin() { + let mock_client = MockOsqueryClient::new(); + let mut server: Server = + Server::with_client(Some("test"), "/tmp/test.sock", mock_client); + + let (plugin, shutdown_flag) = ShutdownTrackingConfigPlugin::new(); + server.register_plugin(Plugin::config(plugin)); + + assert!(!shutdown_flag.load(Ordering::SeqCst)); + + server.notify_plugins_shutdown(); + + assert!(shutdown_flag.load(Ordering::SeqCst)); + } + + #[test] + fn test_notify_plugins_shutdown_multiple_plugins() { + let mock_client = MockOsqueryClient::new(); + let mut server: Server = + Server::with_client(Some("test"), "/tmp/test.sock", mock_client); + + let (plugin1, shutdown_flag1) = ShutdownTrackingConfigPlugin::new(); + let (plugin2, shutdown_flag2) = ShutdownTrackingConfigPlugin::new(); + let (plugin3, shutdown_flag3) = ShutdownTrackingConfigPlugin::new(); + + server.register_plugin(Plugin::config(plugin1)); + server.register_plugin(Plugin::config(plugin2)); + server.register_plugin(Plugin::config(plugin3)); + + assert!(!shutdown_flag1.load(Ordering::SeqCst)); + assert!(!shutdown_flag2.load(Ordering::SeqCst)); + assert!(!shutdown_flag3.load(Ordering::SeqCst)); + + server.notify_plugins_shutdown(); + + // All plugins should have been notified + assert!(shutdown_flag1.load(Ordering::SeqCst)); + assert!(shutdown_flag2.load(Ordering::SeqCst)); + assert!(shutdown_flag3.load(Ordering::SeqCst)); + } + + #[test] + fn test_notify_plugins_shutdown_empty_plugins() { + let mock_client = MockOsqueryClient::new(); + let server: Server = + Server::with_client(Some("test"), "/tmp/test.sock", mock_client); + + assert!(server.plugins.is_empty()); + + // Should not panic with no plugins + server.notify_plugins_shutdown(); + } + + // ======================================================================== + // join_listener_thread() tests + // ======================================================================== + + #[test] + fn test_join_listener_thread_no_thread() { + let mock_client = MockOsqueryClient::new(); + let mut server: Server = + Server::with_client(Some("test"), "/tmp/test.sock", mock_client); + + // listener_thread is None by default + assert!(server.listener_thread.is_none()); + + // Should return immediately without panic + server.join_listener_thread(); + } + + #[test] + fn test_join_listener_thread_finished_thread() { + let mock_client = MockOsqueryClient::new(); + let mut server: Server = + Server::with_client(Some("test"), "/tmp/test.sock", mock_client); + + // Create a thread that finishes immediately + let thread = thread::spawn(|| { + // Thread exits immediately + }); + + // Wait a bit for thread to finish + thread::sleep(Duration::from_millis(10)); + + server.listener_thread = Some(thread); + + // Should join successfully + server.join_listener_thread(); + + // Thread should have been taken + assert!(server.listener_thread.is_none()); + } + + // ======================================================================== + // wake_listener() tests + // ======================================================================== + + #[test] + fn test_wake_listener_no_path() { + let mock_client = MockOsqueryClient::new(); + let server: Server = + Server::with_client(Some("test"), "/tmp/test.sock", mock_client); + + // listen_path is None by default + assert!(server.listen_path.is_none()); + + // Should not panic with no path + server.wake_listener(); + } + + #[test] + fn test_wake_listener_with_path() { + use std::os::unix::net::UnixListener; + use tempfile::tempdir; + + let temp_dir = tempdir().expect("Failed to create temp dir"); + let socket_path = temp_dir.path().join("test.sock"); + let socket_path_str = socket_path.to_string_lossy().to_string(); + + // Create a Unix listener on the socket + let listener = UnixListener::bind(&socket_path).expect("Failed to bind listener"); + + // Set non-blocking so accept doesn't hang + listener + .set_nonblocking(true) + .expect("Failed to set non-blocking"); + + let mock_client = MockOsqueryClient::new(); + let mut server: Server = + Server::with_client(Some("test"), "/tmp/test.sock", mock_client); + + server.listen_path = Some(socket_path_str); + + // Call wake_listener + server.wake_listener(); + + // Verify connection was received (or would have been if blocking) + // The connection attempt is best-effort, so we just verify no panic + // and that accept would have received something if blocking + match listener.accept() { + Ok(_) => { + // Connection received - wake_listener worked + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + // This can happen in some race conditions, which is fine + // The important thing is no panic occurred + } + Err(e) => { + panic!("Unexpected error: {e}"); + } + } + } + + #[test] + fn test_mock_client_query() { + use crate::ExtensionResponse; + + let mut mock_client = MockOsqueryClient::new(); + + // Set up expectation for query() method + mock_client.expect_query().returning(|sql| { + // Return a mock response based on the SQL + let status = osquery::ExtensionStatus { + code: Some(0), + message: Some(format!("Query executed: {sql}")), + uuid: None, + }; + Ok(ExtensionResponse::new(status, vec![])) + }); + + // Call query() and verify behavior + let result = mock_client.query("SELECT * FROM test".to_string()); + assert!(result.is_ok()); + let response = result.expect("query should succeed"); + assert_eq!(response.status.as_ref().and_then(|s| s.code), Some(0)); + } + + #[test] + fn test_mock_client_get_query_columns() { + use crate::ExtensionResponse; + + let mut mock_client = MockOsqueryClient::new(); + + // Set up expectation for get_query_columns() method + mock_client.expect_get_query_columns().returning(|sql| { + let status = osquery::ExtensionStatus { + code: Some(0), + message: Some(format!("Columns for: {sql}")), + uuid: None, + }; + Ok(ExtensionResponse::new(status, vec![])) + }); + + // Call get_query_columns() and verify behavior + let result = mock_client.get_query_columns("SELECT * FROM test".to_string()); + assert!(result.is_ok()); + let response = result.expect("get_query_columns should succeed"); + assert_eq!(response.status.as_ref().and_then(|s| s.code), Some(0)); + } +} diff --git a/osquery-rust/src/util.rs b/osquery-rust/src/util.rs index 6ee2511..18f136e 100644 --- a/osquery-rust/src/util.rs +++ b/osquery-rust/src/util.rs @@ -19,3 +19,36 @@ impl OptionToThriftResult for Option { }) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_ok_or_thrift_err_with_some() { + let value: Option = Some(42); + let result = value.ok_or_thrift_err(|| "should not be called".to_string()); + assert!(result.is_ok()); + assert_eq!(result.ok(), Some(42)); + } + + #[test] + fn test_ok_or_thrift_err_with_none() { + let value: Option = None; + let result = value.ok_or_thrift_err(|| "custom error message".to_string()); + assert!(result.is_err()); + + // Verify it's an Application error with InternalError kind + let err = result.err(); + assert!(err.is_some(), "Expected error"); + assert!( + matches!( + &err, + Some(thrift::Error::Application(app_err)) + if app_err.kind == ApplicationErrorKind::InternalError + && app_err.message == "custom error message" + ), + "Expected Application error with InternalError kind" + ); + } +} diff --git a/osquery-rust/tests/integration_test.rs b/osquery-rust/tests/integration_test.rs new file mode 100644 index 0000000..c6cdfd1 --- /dev/null +++ b/osquery-rust/tests/integration_test.rs @@ -0,0 +1,823 @@ +//! Integration tests for osquery-rust-ng with real osquery. +//! +//! These tests require osquery to be installed and running. They test the ThriftClient +//! implementation against a real osquery Unix domain socket. +//! +//! ## Running the tests +//! +//! ### Via pre-commit hook (sets up osquery automatically) +//! ```bash +//! .git/hooks/pre-commit +//! ``` +//! +//! ### Direct (requires osquery running with extensions autoloaded) +//! ```bash +//! cargo test --features osquery-tests --test integration_test +//! ``` +//! +//! ## Architecture Note +//! +//! osquery extensions communicate via Unix domain sockets. Integration tests must run +//! on a host with osquery installed and running. +//! +//! These tests will FAIL (not skip) if osquery socket is not available. + +#![cfg(feature = "osquery-tests")] + +#[allow(clippy::expect_used, clippy::panic)] // Integration tests can panic on infra failures +mod tests { + use std::path::Path; + use std::time::Duration; + + const SOCKET_WAIT_TIMEOUT: Duration = Duration::from_secs(30); + const SOCKET_POLL_INTERVAL: Duration = Duration::from_millis(100); + + /// Get the osquery extensions socket path from environment or common locations. + /// Waits up to SOCKET_WAIT_TIMEOUT for socket to appear. + fn get_osquery_socket() -> String { + let start = std::time::Instant::now(); + + // Build list of paths to check + let env_path = std::env::var("OSQUERY_SOCKET").ok(); + let home = std::env::var("HOME").unwrap_or_default(); + + loop { + // Check environment variable first + if let Some(ref path) = env_path { + if Path::new(path).exists() { + return path.clone(); + } + } + + // Try common locations on macOS and Linux + let common_paths = [ + "/var/osquery/osquery.em".to_string(), + "/tmp/osquery.em".to_string(), + format!("{}/.osquery/shell.em", home), + ]; + + for path in &common_paths { + if Path::new(path).exists() { + return path.clone(); + } + } + + // Check timeout + if start.elapsed() >= SOCKET_WAIT_TIMEOUT { + let checked_paths: Vec<&str> = env_path + .as_ref() + .map(|p| vec![p.as_str()]) + .unwrap_or_default() + .into_iter() + .chain(common_paths.iter().map(|s| s.as_str())) + .collect(); + + panic!( + "No osquery socket found after {:?}. Checked paths: {:?}\n\ + \n\ + To run integration tests:\n\ + 1. Start osqueryi: osqueryi --nodisable_extensions\n\ + 2. Set OSQUERY_SOCKET env var to the socket path\n\ + 3. Or run tests inside Docker container with osqueryd", + SOCKET_WAIT_TIMEOUT, checked_paths + ); + } + + std::thread::sleep(SOCKET_POLL_INTERVAL); + } + } + + /// Wait for an extension to be registered in osquery. + /// Polls `osquery_extensions` table until the extension name appears or timeout. + fn wait_for_extension_registered(socket_path: &str, extension_name: &str) { + use osquery_rust_ng::{OsqueryClient, ThriftClient}; + + const REGISTRATION_TIMEOUT: Duration = Duration::from_secs(10); + const REGISTRATION_POLL_INTERVAL: Duration = Duration::from_millis(100); + + let start = std::time::Instant::now(); + let query = format!( + "SELECT name FROM osquery_extensions WHERE name = '{}'", + extension_name + ); + + loop { + // Try to query for the extension + if let Ok(mut client) = ThriftClient::new(socket_path, Default::default()) { + if let Ok(response) = client.query(query.clone()) { + if let Some(rows) = response.response { + if !rows.is_empty() { + eprintln!( + "Extension '{}' registered after {:?}", + extension_name, + start.elapsed() + ); + return; + } + } + } + } + + // Check timeout + if start.elapsed() >= REGISTRATION_TIMEOUT { + panic!( + "Extension '{}' not registered after {:?}", + extension_name, REGISTRATION_TIMEOUT + ); + } + + std::thread::sleep(REGISTRATION_POLL_INTERVAL); + } + } + + /// Test ThriftClient can connect to osquery socket. + #[test] + fn test_thrift_client_connects_to_osquery() { + use osquery_rust_ng::ThriftClient; + + let socket_path = get_osquery_socket(); + eprintln!("Using osquery socket: {}", socket_path); + + let client = ThriftClient::new(&socket_path, Default::default()); + + match client { + Ok(_) => eprintln!("SUCCESS: ThriftClient connected to {}", socket_path), + Err(e) => panic!("ThriftClient::new failed for {}: {:?}", socket_path, e), + } + } + + /// Test ThriftClient ping functionality. + #[test] + fn test_thrift_client_ping() { + use osquery_rust_ng::{OsqueryClient, ThriftClient}; + + let socket_path = get_osquery_socket(); + eprintln!("Using osquery socket: {}", socket_path); + + let mut client = ThriftClient::new(&socket_path, Default::default()) + .expect("Failed to create ThriftClient"); + + let result = client.ping(); + + match result { + Ok(status) => { + eprintln!("SUCCESS: Ping returned status code {:?}", status.code); + assert!( + status.code == Some(0) || status.code.is_none(), + "Ping returned unexpected code: {:?}", + status + ); + } + Err(e) => panic!("Ping failed: {:?}", e), + } + } + + /// Test querying osquery_info table via ThriftClient. + #[test] + fn test_query_osquery_info() { + use osquery_rust_ng::{OsqueryClient, ThriftClient}; + + let socket_path = get_osquery_socket(); + eprintln!("Using osquery socket: {}", socket_path); + + let mut client = ThriftClient::new(&socket_path, Default::default()) + .expect("Failed to create ThriftClient"); + + // Query osquery_info table - built-in table that always exists + let result = client.query("SELECT * FROM osquery_info".to_string()); + assert!(result.is_ok(), "Query should succeed: {:?}", result.err()); + + let response = result.expect("Should have response"); + + // Verify status + let status = response.status.expect("Should have status"); + assert_eq!(status.code, Some(0), "Query should return success status"); + + // Verify we got rows back + let rows = response.response.expect("Should have response rows"); + assert!( + !rows.is_empty(), + "osquery_info should return at least one row" + ); + + eprintln!("SUCCESS: Query returned {} rows", rows.len()); + } + + #[test] + fn test_server_lifecycle() { + use osquery_rust_ng::plugin::{ + ColumnDef, ColumnOptions, ColumnType, ReadOnlyTable, TablePlugin, + }; + use osquery_rust_ng::{ExtensionPluginRequest, ExtensionResponse, ExtensionStatus, Server}; + use std::thread; + + // Create a simple test table + struct TestLifecycleTable; + + impl ReadOnlyTable for TestLifecycleTable { + fn name(&self) -> String { + "test_lifecycle_table".to_string() + } + + fn columns(&self) -> Vec { + vec![ColumnDef::new( + "id", + ColumnType::Text, + ColumnOptions::DEFAULT, + )] + } + + fn generate(&self, _req: ExtensionPluginRequest) -> ExtensionResponse { + ExtensionResponse::new( + ExtensionStatus { + code: Some(0), + message: Some("OK".to_string()), + uuid: None, + }, + vec![], + ) + } + + fn shutdown(&self) {} + } + + let socket_path = get_osquery_socket(); + eprintln!("Using osquery socket: {}", socket_path); + + // Create server - Server::new returns Result + let mut server = + Server::new(Some("test_lifecycle"), &socket_path).expect("Failed to create Server"); + + // Wrap table in TablePlugin and register + let plugin = TablePlugin::from_readonly_table(TestLifecycleTable); + server.register_plugin(plugin); + + // Get stop handle before spawning thread + let stop_handle = server.get_stop_handle(); + + // Run server in background thread + let server_thread = thread::spawn(move || { + server.run().expect("Server run failed"); + }); + + // Wait for extension to register using active polling + wait_for_extension_registered(&socket_path, "test_lifecycle"); + + // Stop server (triggers graceful shutdown) + stop_handle.stop(); + + // Wait for server thread to finish + server_thread.join().expect("Server thread panicked"); + + eprintln!("SUCCESS: Server lifecycle completed (create → register → run → stop)"); + } + + #[test] + fn test_table_plugin_end_to_end() { + use osquery_rust_ng::plugin::{ + ColumnDef, ColumnOptions, ColumnType, ReadOnlyTable, TablePlugin, + }; + use osquery_rust_ng::{ + ExtensionPluginRequest, ExtensionResponse, ExtensionStatus, OsqueryClient, Server, + ThriftClient, + }; + use std::collections::BTreeMap; + use std::thread; + + // Create test table that returns known data + struct TestEndToEndTable; + + impl ReadOnlyTable for TestEndToEndTable { + fn name(&self) -> String { + "test_e2e_table".to_string() + } + + fn columns(&self) -> Vec { + vec![ + ColumnDef::new("id", ColumnType::Integer, ColumnOptions::DEFAULT), + ColumnDef::new("name", ColumnType::Text, ColumnOptions::DEFAULT), + ] + } + + fn generate(&self, _req: ExtensionPluginRequest) -> ExtensionResponse { + let mut row = BTreeMap::new(); + row.insert("id".to_string(), "42".to_string()); + row.insert("name".to_string(), "test_value".to_string()); + + ExtensionResponse::new( + ExtensionStatus { + code: Some(0), + message: Some("OK".to_string()), + uuid: None, + }, + vec![row], + ) + } + + fn shutdown(&self) {} + } + + let socket_path = get_osquery_socket(); + eprintln!("Using osquery socket: {}", socket_path); + + // Create and start server with test table + let mut server = + Server::new(Some("test_e2e"), &socket_path).expect("Failed to create Server"); + + let plugin = TablePlugin::from_readonly_table(TestEndToEndTable); + server.register_plugin(plugin); + + let stop_handle = server.get_stop_handle(); + + let server_thread = thread::spawn(move || { + server.run().expect("Server run failed"); + }); + + // Wait for extension to register using active polling + wait_for_extension_registered(&socket_path, "test_e2e"); + + // Query the table through osquery using a separate client + let mut client = ThriftClient::new(&socket_path, Default::default()) + .expect("Failed to create query client"); + + let result = client.query("SELECT * FROM test_e2e_table".to_string()); + + // Stop server before assertions (cleanup) + stop_handle.stop(); + server_thread.join().expect("Server thread panicked"); + + // Verify query results + let response = result.expect("Query should succeed"); + let status = response.status.expect("Should have status"); + assert_eq!(status.code, Some(0), "Query should return success"); + + let rows = response.response.expect("Should have rows"); + assert_eq!(rows.len(), 1, "Should have exactly one row"); + + let row = rows.first().expect("Should have first row"); + assert_eq!(row.get("id"), Some(&"42".to_string())); + assert_eq!(row.get("name"), Some(&"test_value".to_string())); + + eprintln!("SUCCESS: End-to-end table query returned expected data"); + } + + // Note: Config plugin integration testing requires autoload configuration. + // Runtime-registered config plugins are not used by osquery automatically. + // To test config plugins, build a config extension, autoload it, and configure + // osqueryd with --config_plugin=. + + #[test] + fn test_logger_plugin_registers_successfully() { + use osquery_rust_ng::plugin::{LogStatus, LoggerPlugin, Plugin}; + use osquery_rust_ng::{OsqueryClient, Server, ThriftClient}; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + use std::thread; + + // Create a logger plugin that counts log calls + struct TestLoggerPlugin { + log_string_count: Arc, + log_status_count: Arc, + } + + impl LoggerPlugin for TestLoggerPlugin { + fn name(&self) -> String { + "test_logger".to_string() + } + + fn log_string(&self, _message: &str) -> Result<(), String> { + self.log_string_count.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + + fn log_status(&self, _status: &LogStatus) -> Result<(), String> { + self.log_status_count.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + + fn log_snapshot(&self, _snapshot: &str) -> Result<(), String> { + Ok(()) + } + + fn init(&self, _name: &str) -> Result<(), String> { + Ok(()) + } + + fn health(&self) -> Result<(), String> { + Ok(()) + } + + fn shutdown(&self) {} + } + + let socket_path = get_osquery_socket(); + eprintln!("Using osquery socket: {}", socket_path); + + let log_string_count = Arc::new(AtomicUsize::new(0)); + let log_status_count = Arc::new(AtomicUsize::new(0)); + + let logger = TestLoggerPlugin { + log_string_count: Arc::clone(&log_string_count), + log_status_count: Arc::clone(&log_status_count), + }; + + // Create and start server with logger plugin + let mut server = Server::new(Some("test_logger_integration"), &socket_path) + .expect("Failed to create Server"); + + server.register_plugin(Plugin::logger(logger)); + + let stop_handle = server.get_stop_handle(); + + let server_thread = thread::spawn(move || { + server.run().expect("Server run failed"); + }); + + // Wait for extension to register using active polling + wait_for_extension_registered(&socket_path, "test_logger_integration"); + + // Run some queries to potentially trigger logging + let mut client = ThriftClient::new(&socket_path, Default::default()) + .expect("Failed to create query client"); + + // These queries may generate log events + let _ = client.query("SELECT * FROM osquery_info".to_string()); + let _ = client.query("SELECT * FROM osquery_extensions".to_string()); + + // Give osquery time to send any log events + std::thread::sleep(Duration::from_secs(1)); + + // Stop server + stop_handle.stop(); + server_thread.join().expect("Server thread panicked"); + + // Check if any logs were received + let string_logs = log_string_count.load(Ordering::SeqCst); + let status_logs = log_status_count.load(Ordering::SeqCst); + + eprintln!( + "Logger received: {} string logs, {} status logs", + string_logs, status_logs + ); + + // Note: This test verifies runtime registration works. Callback invocation + // is tested separately via autoload in test_autoloaded_logger_receives_init + // and test_autoloaded_logger_receives_logs (daemon mode required). + eprintln!("SUCCESS: Logger plugin registered successfully"); + } + + /// Test that the autoloaded logger-file extension receives init callback from osquery. + /// + /// This test verifies the logger-file example extension is properly autoloaded + /// by osqueryd and receives the init() callback. The pre-commit hook sets up + /// the autoload configuration and exports TEST_LOGGER_FILE with the log path. + /// + /// Requires: osqueryd with autoload configured (set up by pre-commit hook) + #[test] + fn test_autoloaded_logger_receives_init() { + use std::fs; + + // Get the autoloaded logger's log file path from environment + let log_path = match std::env::var("TEST_LOGGER_FILE") { + Ok(path) => path, + Err(_) => { + panic!( + "TEST_LOGGER_FILE not set - this test requires osqueryd with autoload. \ + Run via: .git/hooks/pre-commit or ./scripts/ci-test.sh" + ); + } + }; + + eprintln!("Checking autoloaded logger file: {}", log_path); + + // Read the log file written by the autoloaded logger-file extension + let log_contents = fs::read_to_string(&log_path).unwrap_or_else(|e| { + panic!( + "Failed to read autoloaded logger file '{}': {}", + log_path, e + ); + }); + + eprintln!("Autoloaded logger file contents:\n{}", log_contents); + + // Strict assertion: init MUST be called when logger plugin is autoloaded and active + // The logger-file extension writes "Logger initialized" when init() is called + assert!( + log_contents.contains("Logger initialized"), + "Autoloaded logger must receive init callback - verify osqueryd started with \ + --logger_plugin=file_logger and --extensions_autoload configured. Log file contents: {}", + log_contents + ); + + eprintln!("SUCCESS: Autoloaded logger-file extension received init callback"); + } + + /// Test that the autoloaded logger-file extension receives log callbacks from osquery. + /// + /// This test verifies that osquery actually sends logs to the file_logger plugin, + /// not just that it was initialized. This tests the log_status callback path. + /// + /// Requires: osqueryd with autoload configured (set up by pre-commit hook) + #[test] + fn test_autoloaded_logger_receives_logs() { + use std::fs; + + // Get the autoloaded logger's log file path from environment + let log_path = match std::env::var("TEST_LOGGER_FILE") { + Ok(path) => path, + Err(_) => { + panic!( + "TEST_LOGGER_FILE not set - this test requires osqueryd with autoload. \ + Run via: .git/hooks/pre-commit or ./scripts/ci-test.sh" + ); + } + }; + + eprintln!( + "Checking autoloaded logger file for log entries: {}", + log_path + ); + + // Read the log file written by the autoloaded logger-file extension + let log_contents = fs::read_to_string(&log_path).unwrap_or_else(|e| { + panic!( + "Failed to read autoloaded logger file '{}': {}", + log_path, e + ); + }); + + eprintln!("Log file contents:\n{}", log_contents); + + // Look for specific osquery core log messages + // osquery logs from C++ source files have the format: [SEVERITY] filename.cpp:line - message + // For example: "[INFO] interface.cpp:137 - Registering extension" + // + // We verify the logger receives actual osquery core messages, not just plugin output + let has_osquery_core_log = log_contents + .lines() + .any(|line| line.contains(".cpp:") && line.contains(" - ")); + + assert!( + has_osquery_core_log, + "Autoloaded logger should receive osquery core log messages (format: 'file.cpp:line - message'). \ + Log file contents:\n{}", + log_contents + ); + + eprintln!("SUCCESS: Autoloaded logger received osquery core log messages"); + } + + /// Test that the autoloaded config-static extension provides configuration to osquery. + /// + /// This test verifies: + /// 1. The config plugin's gen_config() was called (marker file exists) + /// 2. osquery actually used the configuration (schedule queries are present) + /// + /// Requires: osqueryd with autoload and --config_plugin=static_config + #[test] + fn test_autoloaded_config_provides_config() { + use osquery_rust_ng::{OsqueryClient, ThriftClient}; + use std::fs; + + // Get the config marker file path from environment + let marker_path = match std::env::var("TEST_CONFIG_MARKER_FILE") { + Ok(path) => path, + Err(_) => { + panic!( + "TEST_CONFIG_MARKER_FILE not set - this test requires osqueryd with autoload. \ + Run via: .git/hooks/pre-commit or ./scripts/ci-test.sh" + ); + } + }; + + eprintln!("Checking config marker file: {}", marker_path); + + // Part 1: Verify gen_config() was called by checking marker file + let marker_contents = fs::read_to_string(&marker_path).unwrap_or_else(|e| { + panic!( + "Config marker file '{}' not found or unreadable: {}. \ + This means gen_config() was never called by osquery.", + marker_path, e + ); + }); + + assert!( + marker_contents.contains("Config generated"), + "Marker file should contain 'Config generated', found: {}", + marker_contents + ); + + eprintln!("Config marker verified: gen_config() was called"); + + // Part 2: Verify osquery is using the configuration by querying osquery_schedule + // The static_config plugin provides a canary schedule with a unique name and query + // that could only exist if our config was applied by osquery + let socket_path = get_osquery_socket(); + let mut client = ThriftClient::new(&socket_path, Default::default()) + .expect("Failed to create ThriftClient"); + + let result = client.query("SELECT name, query FROM osquery_schedule".to_string()); + assert!( + result.is_ok(), + "Query to osquery_schedule should succeed: {:?}", + result.err() + ); + + let response = result.expect("Should have response"); + let status = response.status.expect("Should have status"); + assert_eq!(status.code, Some(0), "Query should return success status"); + + let rows = response.response.expect("Should have response rows"); + + eprintln!("osquery_schedule contents: {:?}", rows); + + // Look for the canary schedule - this unique name proves our config was applied + const CANARY_NAME: &str = "rust_config_canary_7f3d2a"; + const CANARY_VALUE: &str = "canary_value_abc123"; + + let canary_row = rows + .iter() + .find(|row| row.get("name").map(|n| n == CANARY_NAME).unwrap_or(false)); + + assert!( + canary_row.is_some(), + "osquery_schedule should contain canary schedule '{}' from static_config. \ + This proves the config plugin was called and osquery applied the configuration. \ + Found schedules: {:?}", + CANARY_NAME, + rows.iter() + .filter_map(|r| r.get("name")) + .collect::>() + ); + + // Verify the canary query contains the expected canary value + let canary_query = canary_row + .and_then(|row| row.get("query")) + .expect("canary schedule should have a query column"); + + assert!( + canary_query.contains(CANARY_VALUE), + "Canary query should contain '{}', found: {}", + CANARY_VALUE, + canary_query + ); + + eprintln!( + "SUCCESS: Config plugin canary verified - schedule '{}' with query: {}", + CANARY_NAME, canary_query + ); + } + + /// Test that the autoloaded logger-file extension receives snapshot logs from scheduled queries. + /// + /// This test verifies the complete log_snapshot callback path: + /// 1. The logger plugin advertises LOG_EVENT feature + /// 2. A scheduled query executes (osquery_info_snapshot runs every 3 seconds) + /// 3. osquery sends the query results to log_snapshot() + /// 4. The logger writes [SNAPSHOT] entries to the log file + /// + /// The startup script uses `osqueryi --connect` to verify extensions are ready + /// and waits for the first scheduled query, so snapshots should exist immediately. + /// + /// Requires: osqueryd with autoload configured (set up by pre-commit hook) + #[test] + fn test_autoloaded_logger_receives_snapshots() { + use std::fs; + use std::process::Command; + + // Get the autoloaded logger's log file path from environment + let log_path = match std::env::var("TEST_LOGGER_FILE") { + Ok(path) => path, + Err(_) => { + panic!( + "TEST_LOGGER_FILE not set - this test requires osqueryd with autoload. \ + Run via: .git/hooks/pre-commit or ./scripts/ci-test.sh" + ); + } + }; + + let socket_path = get_osquery_socket(); + + eprintln!( + "Testing snapshot logging via osqueryi --connect to {}", + socket_path + ); + + // Use osqueryi --connect to verify osquery is responding and trigger activity + // This also verifies the scheduled queries are configured + let output = Command::new("osqueryi") + .args([ + "--connect", + &socket_path, + "--json", + "SELECT name FROM osquery_schedule WHERE name = 'osquery_info_snapshot'", + ]) + .output(); + + match output { + Ok(out) => { + let stdout = String::from_utf8_lossy(&out.stdout); + eprintln!("osquery_schedule query result: {}", stdout); + if !stdout.contains("osquery_info_snapshot") { + eprintln!( + "Warning: osquery_info_snapshot not in schedule. \ + Snapshots may come from other scheduled queries." + ); + } + } + Err(e) => { + eprintln!( + "osqueryi --connect failed (may be expected in some envs): {}", + e + ); + } + } + + // Check for snapshot entries - they should already exist from startup + // The startup script waits for the first scheduled query execution + let log_contents = fs::read_to_string(&log_path).unwrap_or_else(|e| { + panic!( + "Failed to read autoloaded logger file '{}': {}", + log_path, e + ); + }); + + eprintln!("Log file contents:\n{}", log_contents); + + // Count [SNAPSHOT] entries - these come from scheduled query results + let snapshot_count = log_contents + .lines() + .filter(|line| line.contains("[SNAPSHOT]")) + .count(); + + if snapshot_count > 0 { + eprintln!( + "SUCCESS: Autoloaded logger received {} snapshot entries from scheduled queries", + snapshot_count + ); + + // Verify the snapshot contains expected data from osquery_info query + // The osquery_info_snapshot query selects version and build_platform + let has_expected_content = log_contents.lines().any(|line| { + line.contains("[SNAPSHOT]") + && (line.contains("version") || line.contains("build_platform")) + }); + + assert!( + has_expected_content, + "Snapshot should contain osquery_info data (version or build_platform). \ + Log contents:\n{}", + log_contents + ); + + return; + } + + // If no snapshots yet (rare), briefly poll with short timeout + eprintln!("No snapshots found yet, polling briefly..."); + let start = std::time::Instant::now(); + let timeout = Duration::from_secs(5); + let poll_interval = Duration::from_millis(500); + + loop { + std::thread::sleep(poll_interval); + + let log_contents = fs::read_to_string(&log_path).unwrap_or_else(|e| { + panic!("Failed to read logger file '{}': {}", log_path, e); + }); + + let snapshot_count = log_contents + .lines() + .filter(|line| line.contains("[SNAPSHOT]")) + .count(); + + if snapshot_count > 0 { + eprintln!( + "SUCCESS: Found {} snapshot entries after polling", + snapshot_count + ); + + let has_expected_content = log_contents.lines().any(|line| { + line.contains("[SNAPSHOT]") + && (line.contains("version") || line.contains("build_platform")) + }); + + assert!( + has_expected_content, + "Snapshot should contain osquery_info data. Log:\n{}", + log_contents + ); + return; + } + + if start.elapsed() >= timeout { + panic!( + "No [SNAPSHOT] entries found after {:?}. \ + Logger must advertise LOG_EVENT feature and osquery must have \ + scheduled queries with snapshot=true. Log:\n{}", + timeout, log_contents + ); + } + } + } +} diff --git a/scripts/build-test-image.sh b/scripts/build-test-image.sh new file mode 100755 index 0000000..05c1aa1 --- /dev/null +++ b/scripts/build-test-image.sh @@ -0,0 +1,86 @@ +#!/bin/bash +# build-test-image.sh - Build the osquery-rust-test Docker image +# +# This script builds the multi-stage Docker image that contains +# osquery and the Rust extensions for integration testing. +# +# Usage: +# ./scripts/build-test-image.sh [IMAGE_TAG] +# +# Arguments: +# IMAGE_TAG - Optional tag for the image (default: osquery-rust-test:latest) + +set -e + +IMAGE_TAG="${1:-osquery-rust-test:latest}" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" + +echo "=== Building osquery-rust-test Docker image ===" +echo "Image tag: $IMAGE_TAG" +echo "Project root: $PROJECT_ROOT" +echo "" + +# Check that Dockerfile exists +if [ ! -f "$PROJECT_ROOT/docker/Dockerfile.test" ]; then + echo "ERROR: docker/Dockerfile.test not found" + echo "Expected path: $PROJECT_ROOT/docker/Dockerfile.test" + exit 1 +fi + +# Build the image +echo "Building image (this may take a few minutes on first run)..." +docker build \ + -t "$IMAGE_TAG" \ + -f "$PROJECT_ROOT/docker/Dockerfile.test" \ + "$PROJECT_ROOT" + +echo "" +echo "=== Build complete ===" +echo "Image: $IMAGE_TAG" +echo "" + +# Verify the image - basic osquery works +echo "Verifying osquery..." +docker run --rm "$IMAGE_TAG" osqueryi --json "SELECT 1 AS test;" + +# Verify Rust toolchain is present +echo "" +echo "Verifying Rust toolchain..." +docker run --rm "$IMAGE_TAG" cargo --version +docker run --rm "$IMAGE_TAG" rustc --version + +# Verify ALL extensions load (start osqueryd, wait, query osquery_extensions) +echo "" +echo "Verifying all extensions load..." +docker run --rm "$IMAGE_TAG" sh -c ' +/opt/osquery/bin/osqueryd --ephemeral --disable_extensions=false \ + --extensions_socket=/var/osquery/osquery.em \ + --extensions_autoload=/etc/osquery/extensions.load \ + --database_path=/tmp/osquery.db \ + --disable_watchdog --force 2>/dev/null & +for i in $(seq 1 15); do + if [ -S /var/osquery/osquery.em ]; then sleep 3; break; fi + sleep 1 +done +echo "Loaded extensions:" +/usr/bin/osqueryi --connect /var/osquery/osquery.em --json "SELECT name, type FROM osquery_extensions WHERE name != \"core\";" +echo "" +echo "Testing two-tables extension (t1 table):" +/usr/bin/osqueryi --connect /var/osquery/osquery.em --json "SELECT * FROM t1 LIMIT 1;" +' + +echo "" +echo "=== Image verified successfully ===" +echo "" +echo "To test extensions manually:" +echo " docker run --rm $IMAGE_TAG sh -c '" +echo " osqueryd --ephemeral --disable_extensions=false --extensions_socket=/var/osquery/osquery.em \\" +echo " --extensions_autoload=/etc/osquery/extensions.load --database_path=/tmp/osquery.db \\" +echo " --disable_watchdog --force &" +echo " sleep 5" +echo " osqueryi --connect /var/osquery/osquery.em \"SELECT * FROM t1;\"'" +echo "" +echo "To run cargo test inside container:" +echo " docker run --rm -v \$(pwd):/workspace -w /workspace $IMAGE_TAG \\" +echo " sh -c 'osqueryd --ephemeral ... & sleep 5 && cargo test --test integration_test'" diff --git a/scripts/ci-test.sh b/scripts/ci-test.sh new file mode 100755 index 0000000..552e6f7 --- /dev/null +++ b/scripts/ci-test.sh @@ -0,0 +1,440 @@ +#!/usr/bin/env bash +# CI Test Runner for osquery-rust +# +# Usage: ./scripts/ci-test.sh [--coverage] [--html] +# +# Options: +# --coverage Generate lcov coverage report +# --html Generate HTML coverage report +# +# This script: +# 1. Detects osqueryd (checks PATH and common install locations) +# 2. If osqueryd not found, falls back to running tests in Docker +# 3. Builds extension examples (logger-file, config-static) +# 4. Sets up autoload configuration +# 5. Starts osqueryd with extensions autoloaded +# 6. Waits for socket AND extensions to be ready +# 7. Runs integration tests with osquery-tests feature +# 8. Optionally generates coverage reports +# 9. Cleans up on exit (success or failure) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +CI_DIR="/tmp/osquery-ci-$$" +OSQUERY_PID="" +COVERAGE=false +HTML=false +DOCKER_IMAGE="osquery-rust-test:latest" + +# Parse args +for arg in "$@"; do + case $arg in + --coverage) COVERAGE=true ;; + --html) HTML=true ;; + esac +done + +cleanup() { + echo "Cleaning up..." + # Kill osqueryd by name since piping to tee makes $! capture tee's PID + pkill -f "osqueryd.*extensions_socket.*$CI_DIR" 2>/dev/null || true + if [ -n "$OSQUERY_PID" ]; then + kill "$OSQUERY_PID" 2>/dev/null || true + wait "$OSQUERY_PID" 2>/dev/null || true + fi + # Give osqueryd a moment to exit + sleep 1 + rm -rf "$CI_DIR" 2>/dev/null || true +} +trap cleanup EXIT + +# Find osqueryd binary - check PATH and common install locations +find_osqueryd() { + # Check PATH first + if command -v osqueryd &> /dev/null; then + command -v osqueryd + return 0 + fi + + # Common installation paths + local paths=( + "/opt/osquery/bin/osqueryd" # Linux .deb/.rpm package + "/usr/local/bin/osqueryd" # Manual install / homebrew + "/usr/bin/osqueryd" # System package + ) + + for path in "${paths[@]}"; do + if [ -x "$path" ]; then + echo "$path" + return 0 + fi + done + + return 1 +} + +# Check if Docker is available +has_docker() { + command -v docker &> /dev/null && docker info &> /dev/null +} + +# Build Docker test image if needed +build_docker_image() { + echo "Building Docker test image..." + cd "$PROJECT_ROOT" + docker build -t "$DOCKER_IMAGE" -f docker/Dockerfile.test . +} + +# Run tests inside Docker container +run_tests_in_docker() { + echo "=== Running tests in Docker ===" + + # Build the image first + build_docker_image + + local docker_args=( + "--rm" + "-v" "$PROJECT_ROOT:/workspace" + "-w" "/workspace" + "-e" "CARGO_HOME=/workspace/.cargo-docker" + ) + + # Set up environment for coverage + if [ "$COVERAGE" = true ]; then + docker_args+=("-e" "RUSTFLAGS=-C instrument-coverage") + fi + + # The entrypoint script handles starting osqueryd and running tests + local test_script=' +set -e + +# Set up paths - use standard /var/osquery path that extensions default to +CI_DIR="/var/osquery" +mkdir -p "$CI_DIR"/{extensions,db,logs} +chmod 777 "$CI_DIR" "$CI_DIR/extensions" "$CI_DIR/db" "$CI_DIR/logs" + +SOCKET_PATH="$CI_DIR/osquery.em" +EXTENSIONS_DIR="$CI_DIR/extensions" +DB_PATH="$CI_DIR/db" +LOGGER_FILE="$CI_DIR/logs/file_logger.log" +CONFIG_MARKER="$CI_DIR/logs/config_marker.txt" + +# Set environment for logger and config plugins +export FILE_LOGGER_PATH="$LOGGER_FILE" +export CONFIG_MARKER_PATH="$CONFIG_MARKER" + +# Copy pre-built extensions from image +cp /opt/osquery/extensions/logger-file.ext "$EXTENSIONS_DIR/" +cp /opt/osquery/extensions/config-static.ext "$EXTENSIONS_DIR/" +chmod +x "$EXTENSIONS_DIR"/*.ext + +# Create extensions.load +cat > "$CI_DIR/extensions.load" << EXTEOF +$EXTENSIONS_DIR/logger-file.ext +$EXTENSIONS_DIR/config-static.ext +EXTEOF + +echo "Starting osqueryd..." +/opt/osquery/bin/osqueryd \ + --ephemeral \ + --force \ + --disable_watchdog \ + --disable_extensions=false \ + --extensions_socket="$SOCKET_PATH" \ + --extensions_autoload="$CI_DIR/extensions.load" \ + --extensions_timeout=30 \ + --extensions_interval=1 \ + --database_path="$DB_PATH" \ + --config_plugin=static_config \ + --logger_plugin=file_logger \ + --verbose \ + 2>&1 | tee "$CI_DIR/osqueryd.log" & +OSQUERY_PID=$! + +# Wait for socket +echo "Waiting for osquery socket..." +for i in {1..30}; do + if [ -S "$SOCKET_PATH" ]; then + echo "Socket ready" + break + fi + if [ "$i" -eq 30 ]; then + echo "ERROR: Socket not ready" + cat "$CI_DIR/osqueryd.log" + exit 1 + fi + sleep 1 +done + +# Wait for extensions (check osqueryd log for registration messages) +echo "Waiting for extensions..." +for i in {1..30}; do + LOGGER_READY=$(grep -c "registered logger plugin file_logger" "$CI_DIR/osqueryd.log" 2>/dev/null || echo 0) + CONFIG_READY=$(grep -c "registered config plugin static_config" "$CI_DIR/osqueryd.log" 2>/dev/null || echo 0) + + if [ "$LOGGER_READY" -ge 1 ] && [ "$CONFIG_READY" -ge 1 ]; then + echo "Extensions registered" + break + fi + + if [ "$i" -eq 30 ]; then + echo "ERROR: Extensions not registered" + cat "$CI_DIR/osqueryd.log" + exit 1 + fi + sleep 1 +done + +# Wait for first snapshot +echo "Waiting for first scheduled query..." +for i in {1..15}; do + if [ -f "$LOGGER_FILE" ] && grep -q "SNAPSHOT" "$LOGGER_FILE" 2>/dev/null; then + echo "First snapshot logged" + break + fi + if [ "$i" -eq 15 ]; then + echo "Warning: No snapshot after 15s" + fi + sleep 1 +done + +# Export for tests +export OSQUERY_SOCKET="$SOCKET_PATH" +export TEST_LOGGER_FILE="$LOGGER_FILE" +export TEST_CONFIG_MARKER_FILE="$CONFIG_MARKER" + +echo "" +echo "=== Running tests ===" +echo "OSQUERY_SOCKET=$OSQUERY_SOCKET" +echo "TEST_LOGGER_FILE=$TEST_LOGGER_FILE" +echo "TEST_CONFIG_MARKER_FILE=$TEST_CONFIG_MARKER_FILE" +echo "" +' + + if [ "$COVERAGE" = true ]; then + if [ "$HTML" = true ]; then + test_script+='cargo llvm-cov --all-features --workspace --html --ignore-filename-regex "_osquery"' + else + test_script+='cargo llvm-cov --all-features --workspace --lcov --output-path lcov.info --ignore-filename-regex "_osquery"' + fi + else + test_script+='cargo test --all-features --workspace' + fi + + test_script+=' +RESULT=$? + +# Cleanup - use pkill since tee pipe makes $! capture tee PID, not osqueryd +pkill -f "osqueryd.*extensions_socket" 2>/dev/null || true +kill $OSQUERY_PID 2>/dev/null || true +sleep 1 +exit $RESULT +' + + docker run "${docker_args[@]}" "$DOCKER_IMAGE" /bin/bash -c "$test_script" + + # Copy coverage output if generated + if [ "$COVERAGE" = true ] && [ -f "$PROJECT_ROOT/lcov.info" ]; then + # Calculate coverage percentage (cross-platform: use awk instead of paste) + if [ -f "$PROJECT_ROOT/lcov.info" ]; then + LINES_HIT=$(grep -E "^LH:" "$PROJECT_ROOT/lcov.info" | cut -d: -f2 | awk '{sum+=$1} END {print sum}' 2>/dev/null || echo 0) + LINES_FOUND=$(grep -E "^LF:" "$PROJECT_ROOT/lcov.info" | cut -d: -f2 | awk '{sum+=$1} END {print sum}' 2>/dev/null || echo 1) + if [ -n "$LINES_HIT" ] && [ -n "$LINES_FOUND" ] && [ "$LINES_FOUND" -gt 0 ]; then + COVERAGE_PCT=$(awk "BEGIN {printf \"%.1f\", $LINES_HIT * 100 / $LINES_FOUND}") + else + COVERAGE_PCT="0" + fi + echo "Coverage: $COVERAGE_PCT%" + echo "coverage=$COVERAGE_PCT" >> "${GITHUB_OUTPUT:-/dev/null}" + fi + fi +} + +# ========== MAIN ========== + +OSQUERYD_PATH="" +if OSQUERYD_PATH=$(find_osqueryd); then + echo "Found osqueryd at: $OSQUERYD_PATH" +else + echo "osqueryd not found in PATH or common locations" + + if has_docker; then + echo "Docker available - will run tests in Docker container" + run_tests_in_docker + exit 0 + else + echo "ERROR: Neither osqueryd nor Docker available" + echo "" + echo "To run tests, either:" + echo " 1. Install osquery: https://osquery.io/downloads" + echo " 2. Install Docker and run: ./scripts/ci-test.sh" + exit 1 + fi +fi + +echo "=== Setting up CI test environment ===" + +# Create CI directory structure +mkdir -p "$CI_DIR"/{extensions,db,logs} +chmod 777 "$CI_DIR" "$CI_DIR/extensions" "$CI_DIR/db" "$CI_DIR/logs" + +SOCKET_PATH="$CI_DIR/osquery.em" +EXTENSIONS_DIR="$CI_DIR/extensions" +DB_PATH="$CI_DIR/db" +LOGGER_FILE="$CI_DIR/logs/file_logger.log" +CONFIG_MARKER="$CI_DIR/logs/config_marker.txt" + +cd "$PROJECT_ROOT" + +# Set environment variables for extensions BEFORE building +export FILE_LOGGER_PATH="$LOGGER_FILE" +export CONFIG_MARKER_PATH="$CONFIG_MARKER" + +echo "Building extensions..." +cargo build --workspace 2>&1 | tail -5 + +# Copy extensions to autoload directory with .ext suffix +echo "Setting up extension autoload..." +if [ -f target/debug/logger-file ]; then + cp target/debug/logger-file "$EXTENSIONS_DIR/logger-file.ext" +else + cp target/release/logger-file "$EXTENSIONS_DIR/logger-file.ext" +fi +if [ -f target/debug/config_static ]; then + cp target/debug/config_static "$EXTENSIONS_DIR/config-static.ext" +else + cp target/release/config_static "$EXTENSIONS_DIR/config-static.ext" +fi +chmod +x "$EXTENSIONS_DIR"/*.ext + +# Create extensions.load file +cat > "$CI_DIR/extensions.load" << EOF +$EXTENSIONS_DIR/logger-file.ext +$EXTENSIONS_DIR/config-static.ext +EOF + +echo "Extensions configured:" +cat "$CI_DIR/extensions.load" + +echo "Starting osqueryd..." +# Start osqueryd with extension autoloading +$OSQUERYD_PATH \ + --ephemeral \ + --force \ + --disable_watchdog \ + --disable_extensions=false \ + --extensions_socket="$SOCKET_PATH" \ + --extensions_autoload="$CI_DIR/extensions.load" \ + --extensions_timeout=30 \ + --extensions_interval=1 \ + --database_path="$DB_PATH" \ + --config_plugin=static_config \ + --logger_plugin=file_logger \ + --verbose \ + 2>&1 | tee "$CI_DIR/osqueryd.log" & +OSQUERY_PID=$! + +echo "osqueryd PID: $OSQUERY_PID" + +# Wait for socket with timeout +echo "Waiting for osquery socket..." +for i in {1..30}; do + if [ -S "$SOCKET_PATH" ]; then + echo "Socket ready at $SOCKET_PATH" + break + fi + if [ "$i" -eq 30 ]; then + echo "ERROR: Socket not ready after 30s" + echo "osqueryd log:" + cat "$CI_DIR/osqueryd.log" + exit 1 + fi + sleep 1 +done + +# Wait for extensions to register (check osqueryd log for registration messages) +echo "Waiting for extensions to register..." +for i in {1..30}; do + # Check osqueryd log for extension registration messages + LOGGER_READY=$(grep -c "registered logger plugin file_logger" "$CI_DIR/osqueryd.log" 2>/dev/null || echo 0) + CONFIG_READY=$(grep -c "registered config plugin static_config" "$CI_DIR/osqueryd.log" 2>/dev/null || echo 0) + + if [ "$LOGGER_READY" -ge 1 ] && [ "$CONFIG_READY" -ge 1 ]; then + echo "Extensions registered successfully" + break + fi + + if [ "$i" -eq 30 ]; then + echo "ERROR: Extensions not registered after 30s" + echo "osqueryd log:" + cat "$CI_DIR/osqueryd.log" + exit 1 + fi + sleep 1 +done + +# Wait for first scheduled query to run (generates snapshots) +echo "Waiting for first scheduled query..." +for i in {1..15}; do + if [ -f "$LOGGER_FILE" ] && grep -q "SNAPSHOT" "$LOGGER_FILE" 2>/dev/null; then + echo "First snapshot logged" + break + fi + if [ "$i" -eq 15 ]; then + echo "Warning: No snapshot after 15s, continuing anyway" + fi + sleep 1 +done + +# Show what was logged +echo "Logger file contents:" +cat "$LOGGER_FILE" 2>/dev/null || echo "(empty)" + +echo "Config marker contents:" +cat "$CONFIG_MARKER" 2>/dev/null || echo "(empty)" + +# Export for tests +export OSQUERY_SOCKET="$SOCKET_PATH" +export TEST_LOGGER_FILE="$LOGGER_FILE" +export TEST_CONFIG_MARKER_FILE="$CONFIG_MARKER" + +echo "" +echo "=== Running tests ===" +echo "OSQUERY_SOCKET=$OSQUERY_SOCKET" +echo "TEST_LOGGER_FILE=$TEST_LOGGER_FILE" +echo "TEST_CONFIG_MARKER_FILE=$TEST_CONFIG_MARKER_FILE" +echo "" + +cd "$PROJECT_ROOT" +if [ "$COVERAGE" = true ]; then + if [ "$HTML" = true ]; then + cargo llvm-cov --all-features --workspace --html \ + --ignore-filename-regex "_osquery" + echo "HTML report: target/llvm-cov/html/index.html" + else + cargo llvm-cov --all-features --workspace --lcov \ + --output-path lcov.info --ignore-filename-regex "_osquery" + + # Calculate and display coverage (cross-platform: use awk instead of paste/bc) + if [ -f lcov.info ]; then + LINES_HIT=$(grep -E "^LH:" lcov.info | cut -d: -f2 | awk '{sum+=$1} END {print sum}') + LINES_FOUND=$(grep -E "^LF:" lcov.info | cut -d: -f2 | awk '{sum+=$1} END {print sum}') + if [ -n "$LINES_HIT" ] && [ -n "$LINES_FOUND" ] && [ "$LINES_FOUND" -gt 0 ]; then + COVERAGE_PCT=$(awk "BEGIN {printf \"%.1f\", $LINES_HIT * 100 / $LINES_FOUND}") + else + COVERAGE_PCT="0" + fi + echo "Coverage: $COVERAGE_PCT%" + # Output for GitHub Actions + echo "coverage=$COVERAGE_PCT" >> "${GITHUB_OUTPUT:-/dev/null}" + fi + fi +else + cargo test --all-features --workspace +fi + +echo "" +echo "=== Tests completed successfully ===" diff --git a/scripts/coverage.sh b/scripts/coverage.sh new file mode 100755 index 0000000..b097616 --- /dev/null +++ b/scripts/coverage.sh @@ -0,0 +1,302 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Coverage script with osquery for integration tests and examples +# Usage: ./scripts/coverage.sh [--html] [--examples-only] +# +# This script mirrors the pre-commit hook workflow, running all tests including +# the autoloaded logger integration test. +# +# Options: +# --html Generate HTML coverage report +# --examples-only Only test examples, skip coverage +# +# Platform handling: +# - Uses local osqueryd if available (required for autoload tests) +# - Falls back to Docker on amd64 only (osquery image is amd64-only) + +OSQUERY_IMAGE="osquery/osquery:5.17.0-ubuntu22.04" +SOCKET_DIR="/tmp/osquery-coverage-$$" +CONTAINER_NAME="osquery-coverage-$$" +OSQUERY_PID="" +USE_DOCKER=false +EXAMPLES_ONLY=false +HTML_REPORT=false + +# Parse arguments +for arg in "$@"; do + case $arg in + --html) + HTML_REPORT=true + ;; + --examples-only) + EXAMPLES_ONLY=true + ;; + esac +done + +# Test a table plugin example - load extension and query the table +# Args: $1=binary_name $2=table_name +test_table_example() { + local binary="$1" + local table="$2" + + echo -n " $binary ($table)... " + + # Retry up to 3 times (race condition between extension load and query) + for attempt in 1 2 3; do + local output + output=$(osqueryi --extension "./target/debug/$binary" \ + --line "SELECT * FROM $table LIMIT 1;" 2>&1) + + # Check for success (has output and no "no such table" error) + if [ -n "$output" ] && ! echo "$output" | grep -q "no such table"; then + echo "OK" + return 0 + fi + sleep 1 + done + + echo "FAILED" + return 1 +} + +# Test a config/logger plugin example - verify it registers +# Args: $1=binary_name $2=expected_extension_name +test_plugin_example() { + local binary="$1" + local expected_name="$2" + + echo -n " $binary ($expected_name)... " + + for attempt in 1 2 3; do + local output + output=$(osqueryi --extension "./target/debug/$binary" \ + --line "SELECT name FROM osquery_extensions WHERE name = '$expected_name';" 2>&1) + + if echo "$output" | grep -q "$expected_name"; then + echo "OK" + return 0 + fi + sleep 1 + done + + echo "FAILED" + return 1 +} + +# Test all examples that work on the current platform +test_examples() { + echo "Testing example extensions..." + + local failed=0 + local platform + platform=$(uname -s) + + # Build examples first + echo " Building workspace..." + if ! cargo build --workspace 2>/dev/null; then + echo " FAILED to build workspace" + return 1 + fi + echo " Build complete." + + # Table plugins - query actual tables + test_table_example "two-tables" "t1" || ((failed++)) + test_table_example "writeable-table" "writeable_table" || ((failed++)) + + # Config plugins - verify registration + test_plugin_example "config_static" "static_config" || ((failed++)) + test_plugin_example "config_file" "file_config" || ((failed++)) + + # Logger plugins - verify registration + test_plugin_example "logger-file" "file_logger" || ((failed++)) + test_plugin_example "logger-syslog" "syslog_logger" || ((failed++)) + + # Linux-only: table-proc-meminfo (reads /proc/meminfo) + if [ "$platform" = "Linux" ]; then + test_table_example "table-proc-meminfo" "proc_meminfo" || ((failed++)) + else + echo " Skipping table-proc-meminfo (Linux only)" + fi + + if [ "$failed" -gt 0 ]; then + echo "Example tests: $failed failed" + return 1 + fi + + echo "Example tests: all passed" + return 0 +} + +cleanup() { + # Suppress "Terminated" messages from killed background jobs + set +e + if [ "$USE_DOCKER" = true ]; then + docker stop "$CONTAINER_NAME" 2>/dev/null + docker rm "$CONTAINER_NAME" 2>/dev/null + elif [ -n "$OSQUERY_PID" ]; then + kill "$OSQUERY_PID" 2>/dev/null + wait "$OSQUERY_PID" 2>/dev/null + fi + # Kill any extension processes + pkill -f "logger-file.*$SOCKET_DIR" 2>/dev/null || true + rm -rf "$SOCKET_DIR" 2>/dev/null + set -e +} + +trap cleanup EXIT + +# Find osqueryd - check common locations (macOS app bundle, Linux, PATH) +find_osqueryd() { + if command -v osqueryd &> /dev/null; then + echo "osqueryd" + elif [ -x "/opt/osquery/lib/osquery.app/Contents/MacOS/osqueryd" ]; then + # macOS: osqueryd is inside the app bundle + echo "/opt/osquery/lib/osquery.app/Contents/MacOS/osqueryd" + else + echo "" + fi +} + +# Check osquery availability early +if ! command -v osqueryi &> /dev/null && ! command -v docker &> /dev/null; then + echo "ERROR: Neither osquery nor Docker is available" + echo "Install osquery: brew install osquery (macOS) or see https://osquery.io/downloads" + exit 1 +fi + +# Test examples FIRST (before starting background osquery) +# Example tests use osqueryi --extension which creates fresh osqueryi instances +test_examples + +# If --examples-only, skip coverage +if [ "$EXAMPLES_ONLY" = true ]; then + echo "Examples tested. Skipping coverage (--examples-only)." + exit 0 +fi + +# Start fresh for coverage tests +cleanup +mkdir -p "$SOCKET_DIR" + +# Find osqueryd for daemon mode (required for autoload tests) +OSQUERYD=$(find_osqueryd) + +# Start background osqueryd for integration tests (daemon mode with autoload) +if [ -n "$OSQUERYD" ]; then + echo "Using local osqueryd (daemon mode)..." + + SOCKET_PATH="$SOCKET_DIR/osquery.em" + DB_PATH="$SOCKET_DIR/osquery.db" + LOG_PATH="$SOCKET_DIR/logs" + AUTOLOAD_PATH="$SOCKET_DIR/autoload" + TEST_LOG_FILE="$SOCKET_DIR/test_logger.log" + + # Create directories + mkdir -p "$LOG_PATH" "$AUTOLOAD_PATH" + + # Build the logger-file extension for autoload testing + echo "Building logger-file extension for autoload..." + cargo build -p logger-file --quiet + + # Get absolute path to the extension binary + EXTENSION_BIN="$(pwd)/target/debug/logger-file" + if [ ! -f "$EXTENSION_BIN" ]; then + echo "ERROR: Extension binary not found at $EXTENSION_BIN" + exit 1 + fi + + # osquery requires extensions to end in .ext for autoload + EXTENSION_PATH="$AUTOLOAD_PATH/logger-file.ext" + ln -sf "$EXTENSION_BIN" "$EXTENSION_PATH" + + # Create autoload configuration (just the path - osquery adds --socket automatically) + echo "$EXTENSION_PATH" > "$AUTOLOAD_PATH/extensions.load" + + # Set the log file path via environment variable (the extension reads FILE_LOGGER_PATH) + export FILE_LOGGER_PATH="$TEST_LOG_FILE" + + # Start osqueryd in ephemeral mode with autoload and file_logger plugin + "$OSQUERYD" \ + --ephemeral \ + --disable_extensions=false \ + --extensions_socket="$SOCKET_PATH" \ + --extensions_autoload="$AUTOLOAD_PATH/extensions.load" \ + --extensions_timeout=30 \ + --database_path="$DB_PATH" \ + --logger_plugin=filesystem,file_logger \ + --logger_path="$LOG_PATH" \ + --config_path=/dev/null \ + --disable_watchdog \ + --force & + OSQUERY_PID=$! + + # Export for integration tests + export OSQUERY_SOCKET="$SOCKET_PATH" + export TEST_LOGGER_FILE="$TEST_LOG_FILE" + +# Fall back to osqueryi if osqueryd not available (limited functionality) +elif command -v osqueryi &> /dev/null; then + echo "WARNING: osqueryd not found, using osqueryi (autoload test will fail)" + echo "Install osquery daemon for full test coverage" + + # Start osqueryi with extensions enabled + ( + while true; do sleep 60; done | osqueryi \ + --nodisable_extensions \ + --extensions_socket="$SOCKET_DIR/osquery.em" \ + 2>/dev/null + ) & + OSQUERY_PID=$! + export OSQUERY_SOCKET="$SOCKET_DIR/osquery.em" + +# Fall back to Docker only on amd64 (osquery image is amd64-only) +elif command -v docker &> /dev/null; then + ARCH=$(uname -m) + if [ "$ARCH" = "x86_64" ] || [ "$ARCH" = "amd64" ]; then + echo "Using Docker (osquery not installed locally)..." + USE_DOCKER=true + + docker run -d --name "$CONTAINER_NAME" \ + -v "$SOCKET_DIR:/var/osquery" \ + "$OSQUERY_IMAGE" \ + osqueryd --ephemeral --disable_extensions=false \ + --extensions_socket=/var/osquery/osquery.em + + export OSQUERY_SOCKET="$SOCKET_DIR/osquery.em" + else + echo "ERROR: osquery not installed and Docker image only supports amd64" + echo "Install osquery: brew install osquery" + exit 1 + fi +fi + +# Wait for socket (30s timeout) +echo "Waiting for osquery socket..." +for i in {1..30}; do + if [ -S "$OSQUERY_SOCKET" ]; then + echo "Socket ready" + break + fi + sleep 1 +done + +if [ ! -S "$OSQUERY_SOCKET" ]; then + echo "ERROR: osquery socket not found after 30s" + if [ "$USE_DOCKER" = true ]; then + docker logs "$CONTAINER_NAME" + fi + exit 1 +fi + +# Give extension time to register with osquery +sleep 2 + +echo "Running coverage..." +if [ "$HTML_REPORT" = true ]; then + cargo llvm-cov --all-features --workspace --html --ignore-filename-regex "_osquery" + echo "HTML report: target/llvm-cov/html/index.html" +else + cargo llvm-cov --all-features --workspace --ignore-filename-regex "_osquery" +fi