diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index da6e40b..28198d2 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -8,13 +8,19 @@ "extensions": [ "denoland.vscode-deno", "doppler.doppler-vscode", - "ms-azuretools.vscode-docker" + "ms-azuretools.vscode-docker", + "rust-lang.rust-analyzer", + "tamasfe.even-better-toml" ] } }, "features": { "ghcr.io/itsmechlark/features/doppler:2": { "version": "latest" + }, + "ghcr.io/devcontainers/features/rust:1": { + "version": "latest", + "profile": "default" } }, "portsAttributes": { diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index dcdf523..217dc95 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -6,6 +6,7 @@ on: - "v[0-9]+.[0-9]+.[0-9]+*" permissions: + contents: write packages: write env: @@ -79,3 +80,64 @@ jobs: chart: ./helm version: ${{ steps.version.outputs.full }} appVersion: ${{ steps.version.outputs.full }} + + rust-cli-build: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - uses: nowsprinting/check-version-format-action@v3 + id: version + with: + prefix: "v" + + - name: Install Rust toolchain + uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: stable + override: true + + - name: Build Rust CLI + working-directory: ./cli + run: cargo build --release + + - name: Upload Rust CLI binary + uses: actions/upload-artifact@v4 + with: + name: keys-cli-${{ steps.version.outputs.full }} + path: cli/target/release/keys + retention-days: 30 + + github-release: + needs: + - docker-publish + - helm-publish + - rust-cli-build + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - uses: nowsprinting/check-version-format-action@v3 + id: version + with: + prefix: "v" + + - name: Download Rust CLI binary + uses: actions/download-artifact@v4 + with: + name: keys-cli-${{ steps.version.outputs.full }} + path: ./artifacts + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.version.outputs.full }} + name: Release ${{ steps.version.outputs.full }} + generate_release_notes: true + files: | + ./artifacts/keys diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 305057d..cf8bda0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -43,3 +43,49 @@ jobs: uses: codecov/codecov-action@v5 env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + with: + flags: deno-server + + test-rust-cli: + runs-on: ubuntu-latest + + steps: + - name: Setup repo + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: stable + override: true + components: rustfmt, clippy + + - name: Install cargo-llvm-cov + uses: taiki-e/install-action@v2 + with: + tool: cargo-llvm-cov + + - name: Check Rust formatting + working-directory: ./cli + run: cargo fmt --all -- --check + + - name: Lint with clippy + working-directory: ./cli + run: cargo clippy --all-targets -- -D warnings + + - name: Build Rust CLI + working-directory: ./cli + run: cargo build + + - name: Run Rust tests with coverage + working-directory: ./cli + run: cargo llvm-cov --lcov --output-path lcov.info + + - name: Upload Rust coverage to Codecov + uses: codecov/codecov-action@v5 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + with: + files: ./cli/lcov.info + flags: rust-cli diff --git a/README.md b/README.md index 55edca7..a02d8c8 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,8 @@ machine. This is currently done with manual curl commands, and not typically automated with a CRON due to the risk of losing access to machines as a result of the service being down or misconfigured. -In the future ([#24](https://github.com/danielemery/keys/issues/24)) a cli tool -will be provided to safely manage the `authorized_keys` file with guards in -place to prevent loss of access. +A CLI tool is now available to interact with the keys server. See the +[CLI README](/cli/README.md) for more information on installation and usage. ### Get all listed keys diff --git a/cli/.gitignore b/cli/.gitignore new file mode 100644 index 0000000..eccd7b4 --- /dev/null +++ b/cli/.gitignore @@ -0,0 +1,2 @@ +/target/ +**/*.rs.bk diff --git a/cli/Cargo.lock b/cli/Cargo.lock new file mode 100644 index 0000000..ddfa980 --- /dev/null +++ b/cli/Cargo.lock @@ -0,0 +1,2159 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "0.6.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "301af1932e46185686725e0fad2f8f2aa7da69dd70bf6ecc44d6b703844a3933" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.59.0", +] + +[[package]] +name = "anyhow" +version = "1.0.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi", + "libc", + "winapi", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "backtrace" +version = "0.3.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets 0.52.6", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "cc" +version = "1.2.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "deec109607ca693028562ed836a5f1c4b8bd77755c4e132fc5ce11b0b6211ae7" +dependencies = [ + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" + +[[package]] +name = "clap" +version = "4.5.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be92d32e80243a54711e5d7ce823c35c41c9d929dc4ab58e1276f625841aadf9" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707eab41e9622f9139419d573eca0900137718000c517d47da73045f54331c3d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef4f52386a59ca4c860f7393bcf8abd8dfd91ecccc0f774635ff68e92eeef491" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "colored" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" +dependencies = [ + "lazy_static", + "windows-sys 0.59.0", +] + +[[package]] +name = "colored" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fde0e0ec90c9dfb3b4b1a0891a7dcd0e2bffde2f7efed5fe7c9bb00e5bfb915e" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "directories" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" +dependencies = [ + "dirs-sys 0.4.1", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.4.6", + "windows-sys 0.48.0", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.0", + "windows-sys 0.60.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-io", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasi 0.14.2+wasi-0.2.4", +] + +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "h2" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17da50a276f1e01e0ba6c029e47b7100754904ee8a278f886546e98575380785" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.3.1", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" + +[[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.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.3.1", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http 1.3.1", + "http-body 1.0.1", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "h2 0.4.11", + "http 1.3.1", + "http-body 1.0.1", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper 0.14.32", + "native-tls", + "tokio", + "tokio-native-tls", +] + +[[package]] +name = "hyper-util" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66d5bd4c6f02bf0542fad85d626775bab9258cf795a4256dcaf3161114d1df" +dependencies = [ + "bytes", + "futures-core", + "http 1.3.1", + "http-body 1.0.1", + "hyper 1.6.0", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "icu_collections" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" + +[[package]] +name = "icu_properties" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "potential_utf", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" + +[[package]] +name = "icu_provider" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +dependencies = [ + "displaydoc", + "icu_locale_core", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "io-uring" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4" +dependencies = [ + "bitflags 2.9.1", + "cfg-if", + "libc", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[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 = "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 = "keys" +version = "0.0.0" +dependencies = [ + "anyhow", + "atty", + "clap", + "colored 2.2.0", + "directories", + "mockito", + "reqwest", + "serde", + "serde_json", + "shellexpand", + "tempfile", + "toml", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.174" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" + +[[package]] +name = "libredox" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4488594b9328dee448adb906d8b126d9b7deb7cf5c22161ee591610bb1be83c0" +dependencies = [ + "bitflags 2.9.1", + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + +[[package]] +name = "litemap" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" + +[[package]] +name = "lock_api" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "memchr" +version = "2.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.59.0", +] + +[[package]] +name = "mockito" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7760e0e418d9b7e5777c0374009ca4c93861b9066f18cb334a20ce50ab63aa48" +dependencies = [ + "assert-json-diff", + "bytes", + "colored 3.0.0", + "futures-util", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", + "hyper 1.6.0", + "hyper-util", + "log", + "rand", + "regex", + "serde_json", + "serde_urlencoded", + "similar", + "tokio", +] + +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" + +[[package]] +name = "openssl" +version = "0.10.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" +dependencies = [ + "bitflags 2.9.1", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "parking_lot" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets 0.52.6", +] + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "potential_utf" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[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 = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.3", +] + +[[package]] +name = "redox_syscall" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e8af0dde094006011e6a740d4879319439489813bd0bcdc7d821beaeeff48ec" +dependencies = [ + "bitflags 2.9.1", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "redox_users" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 2.0.12", +] + +[[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 = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper-tls", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "system-configuration", + "tokio", + "tokio-native-tls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "winreg", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f" + +[[package]] +name = "rustix" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" +dependencies = [ + "bitflags 2.9.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.60.2", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64", +] + +[[package]] +name = "rustversion" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "schannel" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.9.1", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.141" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30b9eff21ebe718216c6ec64e1d9ac57087aad11efc64e32002bce4a0d4c03d3" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "shellexpand" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb" +dependencies = [ + "dirs", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + +[[package]] +name = "slab" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04dc19736151f35336d325007ac991178d504a119863a2fcb3758cdb5e52c50d" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" +dependencies = [ + "fastrand", + "getrandom 0.3.3", + "once_cell", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +dependencies = [ + "thiserror-impl 2.0.12", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.46.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc3a2344dafbe23a245241fe8b09735b521110d30fcefbbd5feb1797ca35d17" +dependencies = [ + "backtrace", + "bytes", + "io-uring", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "slab", + "socket2", + "windows-sys 0.52.0", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "url" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasi" +version = "0.14.2+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] + +[[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-futures" +version = "0.4.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[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 = "web-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[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-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c66f69fcc9ce11da9966ddb31a40968cad001c5bedeb5c2b82ede4253ab48aef" +dependencies = [ + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + +[[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_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" + +[[package]] +name = "winnow" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3edebf492c8125044983378ecb5766203ad3b4c2f7a922bd7dd207f6d443e95" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags 2.9.1", +] + +[[package]] +name = "writeable" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" + +[[package]] +name = "yoke" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a05eb080e015ba39cc9e23bbe5e7fb04d5fb040350f99f34e338d5fdd294428" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/cli/Cargo.toml b/cli/Cargo.toml new file mode 100644 index 0000000..de578d5 --- /dev/null +++ b/cli/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "keys" +version = "0.0.0" # 0.0.0 is a placeholder, before publish it should be set to the current git tag +edition = "2024" +description = "CLI client for the keys server" + +[dependencies] +clap = { version = "4.5.1", features = ["derive"] } +reqwest = { version = "0.11", features = ["json", "blocking"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +anyhow = "1.0" +colored = "2.0" +atty = "0.2" +toml = "0.8.8" +directories = "5.0.1" +shellexpand = "3.1.0" + +[dev-dependencies] +mockito = "1.2.0" +tempfile = "3.8.0" diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 0000000..d3b6925 --- /dev/null +++ b/cli/README.md @@ -0,0 +1,159 @@ +# Keys CLI + +A command-line interface for interacting with the keys server. + +## Features + +- Fetch and display keys from a keys server +- Raw mode for scripting and automation +- Safely update `authorized_keys` files without risk of losing ssh access +- TODO: Filter keys by user or tag (exlusions or inclusions) +- TODO: Update `known_hosts` files + +## Usage + +```bash +# Fetch SSH keys using default server from config or localhost +keys ssh + +# Fetch SSH keys with explicit server +keys --server http://localhost:8000 ssh + +# Fetch PGP keys +keys pgp + +# Fetch Known hosts +keys known-hosts + +# Display help for the whole CLI +keys --help + +# Display help for a specific subcommand +keys ssh --help +``` + +## Safely Updating authorized_keys + +The CLI can safely update your SSH `authorized_keys` file with keys from the +server: + +```bash +# Only add new keys from server, preserving existing keys +keys ssh --write ~/.ssh/authorized_keys + +# Replace all keys with the server's keys +keys ssh --write ~/.ssh/authorized_keys --force +``` + +By default (without `--force`), the CLI will: + +1. Preserve all existing keys in the file +2. Add any new keys from the server +3. Never remove keys that are in the file but not on the server + +This is designed to be safe for automation (e.g., in a cron job) as it won't +lock you out of your server if the keys server is down or returns incomplete +results. + +When `--force` is used, the file will be completely replaced with the keys from +the server. + +## Configuration + +The CLI supports reading configuration from a TOML file. By default, it looks +for configuration in: + +``` +~/.config/keys/config.toml # On Linux/macOS +%APPDATA%\keys\config.toml # On Windows +``` + +You can initialize a default config file using the `init` command: + +```bash +keys init +``` + +The configuration file format is: + +```toml +# Keys CLI Configuration + +# Server URL (default: http://localhost:8000) +server_url = "https://keys.example.com" +``` + +You can also specify a custom config file location: + +```bash +keys --config /path/to/config.toml ssh +``` + +Command-line options take precedence over configuration file settings. + +## Building + +```bash +cd cli +cargo build --release +``` + +The compiled binary will be available in `target/release/keys`. + +## Installation with Nix + +If you have Nix installed, you can build and install the CLI using the flake: + +```bash +# Build the CLI +nix build + +# Run directly without installing +nix run + +# Install to your profile +nix profile install . + +# Or install from GitHub +nix profile install github:danielemery/keys +``` + +### NixOS System Configuration + +To install the CLI system-wide on NixOS, add it as a flake input and include it +in your system packages: + +```nix +# flake.nix +{ + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + keys.url = "github:danielemery/keys"; + }; + + outputs = { self, nixpkgs, keys }: { + nixosConfigurations.your-hostname = nixpkgs.lib.nixosSystem { + system = "x86_64-linux"; + modules = [ + { + environment.systemPackages = [ + keys.packages.x86_64-linux.default + ]; + } + ]; + }; + }; +} +``` + +The Nix flake will build the Rust binary and make it available as the `keys` +command. + +## Development + +To test the CLI during development, you can run: + +```bash +# Run with the ssh subcommand +cargo run -- --server http://localhost:8000 ssh +``` diff --git a/cli/src/commands/known_hosts.rs b/cli/src/commands/known_hosts.rs new file mode 100644 index 0000000..7beb444 --- /dev/null +++ b/cli/src/commands/known_hosts.rs @@ -0,0 +1,790 @@ +use anyhow::{Context, Result}; +use atty; +use colored::Colorize; +use reqwest::header::ACCEPT; +use serde::Deserialize; + +use crate::utils::{ColumnConfig, pretty_print_table}; + +#[derive(Debug, Deserialize)] +pub struct KnownHostsResponse { + pub version: String, + #[serde(rename = "knownHosts")] + pub hosts: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct KnownHost { + pub name: Option, + pub hosts: Vec, + pub keys: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct HostKey { + #[serde(rename = "type")] + pub key_type: String, + pub key: String, + pub comment: Option, + pub revoked: Option, + #[serde(rename = "cert-authority")] + pub cert_authority: Option, +} + +/// Function to pretty print the known hosts with formatted columns and colors +pub fn pretty_print_known_hosts(response: &KnownHostsResponse) { + // Find the maximum width for name and hosts columns for better formatting + let max_name_len = response + .hosts + .iter() + .filter_map(|h| h.name.as_ref()) + .map(|name| name.len()) + .max() + .unwrap_or(4) + .max(4); + + let max_hosts_len = response + .hosts + .iter() + .map(|h| h.hosts.join(",").len()) + .max() + .unwrap_or(5) + .max(5); + + let max_type_len = response + .hosts + .iter() + .flat_map(|h| h.keys.iter()) + .map(|k| k.key_type.len()) + .max() + .unwrap_or(4) + .max(4); + + let max_comment_len = response + .hosts + .iter() + .flat_map(|h| h.keys.iter()) + .filter_map(|k| k.comment.as_ref()) + .map(|c| c.len()) + .max() + .unwrap_or(7) + .max(7); // "COMMENT" header is 7 chars + + // Define the columns + let columns = vec![ + ColumnConfig { + header: "NAME".to_string(), + color: |s| s.green(), + width: max_name_len, + }, + ColumnConfig { + header: "HOSTS".to_string(), + color: |s| s.cyan(), + width: max_hosts_len, + }, + ColumnConfig { + header: "TYPE".to_string(), + color: |s| s.blue(), + width: max_type_len, + }, + ColumnConfig { + header: "FLAGS".to_string(), + color: |s| s.yellow(), + width: 10, + }, + ColumnConfig { + header: "COMMENT".to_string(), + color: |s| s.magenta(), + width: max_comment_len, + }, + ColumnConfig { + header: "KEY".to_string(), + color: |s| s.red(), + width: 50, // Key is typically long, so use a reasonable default width + }, + ]; + + // Prepare the rows - flattening the nested structure + let mut rows: Vec> = Vec::new(); + + for host in &response.hosts { + let name = host.name.clone().unwrap_or_default(); + let hosts_str = host.hosts.join(","); + + for key in &host.keys { + // Create flags string based on boolean values + let mut flags = Vec::new(); + if key.revoked.unwrap_or(false) { + flags.push("REVOKED"); + } + if key.cert_authority.unwrap_or(false) { + flags.push("CA"); + } + let flags_str = flags.join(","); + + // Get comment or empty string + let comment = key.comment.clone().unwrap_or_default(); + + rows.push(vec![ + name.clone(), + hosts_str.clone(), + key.key_type.clone(), + flags_str, + comment, + key.key.clone(), + ]); + } + } + + // Use the generic pretty print function + pretty_print_table( + "Known Hosts Server Version:", + &response.version, + columns, + rows, + "No known hosts found.", + ); +} + +/// Private function to fetch known hosts from the server +/// +/// This function handles the HTTP request to the known hosts server, +/// validates the response, and parses the JSON into a KnownHostsResponse. +/// +/// # Arguments +/// * `server_url` - The base URL of the keys server +/// +/// # Returns +/// * `Result` - The parsed known hosts response or an error +fn fetch_known_hosts_from_server(server_url: &str) -> Result { + let url = format!("{server_url}/known_hosts"); + + let client = reqwest::blocking::Client::new(); + let response = client + .get(&url) + .header(ACCEPT, "application/json") + .send() + .context("Failed to send request to known hosts server")?; + + let status = response.status(); + + if !status.is_success() { + return Err(anyhow::anyhow!( + "Server returned error code: {} - {}", + status.as_u16(), + status.canonical_reason().unwrap_or("Unknown") + )); + } + + response + .json::() + .context("Failed to parse JSON response") +} + +pub fn fetch_known_hosts(server_url: &str) -> Result<()> { + let known_hosts_response = fetch_known_hosts_from_server(server_url)?; + + // Check if the output is being piped (not connected to a terminal) + // Use raw/minimal output when piped to another command + if !atty::is(atty::Stream::Stdout) { + for host in &known_hosts_response.hosts { + for key in &host.keys { + let hosts_str = host.hosts.join(","); + let key_type = &key.key_type; + let key_value = &key.key; + + // Add flags if present + let mut flags = Vec::new(); + if key.revoked.unwrap_or(false) { + flags.push("@revoked"); + } + if key.cert_authority.unwrap_or(false) { + flags.push("@cert-authority"); + } + + // Format comment if present + let comment_str = if let Some(comment) = &key.comment { + format!(" # {comment}") + } else { + String::new() + }; + + // Output in OpenSSH known_hosts format with flags and optional comment + if flags.is_empty() { + println!("{hosts_str} {key_type} {key_value}{comment_str}"); + } else { + println!( + "{} {} {} {}{}", + flags.join(" "), + hosts_str, + key_type, + key_value, + comment_str + ); + } + } + } + return Ok(()); + } + + // Use the pretty print function for interactive terminal output + pretty_print_known_hosts(&known_hosts_response); + + Ok(()) +} + +/// Helper function to format a host entry in known_hosts format +fn format_known_hosts_line(host: &KnownHost, key: &HostKey) -> String { + let hosts_str = host.hosts.join(","); + let key_type = &key.key_type; + let key_value = &key.key; + + // Add flags if present + let mut flags = Vec::new(); + if key.revoked.unwrap_or(false) { + flags.push("@revoked"); + } + if key.cert_authority.unwrap_or(false) { + flags.push("@cert-authority"); + } + + // Format comment if present + let comment_str = if let Some(comment) = &key.comment { + format!(" # {comment}") + } else { + String::new() + }; + + // Output in OpenSSH known_hosts format with flags and optional comment + if flags.is_empty() { + format!("{hosts_str} {key_type} {key_value}{comment_str}") + } else { + format!( + "{} {} {} {}{}", + flags.join(" "), + hosts_str, + key_type, + key_value, + comment_str + ) + } +} + +pub fn write_known_hosts(server_url: &str, file_path: &str) -> Result<()> { + // Fetch known hosts from the server + let known_hosts_response = fetch_known_hosts_from_server(server_url)?; + + // Expand ~ to home directory if present + let expanded_path = shellexpand::tilde(file_path); + let path = std::path::Path::new(expanded_path.as_ref()); + + // Create directory if it doesn't exist + if let Some(parent) = path.parent() + && !parent.exists() + { + std::fs::create_dir_all(parent) + .with_context(|| format!("Failed to create parent directory: {}", parent.display()))?; + } + + // Generate the file content (always replace completely) + let mut lines = Vec::new(); + for host in &known_hosts_response.hosts { + for key in &host.keys { + lines.push(format_known_hosts_line(host, key)); + } + } + + let file_content = lines.join("\n"); + if !file_content.is_empty() { + let file_content = format!("{}\n", file_content); + std::fs::write(path, file_content) + .with_context(|| format!("Failed to write to file: {}", path.display()))?; + } else { + // Write empty file if no hosts + std::fs::write(path, "") + .with_context(|| format!("Failed to write to file: {}", path.display()))?; + } + + // Count the total number of entries written + let total_entries: usize = known_hosts_response + .hosts + .iter() + .map(|h| h.keys.len()) + .sum(); + + println!( + "✅ Wrote {} known host entries to {}", + total_entries, + path.display() + ); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use mockito; + + // Helper function to create a mock server + fn setup_mock_server(response_body: &str) -> (String, mockito::ServerGuard) { + let mut mock_server = mockito::Server::new(); + + mock_server + .mock("GET", "/known_hosts") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(response_body) + .create(); + + (mock_server.url(), mock_server) + } + + // Helper function to create a mock server with error response + fn setup_mock_server_with_error( + status_code: usize, + response_body: &str, + ) -> (String, mockito::ServerGuard) { + let mut mock_server = mockito::Server::new(); + + mock_server + .mock("GET", "/known_hosts") + .with_status(status_code) + .with_header("content-type", "application/json") + .with_body(response_body) + .create(); + + (mock_server.url(), mock_server) + } + + #[test] + fn test_fetch_known_hosts_success() { + // Setup mock server with known hosts response + let mock_response = r#" + { + "version": "1.0.0", + "knownHosts": [ + { + "name": "GitHub", + "hosts": ["github.com", "*.github.com"], + "keys": [ + { + "type": "ssh-rsa", + "key": "AAAAB3NzaC1yc2EAAAADAQABAAABgQC7", + "comment": "GitHub RSA key", + "revoked": false, + "cert-authority": false + } + ] + } + ] + } + "#; + + let (server_url, _server) = setup_mock_server(mock_response); + + // Call function + let result = fetch_known_hosts(&server_url); + assert!( + result.is_ok(), + "fetch_known_hosts failed: {:?}", + result.err() + ); + } + + #[test] + fn test_fetch_known_hosts_server_error() { + // Setup mock server with error response + let (server_url, _server) = + setup_mock_server_with_error(500, r#"{"error": "Internal server error"}"#); + + // Call function + let result = fetch_known_hosts(&server_url); + + // Should return an error + assert!(result.is_err()); + let error_msg = result.err().unwrap().to_string(); + assert!(error_msg.contains("Server returned error code: 500")); + } + + #[test] + fn test_fetch_known_hosts_malformed_response() { + // Setup mock server with malformed JSON + let (server_url, _server) = + setup_mock_server(r#"{"version": "1.0.0", "knownHosts": [{"incomplete": true}]}"#); + + // Call function + let result = fetch_known_hosts(&server_url); + + // Should return an error due to missing required fields + assert!(result.is_err()); + } + + #[test] + fn test_fetch_known_hosts_empty_response() { + // Setup mock server with empty known hosts array + let mock_response = r#" + { + "version": "1.0.0", + "knownHosts": [] + } + "#; + + let (server_url, _server) = setup_mock_server(mock_response); + + // Call function + let result = fetch_known_hosts(&server_url); + assert!( + result.is_ok(), + "fetch_known_hosts failed: {:?}", + result.err() + ); + } + + #[test] + fn test_fetch_known_hosts_multiple_hosts_and_keys() { + // Setup mock server with multiple hosts and keys + let mock_response = r#" + { + "version": "2.1.0", + "knownHosts": [ + { + "name": "GitHub", + "hosts": ["github.com", "*.github.com"], + "keys": [ + { + "type": "ssh-rsa", + "key": "AAAAB3NzaC1yc2EAAAADAQABAAABgQC7GitHub1", + "comment": "GitHub RSA key" + }, + { + "type": "ssh-ed25519", + "key": "AAAAC3NzaC1lZDI1NTE5AAAAIGitHub2", + "comment": "GitHub Ed25519 key" + } + ] + }, + { + "name": "GitLab", + "hosts": ["gitlab.com"], + "keys": [ + { + "type": "ssh-rsa", + "key": "AAAAB3NzaC1yc2EAAAADAQABAAABgQC7GitLab1", + "comment": "GitLab RSA key", + "revoked": true + } + ] + }, + { + "hosts": ["example.com"], + "keys": [ + { + "type": "ssh-ed25519", + "key": "AAAAC3NzaC1lZDI1NTE5AAAAIExample1", + "cert-authority": true + } + ] + } + ] + } + "#; + + let (server_url, _server) = setup_mock_server(mock_response); + + // Call function + let result = fetch_known_hosts(&server_url); + assert!( + result.is_ok(), + "fetch_known_hosts failed: {:?}", + result.err() + ); + } + + #[test] + fn test_fetch_known_hosts_with_flags() { + // Setup mock server with keys that have revoked and cert-authority flags + let mock_response = r#" + { + "version": "1.0.0", + "knownHosts": [ + { + "name": "Test Host", + "hosts": ["test.example.com"], + "keys": [ + { + "type": "ssh-rsa", + "key": "AAAAB3NzaC1yc2EAAAADAQABAAABgQC7Revoked", + "comment": "Revoked key", + "revoked": true, + "cert-authority": false + }, + { + "type": "ssh-ed25519", + "key": "AAAAC3NzaC1lZDI1NTE5AAAAICertAuth", + "comment": "CA key", + "revoked": false, + "cert-authority": true + }, + { + "type": "ssh-rsa", + "key": "AAAAB3NzaC1yc2EAAAADAQABAAABgQC7Both", + "comment": "Both flags", + "revoked": true, + "cert-authority": true + } + ] + } + ] + } + "#; + + let (server_url, _server) = setup_mock_server(mock_response); + + // Call function + let result = fetch_known_hosts(&server_url); + assert!( + result.is_ok(), + "fetch_known_hosts failed: {:?}", + result.err() + ); + } + + #[test] + fn test_pretty_print_known_hosts() { + // Create a test response with various host and key data + let known_hosts_response = KnownHostsResponse { + version: "1.0.0".to_string(), + hosts: vec![ + KnownHost { + name: Some("GitHub".to_string()), + hosts: vec!["github.com".to_string(), "*.github.com".to_string()], + keys: vec![ + HostKey { + key_type: "ssh-rsa".to_string(), + key: "AAAAB3NzaC1yc2EAAAADAQABAAABgQC7GitHub".to_string(), + comment: Some("GitHub RSA key".to_string()), + revoked: Some(false), + cert_authority: Some(false), + }, + HostKey { + key_type: "ssh-ed25519".to_string(), + key: "AAAAC3NzaC1lZDI1NTE5AAAAIGitHub2".to_string(), + comment: None, + revoked: None, + cert_authority: Some(true), + }, + ], + }, + KnownHost { + name: None, + hosts: vec!["example.com".to_string()], + keys: vec![HostKey { + key_type: "ssh-rsa".to_string(), + key: "AAAAB3NzaC1yc2EAAAADAQABAAABgQC7Example".to_string(), + comment: Some("Example key".to_string()), + revoked: Some(true), + cert_authority: Some(false), + }], + }, + ], + }; + + // This test primarily verifies the function doesn't panic and handles the data correctly + // Since pretty_print_known_hosts outputs to stdout, we can't easily capture and verify output + // in this test environment, but we can verify it completes without errors + pretty_print_known_hosts(&known_hosts_response); + } + + #[test] + fn test_pretty_print_known_hosts_empty() { + // Test with empty hosts list + let known_hosts_response = KnownHostsResponse { + version: "1.0.0".to_string(), + hosts: vec![], + }; + + // Should handle empty hosts gracefully + pretty_print_known_hosts(&known_hosts_response); + } + + #[test] + fn test_pretty_print_known_hosts_no_names() { + // Test with hosts that have no names + let known_hosts_response = KnownHostsResponse { + version: "1.0.0".to_string(), + hosts: vec![ + KnownHost { + name: None, + hosts: vec!["host1.example.com".to_string()], + keys: vec![HostKey { + key_type: "ssh-rsa".to_string(), + key: "AAAAB3NzaC1yc2EAAAADAQABAAABgQC7Host1".to_string(), + comment: None, + revoked: None, + cert_authority: None, + }], + }, + KnownHost { + name: None, + hosts: vec!["host2.example.com".to_string()], + keys: vec![HostKey { + key_type: "ssh-ed25519".to_string(), + key: "AAAAC3NzaC1lZDI1NTE5AAAAIHost2".to_string(), + comment: None, + revoked: None, + cert_authority: None, + }], + }, + ], + }; + + // Should handle missing names gracefully + pretty_print_known_hosts(&known_hosts_response); + } + + #[test] + fn test_pretty_print_known_hosts_long_data() { + // Test with very long host names, comments, and multiple hosts per entry + let known_hosts_response = KnownHostsResponse { + version: "1.0.0".to_string(), + hosts: vec![ + KnownHost { + name: Some("Very Long Service Name That Should Test Column Width Calculations".to_string()), + hosts: vec![ + "very-long-hostname-that-tests-column-width.example.com".to_string(), + "another-very-long-hostname.example.com".to_string(), + "third-hostname.example.com".to_string() + ], + keys: vec![ + HostKey { + key_type: "ssh-rsa".to_string(), + key: "AAAAB3NzaC1yc2EAAAADAQABAAABgQC7VeryLongKeyDataThatShouldTestTheKeyColumnWidthHandling".to_string(), + comment: Some("This is a very long comment that should test the comment column width handling and make sure everything aligns properly".to_string()), + revoked: Some(true), + cert_authority: Some(true), + } + ], + } + ], + }; + + // Should handle long data gracefully + pretty_print_known_hosts(&known_hosts_response); + } + + #[test] + fn test_deserialize_known_hosts_response() { + // Test JSON deserialization + let json_data = r#" + { + "version": "1.0.0", + "knownHosts": [ + { + "name": "Test", + "hosts": ["test.com"], + "keys": [ + { + "type": "ssh-rsa", + "key": "AAAAB3NzaC1yc2EAAAADAQABAAABgQC7Test", + "comment": "Test comment", + "revoked": true, + "cert-authority": false + } + ] + } + ] + } + "#; + + let result: Result = serde_json::from_str(json_data); + assert!(result.is_ok()); + + let response = result.unwrap(); + assert_eq!(response.version, "1.0.0"); + assert_eq!(response.hosts.len(), 1); + assert_eq!(response.hosts[0].name, Some("Test".to_string())); + assert_eq!(response.hosts[0].hosts, vec!["test.com"]); + assert_eq!(response.hosts[0].keys.len(), 1); + assert_eq!(response.hosts[0].keys[0].key_type, "ssh-rsa"); + assert_eq!( + response.hosts[0].keys[0].key, + "AAAAB3NzaC1yc2EAAAADAQABAAABgQC7Test" + ); + assert_eq!( + response.hosts[0].keys[0].comment, + Some("Test comment".to_string()) + ); + assert_eq!(response.hosts[0].keys[0].revoked, Some(true)); + assert_eq!(response.hosts[0].keys[0].cert_authority, Some(false)); + } + + #[test] + fn test_deserialize_known_hosts_response_minimal() { + // Test JSON deserialization with minimal required fields + let json_data = r#" + { + "version": "1.0.0", + "knownHosts": [ + { + "hosts": ["minimal.com"], + "keys": [ + { + "type": "ssh-ed25519", + "key": "AAAAC3NzaC1lZDI1NTE5AAAAIMinimal" + } + ] + } + ] + } + "#; + + let result: Result = serde_json::from_str(json_data); + assert!(result.is_ok()); + + let response = result.unwrap(); + assert_eq!(response.version, "1.0.0"); + assert_eq!(response.hosts.len(), 1); + assert_eq!(response.hosts[0].name, None); + assert_eq!(response.hosts[0].hosts, vec!["minimal.com"]); + assert_eq!(response.hosts[0].keys.len(), 1); + assert_eq!(response.hosts[0].keys[0].key_type, "ssh-ed25519"); + assert_eq!( + response.hosts[0].keys[0].key, + "AAAAC3NzaC1lZDI1NTE5AAAAIMinimal" + ); + assert_eq!(response.hosts[0].keys[0].comment, None); + assert_eq!(response.hosts[0].keys[0].revoked, None); + assert_eq!(response.hosts[0].keys[0].cert_authority, None); + } + + #[test] + fn test_deserialize_known_hosts_response_invalid() { + // Test JSON deserialization with missing required fields + let json_data = r#" + { + "version": "1.0.0", + "knownHosts": [ + { + "name": "Invalid", + "keys": [ + { + "type": "ssh-rsa" + } + ] + } + ] + } + "#; + + let result: Result = serde_json::from_str(json_data); + assert!(result.is_err()); // Should fail due to missing required fields + } + + #[test] + fn test_fetch_known_hosts_network_error() { + // Test with invalid URL to simulate network error + let result = fetch_known_hosts("http://invalid-url-that-does-not-exist.local"); + assert!(result.is_err()); + } +} diff --git a/cli/src/commands/mod.rs b/cli/src/commands/mod.rs new file mode 100644 index 0000000..5d8431e --- /dev/null +++ b/cli/src/commands/mod.rs @@ -0,0 +1,10 @@ +pub mod known_hosts; +pub mod pgp_keys; +pub mod ssh_keys; + +// Re-export the main command functions for easier imports +pub use known_hosts::fetch_known_hosts; +pub use known_hosts::write_known_hosts; +pub use pgp_keys::fetch_pgp_keys; +pub use ssh_keys::fetch_ssh_keys; +pub use ssh_keys::write_ssh_keys; diff --git a/cli/src/commands/pgp_keys.rs b/cli/src/commands/pgp_keys.rs new file mode 100644 index 0000000..4fc9414 --- /dev/null +++ b/cli/src/commands/pgp_keys.rs @@ -0,0 +1,508 @@ +use anyhow::{Context, Result}; +use atty; +use colored::Colorize; +use reqwest::header::ACCEPT; +use serde::Deserialize; + +use crate::utils::{ColumnConfig, pretty_print_table}; + +#[derive(Debug, Deserialize)] +pub struct PGPKeysResponse { + pub version: String, + pub keys: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct PGPKey { + pub name: String, + pub key: String, +} + +/// Function to pretty print the PGP keys with formatted columns and colors +pub fn pretty_print_pgp_keys(keys_response: &PGPKeysResponse) { + // Find the maximum width for name column for better formatting + let max_name_len = keys_response + .keys + .iter() + .map(|k| k.name.len()) + .max() + .unwrap_or(4) + .max(4); + + // Define the columns + let columns = vec![ + ColumnConfig { + header: "NAME".to_string(), + color: |s| s.green(), + width: max_name_len, + }, + ColumnConfig { + header: "KEY".to_string(), + color: |s| s.red(), + width: 50, // Key is typically long, so use a reasonable default width + }, + ]; + + // Prepare the rows + let rows: Vec> = keys_response + .keys + .iter() + .map(|key| vec![key.name.clone(), key.key.clone()]) + .collect(); + + // Use the generic pretty print function + pretty_print_table( + "PGP Keys Server Version:", + &keys_response.version, + columns, + rows, + "No PGP keys found matching the criteria.", + ); +} + +pub fn fetch_pgp_keys(server_url: &str) -> Result<()> { + let url = format!("{server_url}/pgp"); + + let client = reqwest::blocking::Client::new(); + let response = client + .get(&url) + .header(ACCEPT, "application/json") + .send() + .context("Failed to send request to PGP keys server")?; + + let status = response.status(); + + if !status.is_success() { + return Err(anyhow::anyhow!( + "Server returned error code: {} - {}", + status.as_u16(), + status.canonical_reason().unwrap_or("Unknown") + )); + } + + let keys_response: PGPKeysResponse = + response.json().context("Failed to parse JSON response")?; + + // Check if the output is being piped (not connected to a terminal) + // Use raw/minimal output when piped to another command + if !atty::is(atty::Stream::Stdout) { + for key in &keys_response.keys { + println!("{}", key.key); + } + return Ok(()); + } + + // Use the pretty print function for interactive terminal output + pretty_print_pgp_keys(&keys_response); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use mockito; + + // Helper function to create a mock server + fn setup_mock_server(response_body: &str) -> (String, mockito::ServerGuard) { + let mut mock_server = mockito::Server::new(); + + mock_server + .mock("GET", "/pgp") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(response_body) + .create(); + + (mock_server.url(), mock_server) + } + + // Helper function to create a mock server with error response + fn setup_mock_server_with_error( + status_code: usize, + response_body: &str, + ) -> (String, mockito::ServerGuard) { + let mut mock_server = mockito::Server::new(); + + mock_server + .mock("GET", "/pgp") + .with_status(status_code) + .with_header("content-type", "application/json") + .with_body(response_body) + .create(); + + (mock_server.url(), mock_server) + } + + #[test] + fn test_fetch_pgp_keys_success() { + // Setup mock server with PGP keys response + let mock_response = r#" + { + "version": "1.0.0", + "keys": [ + { + "name": "John Doe", + "key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\nVersion: GnuPG v2\n\nmQENBFY...\n-----END PGP PUBLIC KEY BLOCK-----" + } + ] + } + "#; + + let (server_url, _server) = setup_mock_server(mock_response); + + // Call function + let result = fetch_pgp_keys(&server_url); + assert!(result.is_ok(), "fetch_pgp_keys failed: {:?}", result.err()); + } + + #[test] + fn test_fetch_pgp_keys_multiple_keys() { + // Setup mock server with multiple PGP keys + let mock_response = r#" + { + "version": "2.1.0", + "keys": [ + { + "name": "Alice Smith", + "key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\nVersion: GnuPG v2\n\nmQENBFYAlice...\n-----END PGP PUBLIC KEY BLOCK-----" + }, + { + "name": "Bob Johnson", + "key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\nVersion: GnuPG v2\n\nmQENBFYBob...\n-----END PGP PUBLIC KEY BLOCK-----" + }, + { + "name": "Charlie Brown", + "key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\nVersion: GnuPG v2\n\nmQENBFYCharlie...\n-----END PGP PUBLIC KEY BLOCK-----" + } + ] + } + "#; + + let (server_url, _server) = setup_mock_server(mock_response); + + // Call function + let result = fetch_pgp_keys(&server_url); + assert!(result.is_ok(), "fetch_pgp_keys failed: {:?}", result.err()); + } + + #[test] + fn test_fetch_pgp_keys_empty_response() { + // Setup mock server with empty keys array + let mock_response = r#" + { + "version": "1.0.0", + "keys": [] + } + "#; + + let (server_url, _server) = setup_mock_server(mock_response); + + // Call function + let result = fetch_pgp_keys(&server_url); + assert!(result.is_ok(), "fetch_pgp_keys failed: {:?}", result.err()); + } + + #[test] + fn test_fetch_pgp_keys_server_error() { + // Setup mock server with error response + let (server_url, _server) = + setup_mock_server_with_error(500, r#"{"error": "Internal server error"}"#); + + // Call function + let result = fetch_pgp_keys(&server_url); + + // Should return an error + assert!(result.is_err()); + let error_msg = result.err().unwrap().to_string(); + assert!(error_msg.contains("Server returned error code: 500")); + } + + #[test] + fn test_fetch_pgp_keys_malformed_response() { + // Setup mock server with malformed JSON + let (server_url, _server) = + setup_mock_server(r#"{"version": "1.0.0", "keys": [{"incomplete": true}]}"#); + + // Call function + let result = fetch_pgp_keys(&server_url); + + // Should return an error due to missing required fields + assert!(result.is_err()); + } + + #[test] + fn test_fetch_pgp_keys_network_error() { + // Test with invalid URL to simulate network error + let result = fetch_pgp_keys("http://invalid-url-that-does-not-exist.local"); + assert!(result.is_err()); + } + + #[test] + fn test_fetch_pgp_keys_unauthorized() { + // Setup mock server with 401 unauthorized + let (server_url, _server) = + setup_mock_server_with_error(401, r#"{"error": "Unauthorized"}"#); + + // Call function + let result = fetch_pgp_keys(&server_url); + + // Should return an error + assert!(result.is_err()); + let error_msg = result.err().unwrap().to_string(); + assert!(error_msg.contains("Server returned error code: 401")); + } + + #[test] + fn test_fetch_pgp_keys_not_found() { + // Setup mock server with 404 not found + let (server_url, _server) = setup_mock_server_with_error(404, r#"{"error": "Not found"}"#); + + // Call function + let result = fetch_pgp_keys(&server_url); + + // Should return an error + assert!(result.is_err()); + let error_msg = result.err().unwrap().to_string(); + assert!(error_msg.contains("Server returned error code: 404")); + } + + #[test] + fn test_pretty_print_pgp_keys() { + // Create a test response with various PGP key data + let keys_response = PGPKeysResponse { + version: "1.0.0".to_string(), + keys: vec![ + PGPKey { + name: "Alice Smith".to_string(), + key: "-----BEGIN PGP PUBLIC KEY BLOCK-----\nVersion: GnuPG v2\n\nmQENBFYAlice...\n-----END PGP PUBLIC KEY BLOCK-----".to_string(), + }, + PGPKey { + name: "Bob Johnson".to_string(), + key: "-----BEGIN PGP PUBLIC KEY BLOCK-----\nVersion: GnuPG v2\n\nmQENBFYBob...\n-----END PGP PUBLIC KEY BLOCK-----".to_string(), + }, + PGPKey { + name: "Charlie Brown with a very long name that tests column width".to_string(), + key: "-----BEGIN PGP PUBLIC KEY BLOCK-----\nVersion: GnuPG v2\n\nmQENBFYCharlie...\n-----END PGP PUBLIC KEY BLOCK-----".to_string(), + } + ], + }; + + // This test primarily verifies the function doesn't panic and handles the data correctly + // Since pretty_print_pgp_keys outputs to stdout, we can't easily capture and verify output + // in this test environment, but we can verify it completes without errors + pretty_print_pgp_keys(&keys_response); + } + + #[test] + fn test_pretty_print_pgp_keys_empty() { + // Test with empty keys list + let keys_response = PGPKeysResponse { + version: "1.0.0".to_string(), + keys: vec![], + }; + + // Should handle empty keys gracefully + pretty_print_pgp_keys(&keys_response); + } + + #[test] + fn test_pretty_print_pgp_keys_single_key() { + // Test with a single key + let keys_response = PGPKeysResponse { + version: "2.0.0".to_string(), + keys: vec![ + PGPKey { + name: "Single User".to_string(), + key: "-----BEGIN PGP PUBLIC KEY BLOCK-----\nVersion: GnuPG v2\n\nmQENBFYSingle...\n-----END PGP PUBLIC KEY BLOCK-----".to_string(), + } + ], + }; + + pretty_print_pgp_keys(&keys_response); + } + + #[test] + fn test_pretty_print_pgp_keys_long_names() { + // Test with very long names to test column width calculations + let keys_response = PGPKeysResponse { + version: "1.0.0".to_string(), + keys: vec![ + PGPKey { + name: "This is a very long name that should test the column width calculation and make sure everything aligns properly even with extremely long names".to_string(), + key: "-----BEGIN PGP PUBLIC KEY BLOCK-----\nVersion: GnuPG v2\n\nmQENBFYLong...\n-----END PGP PUBLIC KEY BLOCK-----".to_string(), + }, + PGPKey { + name: "Short".to_string(), + key: "-----BEGIN PGP PUBLIC KEY BLOCK-----\nVersion: GnuPG v2\n\nmQENBFYShort...\n-----END PGP PUBLIC KEY BLOCK-----".to_string(), + } + ], + }; + + pretty_print_pgp_keys(&keys_response); + } + + #[test] + fn test_deserialize_pgp_keys_response() { + // Test JSON deserialization with valid data + let json_data = r#" + { + "version": "1.0.0", + "keys": [ + { + "name": "Test User", + "key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\nVersion: GnuPG v2\n\nmQENBFYTest...\n-----END PGP PUBLIC KEY BLOCK-----" + } + ] + } + "#; + + let result: Result = serde_json::from_str(json_data); + assert!(result.is_ok()); + + let response = result.unwrap(); + assert_eq!(response.version, "1.0.0"); + assert_eq!(response.keys.len(), 1); + assert_eq!(response.keys[0].name, "Test User"); + assert!(response.keys[0].key.contains("BEGIN PGP PUBLIC KEY BLOCK")); + } + + #[test] + fn test_deserialize_pgp_keys_response_multiple() { + // Test JSON deserialization with multiple keys + let json_data = r#" + { + "version": "2.0.0", + "keys": [ + { + "name": "User One", + "key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\nKey1\n-----END PGP PUBLIC KEY BLOCK-----" + }, + { + "name": "User Two", + "key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\nKey2\n-----END PGP PUBLIC KEY BLOCK-----" + } + ] + } + "#; + + let result: Result = serde_json::from_str(json_data); + assert!(result.is_ok()); + + let response = result.unwrap(); + assert_eq!(response.version, "2.0.0"); + assert_eq!(response.keys.len(), 2); + assert_eq!(response.keys[0].name, "User One"); + assert_eq!(response.keys[1].name, "User Two"); + assert!(response.keys[0].key.contains("Key1")); + assert!(response.keys[1].key.contains("Key2")); + } + + #[test] + fn test_deserialize_pgp_keys_response_empty() { + // Test JSON deserialization with empty keys array + let json_data = r#" + { + "version": "1.0.0", + "keys": [] + } + "#; + + let result: Result = serde_json::from_str(json_data); + assert!(result.is_ok()); + + let response = result.unwrap(); + assert_eq!(response.version, "1.0.0"); + assert_eq!(response.keys.len(), 0); + } + + #[test] + fn test_deserialize_pgp_keys_response_invalid() { + // Test JSON deserialization with missing required fields + let json_data = r#" + { + "version": "1.0.0", + "keys": [ + { + "name": "Incomplete User" + } + ] + } + "#; + + let result: Result = serde_json::from_str(json_data); + assert!(result.is_err()); // Should fail due to missing 'key' field + } + + #[test] + fn test_deserialize_pgp_keys_response_missing_version() { + // Test JSON deserialization with missing version field + let json_data = r#" + { + "keys": [ + { + "name": "Test User", + "key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\nTest\n-----END PGP PUBLIC KEY BLOCK-----" + } + ] + } + "#; + + let result: Result = serde_json::from_str(json_data); + assert!(result.is_err()); // Should fail due to missing 'version' field + } + + #[test] + fn test_deserialize_pgp_keys_response_invalid_json() { + // Test with completely invalid JSON + let json_data = r#"{"invalid": json structure"#; + + let result: Result = serde_json::from_str(json_data); + assert!(result.is_err()); // Should fail due to invalid JSON syntax + } + + #[test] + fn test_pgp_key_with_special_characters() { + // Test with names containing special characters + let keys_response = PGPKeysResponse { + version: "1.0.0".to_string(), + keys: vec![ + PGPKey { + name: "François Müller ".to_string(), + key: "-----BEGIN PGP PUBLIC KEY BLOCK-----\nVersion: GnuPG v2\n\nmQENBFYSpecial...\n-----END PGP PUBLIC KEY BLOCK-----".to_string(), + }, + PGPKey { + name: "José García (Company) [Developer]".to_string(), + key: "-----BEGIN PGP PUBLIC KEY BLOCK-----\nVersion: GnuPG v2\n\nmQENBFYJose...\n-----END PGP PUBLIC KEY BLOCK-----".to_string(), + } + ], + }; + + // Should handle special characters in names gracefully + pretty_print_pgp_keys(&keys_response); + } + + #[test] + fn test_pgp_key_with_different_key_formats() { + // Test with different PGP key formats (RSA, DSA, etc.) + let keys_response = PGPKeysResponse { + version: "1.0.0".to_string(), + keys: vec![ + PGPKey { + name: "RSA User".to_string(), + key: "-----BEGIN PGP PUBLIC KEY BLOCK-----\nVersion: GnuPG v2\n\nmQENBFYRSA... (RSA)\n-----END PGP PUBLIC KEY BLOCK-----".to_string(), + }, + PGPKey { + name: "DSA User".to_string(), + key: "-----BEGIN PGP PUBLIC KEY BLOCK-----\nVersion: GnuPG v1\n\nmQGiBFYDSA... (DSA)\n-----END PGP PUBLIC KEY BLOCK-----".to_string(), + }, + PGPKey { + name: "Ed25519 User".to_string(), + key: "-----BEGIN PGP PUBLIC KEY BLOCK-----\nVersion: GnuPG v2\n\nmDMEZEd25519... (Ed25519)\n-----END PGP PUBLIC KEY BLOCK-----".to_string(), + } + ], + }; + + pretty_print_pgp_keys(&keys_response); + } +} diff --git a/cli/src/commands/ssh_keys.rs b/cli/src/commands/ssh_keys.rs new file mode 100644 index 0000000..884123d --- /dev/null +++ b/cli/src/commands/ssh_keys.rs @@ -0,0 +1,889 @@ +use anyhow::{Context, Result}; +use atty; +use colored::Colorize; +use reqwest::header::ACCEPT; +use serde::Deserialize; + +use crate::utils::{ColumnConfig, pretty_print_table}; + +#[derive(Debug, Deserialize)] +pub struct KeysResponse { + pub version: String, + pub keys: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct SSHKey { + pub key: String, + pub user: String, + pub name: String, + pub tags: Vec, +} + +/// Function to pretty print the SSH keys with formatted columns and colors +pub fn pretty_print_ssh_keys(keys_response: &KeysResponse) { + // Find the maximum width for each column for better formatting + let max_name_len = keys_response + .keys + .iter() + .map(|k| k.name.len()) + .max() + .unwrap_or(4) + .max(4); + let max_user_len = keys_response + .keys + .iter() + .map(|k| k.user.len()) + .max() + .unwrap_or(4) + .max(4); + let max_tags_len = keys_response + .keys + .iter() + .map(|k| k.tags.join(", ").len()) + .max() + .unwrap_or(4) + .max(4); + + // Define the columns + let columns = vec![ + ColumnConfig { + header: "NAME".to_string(), + color: |s| s.green(), + width: max_name_len, + }, + ColumnConfig { + header: "USER".to_string(), + color: |s| s.blue(), + width: max_user_len, + }, + ColumnConfig { + header: "TAGS".to_string(), + color: |s| s.yellow(), + width: max_tags_len, + }, + ColumnConfig { + header: "KEY".to_string(), + color: |s| s.red(), + width: 50, // Key is typically long, so use a reasonable default width + }, + ]; + + // Prepare the rows + let rows: Vec> = keys_response + .keys + .iter() + .map(|key| { + vec![ + key.name.clone(), + key.user.clone(), + key.tags.join(", "), + key.key.clone(), + ] + }) + .collect(); + + // Use the generic pretty print function + pretty_print_table( + "Keys Server Version:", + &keys_response.version, + columns, + rows, + "No SSH keys found matching the criteria.", + ); +} + +/// Private function to fetch SSH keys from the server +/// +/// This function handles the HTTP request to the keys server, +/// validates the response, and parses the JSON into a KeysResponse. +/// +/// # Arguments +/// * `server_url` - The base URL of the keys server +/// +/// # Returns +/// * `Result` - The parsed keys response or an error +fn fetch_keys_from_server(server_url: &str) -> Result { + let url = format!("{server_url}/keys"); + + let client = reqwest::blocking::Client::new(); + let response = client + .get(&url) + .header(ACCEPT, "application/json") + .send() + .context("Failed to send request to keys server")?; + + let status = response.status(); + + if !status.is_success() { + return Err(anyhow::anyhow!( + "Server returned error code: {} - {}", + status.as_u16(), + status.canonical_reason().unwrap_or("Unknown") + )); + } + + response + .json::() + .context("Failed to parse JSON response") +} + +pub fn fetch_ssh_keys(server_url: &str) -> Result<()> { + let keys_response = fetch_keys_from_server(server_url)?; + + // Check if the output is being piped (not connected to a terminal) + // Use raw/minimal output when piped to another command + if !atty::is(atty::Stream::Stdout) { + let output = format_keys_for_pipe(&keys_response); + if !output.is_empty() { + println!("{output}"); + } + return Ok(()); + } + + // Use the pretty print function for interactive terminal output + pretty_print_ssh_keys(&keys_response); + + Ok(()) +} + +/// Helper function to format keys for non-TTY output (used for testing) +fn format_keys_for_pipe(keys_response: &KeysResponse) -> String { + keys_response + .keys + .iter() + .map(|key| format!("{} {}@{}", key.key, key.user, key.name)) + .collect::>() + .join("\n") +} + +/// Helper function to extract just the key part from an SSH key line (without comment) +fn extract_key_part(ssh_line: &str) -> String { + let parts: Vec<&str> = ssh_line.split_whitespace().collect(); + if parts.len() >= 2 { + // Return "ssh-rsa AAAAB..." (type + key, no comment) + format!("{} {}", parts[0], parts[1]) + } else if parts.len() == 1 { + parts[0].to_string() + } else { + ssh_line.trim().to_string() + } +} + +/// Helper function to format a server key with user@host comment +fn format_server_key(ssh_key: &SSHKey) -> String { + format!("{} {}@{}", ssh_key.key, ssh_key.user, ssh_key.name) +} + +pub fn write_ssh_keys(server_url: &str, file_path: &str, force: bool) -> Result<()> { + // Fetch keys from the server + let keys_response = fetch_keys_from_server(server_url)?; + + // Expand ~ to home directory if present + let expanded_path = shellexpand::tilde(file_path); + let path = std::path::Path::new(expanded_path.as_ref()); + + // Read existing authorized_keys file if it exists + let existing_lines = if path.exists() { + std::fs::read_to_string(path) + .with_context(|| format!("Failed to read existing file: {}", path.display()))? + .lines() + .filter(|line| !line.trim().is_empty() && !line.trim().starts_with('#')) + .map(|s| s.to_string()) + .collect::>() + } else { + Vec::new() + }; + + // Extract server keys (just the key part for comparison) + let server_key_parts: Vec = keys_response.keys.iter().map(|k| k.key.clone()).collect(); + + // Extract existing key parts for comparison + let existing_key_parts: Vec = existing_lines + .iter() + .map(|line| extract_key_part(line)) + .collect(); + + // Find keys that are present locally but not on the server + let local_only_keys: Vec<&String> = existing_lines + .iter() + .enumerate() + .filter(|(i, _)| !server_key_parts.contains(&existing_key_parts[*i])) + .map(|(_, line)| line) + .collect(); + let num_local_only = local_only_keys.len(); + + // Create directory if it doesn't exist + if let Some(parent) = path.parent() + && !parent.exists() + { + std::fs::create_dir_all(parent) + .with_context(|| format!("Failed to create parent directory: {}", parent.display()))?; + } + + let mut updated_keys_count = 0; + + // Define the file content based on the force flag + let file_content = if force { + // Force mode: overwrite with server keys (with user@host comments) + keys_response + .keys + .iter() + .map(format_server_key) + .collect::>() + .join("\n") + } else { + // Safe mode: merge existing keys with server keys + let mut result_lines = Vec::new(); + + // First, add existing keys, updating comments if the key matches a server key + for existing_line in &existing_lines { + let existing_key_part = extract_key_part(existing_line); + + // Check if this key matches any server key + if let Some(server_key) = keys_response + .keys + .iter() + .find(|k| k.key == existing_key_part) + { + // Update the comment part with server info + let new_line = format_server_key(server_key); + if new_line != *existing_line { + updated_keys_count += 1; + } + result_lines.push(new_line); + } else { + // Keep existing local key as-is + result_lines.push(existing_line.clone()); + } + } + + // Then, add new server keys that weren't already present + for server_key in &keys_response.keys { + if !existing_key_parts.contains(&server_key.key) { + result_lines.push(format_server_key(server_key)); + } + } + + result_lines.join("\n") + }; + + // Write to file + std::fs::write(path, file_content) + .with_context(|| format!("Failed to write to file: {}", path.display()))?; + + // Count stats + let num_server_keys = keys_response.keys.len(); + let num_existing = existing_lines.len(); + let num_final = if force { + num_server_keys + } else { + // In additive mode, we need to count unique keys + let mut combined_key_parts = existing_key_parts.clone(); + for server_key in &keys_response.keys { + if !combined_key_parts.contains(&server_key.key) { + combined_key_parts.push(server_key.key.clone()); + } + } + combined_key_parts.len() + }; + + // Print a message about what happened + if force { + println!( + "✅ Wrote {} keys to {} (overwriting {} existing keys)", + num_server_keys, + path.display(), + num_existing + ); + } else { + let num_added = num_final - num_existing; + if num_added > 0 { + let mut message = format!( + "✅ Added {} new keys to {} (now {} total keys)", + num_added, + path.display(), + num_final + ); + if updated_keys_count > 0 { + message.push_str(&format!( + " and updated comments for {updated_keys_count} existing keys" + )); + } + println!("{message}"); + } else { + let mut message = format!( + "✅ Server keys are already present locally at {} ({} total keys)", + path.display(), + num_final + ); + if updated_keys_count > 0 { + message.push_str(&format!( + " but updated comments for {updated_keys_count} keys" + )); + } + println!("{message}"); + } + + // Print warning about local-only keys if they exist + if num_local_only > 0 { + println!( + "{} {} local keys were not removed (use {} to remove)", + "⚠️".yellow().bold(), + num_local_only.to_string().yellow().bold(), + "--force".yellow().bold() + ); + + // List the first few keys that would be removed + const MAX_KEYS_TO_SHOW: usize = 3; + if !local_only_keys.is_empty() { + let sample_keys = if local_only_keys.len() <= MAX_KEYS_TO_SHOW { + local_only_keys + .iter() + .map(|k| { + let trimmed = k.trim(); + // Extract the comment part which typically contains name/host information + let parts: Vec<&str> = trimmed.split_whitespace().collect(); + if parts.len() >= 3 { + // Last part is typically user@host or key name + parts[2..].join(" ") + } else { + // If no comment part, just indicate it's a local key + "local key".to_string() + } + }) + .collect::>() + .join(", ") + } else { + format!("{num_local_only} keys") + }; + + println!(" Keys that would be removed: {}", sample_keys.yellow()); + } + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use mockito; + use std::fs::{self, File}; + use std::io::Write; + use std::path::PathBuf; + use tempfile::TempDir; + + // Helper function to create a temp directory and file + fn setup_temp_dir_and_file(content: Option<&str>) -> (TempDir, PathBuf) { + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("authorized_keys"); + + if let Some(content) = content { + let mut file = File::create(&file_path).unwrap(); + writeln!(file, "{content}").unwrap(); + } + + (temp_dir, file_path) + } + + // Helper function to create a mock server + fn setup_mock_server(response_body: &str) -> (String, mockito::ServerGuard) { + let mut mock_server = mockito::Server::new(); + + mock_server + .mock("GET", "/keys") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(response_body) + .create(); + + (mock_server.url(), mock_server) + } + + // Helper function to create a mock server with error response + fn setup_mock_server_with_error( + status_code: usize, + response_body: &str, + ) -> (String, mockito::ServerGuard) { + let mut mock_server = mockito::Server::new(); + + mock_server + .mock("GET", "/keys") + .with_status(status_code) + .with_header("content-type", "application/json") + .with_body(response_body) + .create(); + + (mock_server.url(), mock_server) + } + + #[test] + fn test_write_ssh_keys_force_mode() { + // Setup mock server + let mock_response = r#" + { + "version": "1.0.0", + "keys": [ + {"key": "ssh-rsa AAAAB1", "user": "user1", "name": "key1", "tags": ["dev"]}, + {"key": "ssh-rsa AAAAB2", "user": "user2", "name": "key2", "tags": ["prod"]} + ] + } + "#; + + let (server_url, _server) = setup_mock_server(mock_response); + + // Setup existing file with content that should be overwritten + let existing_content = + "ssh-rsa AAAABX old_key user@host\nssh-rsa AAAABY another_old_key user@host"; + let (temp_dir, file_path) = setup_temp_dir_and_file(Some(existing_content)); + + // Call function with force=true + let result = write_ssh_keys(&server_url, file_path.to_str().unwrap(), true); + assert!(result.is_ok(), "write_ssh_keys failed: {:?}", result.err()); + + // Verify file contents + let content = fs::read_to_string(&file_path).unwrap(); + assert_eq!( + content, + "ssh-rsa AAAAB1 user1@key1\nssh-rsa AAAAB2 user2@key2" + ); + + // Verify it doesn't contain old keys + assert!(!content.contains("AAAABX")); + assert!(!content.contains("AAAABY")); + + // Cleanup + drop(temp_dir); + } + + #[test] + fn test_write_ssh_keys_additive_mode() { + // Setup mock server + let mock_response = r#" + { + "version": "1.0.0", + "keys": [ + {"key": "ssh-rsa AAAAB1", "user": "user1", "name": "key1", "tags": ["dev"]}, + {"key": "ssh-rsa AAAAB2", "user": "user2", "name": "key2", "tags": ["prod"]} + ] + } + "#; + + let (server_url, _server) = setup_mock_server(mock_response); + + // Setup existing file with one key that's also in the response and one that isn't + let existing_content = "ssh-rsa AAAAB1\nssh-rsa AAAABZ local_key user@host"; + let (temp_dir, file_path) = setup_temp_dir_and_file(Some(existing_content)); + + // Call function with force=false (additive mode) + let result = write_ssh_keys(&server_url, file_path.to_str().unwrap(), false); + assert!(result.is_ok(), "write_ssh_keys failed: {:?}", result.err()); + + // Verify file contents - should contain both old and new keys + let content = fs::read_to_string(&file_path).unwrap(); + assert!(content.contains("ssh-rsa AAAAB1")); + assert!(content.contains("ssh-rsa AAAAB2")); // New key added + assert!(content.contains("ssh-rsa AAAABZ")); // Old local key retained + + // Count occurrences of AAAAB1 (should only appear once) + let count_key1 = content.matches("AAAAB1").count(); + assert_eq!(count_key1, 1, "Duplicate key found: AAAAB1"); + + // Cleanup + drop(temp_dir); + } + + #[test] + fn test_write_ssh_keys_new_file() { + // Setup mock server + let mock_response = r#" + { + "version": "1.0.0", + "keys": [ + {"key": "ssh-rsa AAAAB1", "user": "user1", "name": "key1", "tags": ["dev"]}, + {"key": "ssh-rsa AAAAB2", "user": "user2", "name": "key2", "tags": ["prod"]} + ] + } + "#; + + let (server_url, _server) = setup_mock_server(mock_response); + + // Don't create an existing file + let (temp_dir, file_path) = setup_temp_dir_and_file(None); + + // Call function (with either force mode) + let result = write_ssh_keys(&server_url, file_path.to_str().unwrap(), true); + assert!(result.is_ok(), "write_ssh_keys failed: {:?}", result.err()); + + // Verify file was created with correct contents + let content = fs::read_to_string(&file_path).unwrap(); + assert_eq!( + content, + "ssh-rsa AAAAB1 user1@key1\nssh-rsa AAAAB2 user2@key2" + ); + + // Cleanup + drop(temp_dir); + } + + #[test] + fn test_write_ssh_keys_empty_response() { + // Setup mock server with empty keys array + let mock_response = r#" + { + "version": "1.0.0", + "keys": [] + } + "#; + + let (server_url, _server) = setup_mock_server(mock_response); + + // Setup existing file + let existing_content = "ssh-rsa AAAABZ local_key user@host"; + let (temp_dir, file_path) = setup_temp_dir_and_file(Some(existing_content)); + + // Test force mode with empty response (should clear the file) + let result = write_ssh_keys(&server_url, file_path.to_str().unwrap(), true); + assert!(result.is_ok(), "write_ssh_keys failed: {:?}", result.err()); + + // Verify file contents (should be empty) + let content = fs::read_to_string(&file_path).unwrap(); + assert_eq!(content, ""); + + // Cleanup + drop(temp_dir); + } + + #[test] + fn test_write_ssh_keys_server_error() { + // Setup mock server with error response + let (server_url, _server) = + setup_mock_server_with_error(500, r#"{"error": "Internal server error"}"#); + + // Setup temp file + let (temp_dir, file_path) = setup_temp_dir_and_file(Some("existing-content")); + + // Call function + let result = write_ssh_keys(&server_url, file_path.to_str().unwrap(), false); + + // Should return an error + assert!(result.is_err()); + + // Verify file wasn't modified + let content = fs::read_to_string(&file_path).unwrap(); + assert_eq!(content, "existing-content\n"); + + // Cleanup + drop(temp_dir); + } + + #[test] + fn test_write_ssh_keys_malformed_response() { + // Setup mock server with malformed JSON + let (server_url, _server) = + setup_mock_server(r#"{"version": "1.0.0", "keys": [{"incomplete": true}]}"#); + + // Setup temp file + let (temp_dir, file_path) = setup_temp_dir_and_file(Some("existing-content")); + + // Call function + let result = write_ssh_keys(&server_url, file_path.to_str().unwrap(), false); + + // Should return an error + assert!(result.is_err()); + + // Verify file wasn't modified + let content = fs::read_to_string(&file_path).unwrap(); + assert_eq!(content, "existing-content\n"); + + // Cleanup + drop(temp_dir); + } + + #[test] + fn test_write_ssh_keys_local_only_keys() { + // Setup mock server + let mock_response = r#" + { + "version": "1.0.0", + "keys": [ + {"key": "ssh-rsa AAAAB1", "user": "user1", "name": "key1", "tags": ["dev"]} + ] + } + "#; + + let (server_url, _server) = setup_mock_server(mock_response); + + // Setup existing file with one key from the server and two local-only keys + let existing_content = + "ssh-rsa AAAAB1\nssh-rsa LOCALK1 local1@host\nssh-rsa LOCALK2 local2@host"; + let (temp_dir, file_path) = setup_temp_dir_and_file(Some(existing_content)); + + // Call function with force=false (additive mode) + let result = write_ssh_keys(&server_url, file_path.to_str().unwrap(), false); + assert!(result.is_ok(), "write_ssh_keys failed: {:?}", result.err()); + + // Verify file contents - should contain both server keys and local keys + let content = fs::read_to_string(&file_path).unwrap(); + assert!(content.contains("ssh-rsa AAAAB1")); + assert!(content.contains("ssh-rsa LOCALK1")); + assert!(content.contains("ssh-rsa LOCALK2")); + + // Now try with force=true + let result = write_ssh_keys(&server_url, file_path.to_str().unwrap(), true); + assert!(result.is_ok(), "write_ssh_keys failed: {:?}", result.err()); + + // Verify file contents - should only contain the server key + let content = fs::read_to_string(&file_path).unwrap(); + assert!(content.contains("ssh-rsa AAAAB1")); + assert!(!content.contains("ssh-rsa LOCALK1")); + assert!(!content.contains("ssh-rsa LOCALK2")); + + // Cleanup + drop(temp_dir); + } + + #[test] + fn test_write_ssh_keys_already_in_sync() { + // Setup mock server + let mock_response = r#" + { + "version": "1.0.0", + "keys": [ + {"key": "ssh-rsa AAAAB1", "user": "user1", "name": "key1", "tags": ["dev"]}, + {"key": "ssh-rsa AAAAB2", "user": "user2", "name": "key2", "tags": ["prod"]} + ] + } + "#; + + let (server_url, _server) = setup_mock_server(mock_response); + + // Setup existing file with the exact same keys that are on the server + let existing_content = "ssh-rsa AAAAB1\nssh-rsa AAAAB2"; + let (temp_dir, file_path) = setup_temp_dir_and_file(Some(existing_content)); + + // Call function with force=false (additive mode) + let result = write_ssh_keys(&server_url, file_path.to_str().unwrap(), false); + assert!(result.is_ok(), "write_ssh_keys failed: {:?}", result.err()); + + // Verify file contents - should be unchanged + let content = fs::read_to_string(&file_path).unwrap(); + assert!(content.contains("ssh-rsa AAAAB1")); + assert!(content.contains("ssh-rsa AAAAB2")); + + // Count occurrences of each key (should only appear once) + let count_key1 = content.matches("AAAAB1").count(); + let count_key2 = content.matches("AAAAB2").count(); + assert_eq!(count_key1, 1, "Duplicate key found: AAAAB1"); + assert_eq!(count_key2, 1, "Duplicate key found: AAAAB2"); + + // Cleanup + drop(temp_dir); + } + + #[test] + fn test_pretty_print_ssh_keys() { + // Create a test response with various key data + let keys_response = KeysResponse { + version: "1.2.3".to_string(), + keys: vec![ + SSHKey { + key: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC7".to_string(), + user: "alice".to_string(), + name: "work-laptop".to_string(), + tags: vec!["dev".to_string(), "work".to_string()], + }, + SSHKey { + key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI".to_string(), + user: "bob".to_string(), + name: "home-desktop".to_string(), + tags: vec!["personal".to_string()], + }, + SSHKey { + key: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQD".to_string(), + user: "charlie".to_string(), + name: "server".to_string(), + tags: vec![], + }, + ], + }; + + // This test primarily verifies the function doesn't panic and handles the data correctly + // Since pretty_print_ssh_keys outputs to stdout, we can't easily capture and verify output + // in this test environment, but we can verify it completes without errors + pretty_print_ssh_keys(&keys_response); + } + + #[test] + fn test_pretty_print_ssh_keys_empty() { + // Test with empty keys list + let keys_response = KeysResponse { + version: "1.0.0".to_string(), + keys: vec![], + }; + + // Should handle empty keys gracefully + pretty_print_ssh_keys(&keys_response); + } + + #[test] + fn test_fetch_ssh_keys_piped_output() { + // Setup mock server + let mock_response = r#" + { + "version": "1.0.0", + "keys": [ + {"key": "ssh-rsa AAAAB1", "user": "user1", "name": "key1", "tags": ["dev"]}, + {"key": "ssh-rsa AAAAB2", "user": "user2", "name": "key2", "tags": ["prod"]} + ] + } + "#; + + let (server_url, _server) = setup_mock_server(mock_response); + + // Note: Testing the non-TTY path is challenging because atty::is() checks the actual stdout + // In a real test environment, we can't easily mock this behavior + // This test verifies the function completes successfully, but the actual output format + // depends on whether the test is run in a TTY or not + let result = fetch_ssh_keys(&server_url); + assert!(result.is_ok(), "fetch_ssh_keys failed: {:?}", result.err()); + } + + #[test] + fn test_format_keys_for_pipe() { + // Test with multiple keys + let keys_response = KeysResponse { + version: "1.0.0".to_string(), + keys: vec![ + SSHKey { + key: "ssh-rsa AAAAB1".to_string(), + user: "user1".to_string(), + name: "key1".to_string(), + tags: vec!["dev".to_string()], + }, + SSHKey { + key: "ssh-ed25519 AAAAC1".to_string(), + user: "user2".to_string(), + name: "key2".to_string(), + tags: vec!["prod".to_string()], + }, + ], + }; + + let output = format_keys_for_pipe(&keys_response); + assert_eq!( + output, + "ssh-rsa AAAAB1 user1@key1\nssh-ed25519 AAAAC1 user2@key2" + ); + } + + #[test] + fn test_format_keys_for_pipe_empty() { + // Test with no keys + let keys_response = KeysResponse { + version: "1.0.0".to_string(), + keys: vec![], + }; + + let output = format_keys_for_pipe(&keys_response); + assert_eq!(output, ""); + } + + #[test] + fn test_format_keys_for_pipe_single_key() { + // Test with a single key + let keys_response = KeysResponse { + version: "1.0.0".to_string(), + keys: vec![SSHKey { + key: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC7".to_string(), + user: "alice".to_string(), + name: "laptop".to_string(), + tags: vec!["work".to_string(), "dev".to_string()], + }], + }; + + let output = format_keys_for_pipe(&keys_response); + assert_eq!( + output, + "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC7 alice@laptop" + ); + } + + #[test] + fn test_write_ssh_keys_update_comments() { + // Setup mock server with keys that have user@host info + let mock_response = r#" + { + "version": "1.0.0", + "keys": [ + {"key": "ssh-rsa AAAAB1", "user": "newuser", "name": "newname", "tags": ["dev"]}, + {"key": "ssh-rsa AAAAB2", "user": "user2", "name": "key2", "tags": ["prod"]} + ] + } + "#; + + let (server_url, _server) = setup_mock_server(mock_response); + + // Setup existing file with keys that have old comments + let existing_content = "ssh-rsa AAAAB1 olduser@oldname\nssh-rsa AAAAB3 localuser@localhost"; + let (temp_dir, file_path) = setup_temp_dir_and_file(Some(existing_content)); + + // Call function with force=false (additive mode) + let result = write_ssh_keys(&server_url, file_path.to_str().unwrap(), false); + assert!(result.is_ok(), "write_ssh_keys failed: {:?}", result.err()); + + // Verify file contents + let content = fs::read_to_string(&file_path).unwrap(); + + // Should contain the updated comment for AAAAB1 + assert!(content.contains("ssh-rsa AAAAB1 newuser@newname")); + + // Should contain the new key AAAAB2 + assert!(content.contains("ssh-rsa AAAAB2 user2@key2")); + + // Should still contain the local-only key AAAAB3 + assert!(content.contains("ssh-rsa AAAAB3 localuser@localhost")); + + // Should NOT contain the old comment for AAAAB1 + assert!(!content.contains("olduser@oldname")); + + // Count lines - should be 3 lines total + let lines: Vec<&str> = content + .lines() + .filter(|line| !line.trim().is_empty()) + .collect(); + assert_eq!(lines.len(), 3); + + // Cleanup + drop(temp_dir); + } + + #[test] + fn test_extract_key_part() { + // Test with comment + assert_eq!( + extract_key_part("ssh-rsa AAAAB123 user@host"), + "ssh-rsa AAAAB123" + ); + + // Test without comment + assert_eq!(extract_key_part("ssh-rsa AAAAB123"), "ssh-rsa AAAAB123"); + + // Test with extra whitespace + assert_eq!( + extract_key_part(" ssh-rsa AAAAB123 user@host "), + "ssh-rsa AAAAB123" + ); + + // Test with multiple comment parts + assert_eq!( + extract_key_part("ssh-rsa AAAAB123 user@host some extra info"), + "ssh-rsa AAAAB123" + ); + + // Test edge case - only key type + assert_eq!(extract_key_part("ssh-rsa"), "ssh-rsa"); + } +} diff --git a/cli/src/config/mod.rs b/cli/src/config/mod.rs new file mode 100644 index 0000000..30aef56 --- /dev/null +++ b/cli/src/config/mod.rs @@ -0,0 +1,303 @@ +use anyhow::{Context, Result}; +use directories::ProjectDirs; +use serde::Deserialize; +use std::fs; +use std::path::{Path, PathBuf}; + +/// Configuration structure for the keys CLI +#[derive(Debug, Deserialize)] +pub struct Config { + /// URL of the keys server + #[serde(default = "default_server_url")] + pub server_url: String, + // Add more config options here as needed +} + +fn default_server_url() -> String { + "http://localhost:8000".to_string() +} + +impl Default for Config { + fn default() -> Self { + Self { + server_url: default_server_url(), + } + } +} + +/// Load configuration from file or return default if not found +pub fn load_config(config_path: Option<&str>) -> Result { + // Try to use the provided config path if specified + if let Some(path) = config_path { + let config_file = Path::new(path); + if config_file.exists() { + return load_config_from_path(config_file); + } else { + println!("Warning: Specified config file not found at {path}"); + // Fall back to default config if specified file doesn't exist + return Ok(Config::default()); + } + } + + // Try to load from default locations + if let Some(config_path) = get_default_config_path() + && config_path.exists() + { + return load_config_from_path(&config_path); + } + + // If no config file found, return default config + Ok(Config::default()) +} + +/// Get the default config file path +pub fn get_default_config_path() -> Option { + // Use the directories crate to find the standard config directory + // We use "io.github" as a namespace to avoid conflicts with other applications + if let Some(proj_dirs) = ProjectDirs::from("io.github", "danielemery", "keys") { + let config_dir = proj_dirs.config_dir(); + let config_file = config_dir.join("config.toml"); + return Some(config_file); + } + None +} + +/// Load configuration from a specific path +fn load_config_from_path(path: &Path) -> Result { + let contents = fs::read_to_string(path) + .with_context(|| format!("Failed to read config file: {}", path.display()))?; + + let config: Config = toml::from_str(&contents) + .with_context(|| format!("Failed to parse TOML config from: {}", path.display()))?; + + Ok(config) +} + +/// Creates a default config file at the default location if it doesn't exist +pub fn ensure_default_config_exists() -> Result { + if let Some(config_path) = get_default_config_path() { + if !config_path.exists() { + // Create parent directory if it doesn't exist + if let Some(parent) = config_path.parent() { + fs::create_dir_all(parent).with_context(|| { + format!("Failed to create config directory: {}", parent.display()) + })?; + } + + // Write default config to file + let default_config = format!( + "# Keys CLI Configuration\n\n# Server URL (default: http://localhost:8000)\nserver_url = \"{}\"\n", + default_server_url() + ); + + fs::write(&config_path, default_config).with_context(|| { + format!( + "Failed to write default config to: {}", + config_path.display() + ) + })?; + + println!("Created default config file at: {}", config_path.display()); + } + return Ok(config_path); + } + + Err(anyhow::anyhow!("Could not determine default config path")) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::{NamedTempFile, TempDir}; + + #[test] + fn test_default_config() { + let config = Config::default(); + assert_eq!(config.server_url, "http://localhost:8000"); + } + + #[test] + fn test_default_server_url() { + assert_eq!(default_server_url(), "http://localhost:8000"); + } + + #[test] + fn test_load_config_with_valid_file() { + // Create a temporary config file + let temp_file = NamedTempFile::new().unwrap(); + let config_content = r#" +server_url = "https://example.com:8080" +"#; + fs::write(temp_file.path(), config_content).unwrap(); + + // Load config from the temp file + let result = load_config(Some(temp_file.path().to_str().unwrap())); + assert!(result.is_ok()); + + let config = result.unwrap(); + assert_eq!(config.server_url, "https://example.com:8080"); + } + + #[test] + fn test_load_config_with_nonexistent_file() { + // Try to load config from a non-existent file + let result = load_config(Some("/nonexistent/path/config.toml")); + assert!(result.is_ok()); + + // Should return default config when file doesn't exist + let config = result.unwrap(); + assert_eq!(config.server_url, "http://localhost:8000"); + } + + #[test] + fn test_load_config_with_invalid_toml() { + // Create a temporary config file with invalid TOML + let temp_file = NamedTempFile::new().unwrap(); + let invalid_content = r#" +server_url = "unclosed string +"#; + fs::write(temp_file.path(), invalid_content).unwrap(); + + // Load config from the temp file + let result = load_config(Some(temp_file.path().to_str().unwrap())); + assert!(result.is_err()); + } + + #[test] + fn test_load_config_with_empty_file() { + // Create an empty temporary config file + let temp_file = NamedTempFile::new().unwrap(); + fs::write(temp_file.path(), "").unwrap(); + + // Load config from the temp file + let result = load_config(Some(temp_file.path().to_str().unwrap())); + assert!(result.is_ok()); + + // Should use default values when file is empty + let config = result.unwrap(); + assert_eq!(config.server_url, "http://localhost:8000"); + } + + #[test] + fn test_load_config_no_path_provided() { + // Test loading config without providing a path + // This should return default config since no default config file exists in test environment + let result = load_config(None); + assert!(result.is_ok()); + + let config = result.unwrap(); + assert_eq!(config.server_url, "http://localhost:8000"); + } + + #[test] + fn test_load_config_from_path_valid() { + // Create a temporary config file + let temp_file = NamedTempFile::new().unwrap(); + let config_content = r#" +server_url = "https://test.example.com" +"#; + fs::write(temp_file.path(), config_content).unwrap(); + + // Load config using the internal function + let result = load_config_from_path(temp_file.path()); + assert!(result.is_ok()); + + let config = result.unwrap(); + assert_eq!(config.server_url, "https://test.example.com"); + } + + #[test] + fn test_load_config_from_path_nonexistent() { + let nonexistent_path = Path::new("/definitely/does/not/exist/config.toml"); + let result = load_config_from_path(nonexistent_path); + assert!(result.is_err()); + } + + #[test] + fn test_get_default_config_path() { + // This test just ensures the function doesn't panic and returns a sensible path + let path = get_default_config_path(); + if let Some(config_path) = path { + assert!(config_path.to_string_lossy().contains("config.toml")); + assert!(config_path.to_string_lossy().contains("keys")); + } + // Note: path might be None in some test environments, which is acceptable + } + + #[test] + fn test_ensure_default_config_exists() { + // Create a temporary directory to simulate a config directory + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("keys").join("config.toml"); + + // Mock the get_default_config_path function by testing ensure_default_config_exists + // in an environment where we control the path + // Since we can't easily mock the function, we'll test the file creation logic indirectly + + // Ensure parent directory doesn't exist + assert!(!config_path.exists()); + + // Test that the function would work - we can't easily test this without mocking + // but we can at least ensure it doesn't panic in normal circumstances + let result = ensure_default_config_exists(); + // The result depends on whether ProjectDirs can determine a config directory + // In some test environments this might fail, which is acceptable + match result { + Ok(path) => { + // If successful, the path should exist and contain expected content + assert!(path.exists()); + let content = fs::read_to_string(&path).unwrap(); + assert!(content.contains("server_url")); + assert!(content.contains("http://localhost:8000")); + } + Err(_) => { + // In some test environments, ProjectDirs might not work, which is acceptable + } + } + } + + #[test] + fn test_config_with_partial_content() { + // Test config file that only specifies some fields (using serde defaults) + let temp_file = NamedTempFile::new().unwrap(); + let config_content = r#" +# Just a comment, no actual config values +"#; + fs::write(temp_file.path(), config_content).unwrap(); + + let result = load_config_from_path(temp_file.path()); + assert!(result.is_ok()); + + let config = result.unwrap(); + assert_eq!(config.server_url, "http://localhost:8000"); // Should use default + } + + #[test] + fn test_config_debug_implementation() { + let config = Config::default(); + let debug_output = format!("{config:?}"); + assert!(debug_output.contains("Config")); + assert!(debug_output.contains("server_url")); + assert!(debug_output.contains("http://localhost:8000")); + } + + #[test] + fn test_config_deserialization() { + let toml_str = r#" +server_url = "https://custom.server.com:9000" +"#; + let config: Config = toml::from_str(toml_str).unwrap(); + assert_eq!(config.server_url, "https://custom.server.com:9000"); + } + + #[test] + fn test_config_deserialization_with_missing_field() { + // Test that missing fields use defaults + let toml_str = r#" +# No server_url specified +"#; + let config: Config = toml::from_str(toml_str).unwrap(); + assert_eq!(config.server_url, "http://localhost:8000"); // Should use default + } +} diff --git a/cli/src/lib.rs b/cli/src/lib.rs new file mode 100644 index 0000000..6b00a41 --- /dev/null +++ b/cli/src/lib.rs @@ -0,0 +1,8 @@ +pub mod commands { + pub mod known_hosts; + pub mod pgp_keys; + pub mod ssh_keys; +} + +pub mod config; +pub mod utils; diff --git a/cli/src/main.rs b/cli/src/main.rs new file mode 100644 index 0000000..abd7732 --- /dev/null +++ b/cli/src/main.rs @@ -0,0 +1,83 @@ +use anyhow::Result; +use clap::{Parser, Subcommand}; + +use keys::{commands, config}; + +#[derive(Parser, Debug)] +#[command(author, version, about, long_about = None)] +struct Cli { + /// The server URL (overrides config file) + #[arg(short, long)] + server: Option, + + /// Path to config file (default: ~/.config/keys/config.toml) + #[arg(short = 'c', long)] + config: Option, + + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand, Debug)] +enum Commands { + /// Fetch or write SSH keys from the server + Ssh { + /// Write keys to authorized_keys file + #[arg(short, long)] + write: Option, + + /// Force overwrite existing keys (default is to only add new keys) + #[arg(short, long)] + force: bool, + }, + + /// Fetch PGP keys from the server + Pgp {}, + + /// Fetch known hosts from the server + KnownHosts { + /// Write known hosts to file (replaces entire file) + #[arg(short, long)] + write: Option, + }, + + /// Initialize a default config file + Init {}, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + + // Load configuration, with CLI-provided path if specified + let config = config::load_config(cli.config.as_deref())?; + + // CLI server arg takes precedence over config file + let server_url = cli.server.unwrap_or(config.server_url); + + match &cli.command { + Commands::Ssh { write, force } => { + if let Some(path) = write { + commands::ssh_keys::write_ssh_keys(&server_url, path, *force)?; + } else { + commands::ssh_keys::fetch_ssh_keys(&server_url)?; + } + } + Commands::Pgp {} => { + commands::pgp_keys::fetch_pgp_keys(&server_url)?; + } + Commands::KnownHosts { write } => { + if let Some(path) = write { + commands::known_hosts::write_known_hosts(&server_url, path)?; + } else { + commands::known_hosts::fetch_known_hosts(&server_url)?; + } + } + Commands::Init {} => { + // Create a default config file + let config_path = config::ensure_default_config_exists()?; + println!("Configuration file created at: {}", config_path.display()); + } + } + + Ok(()) +} diff --git a/cli/src/utils/mod.rs b/cli/src/utils/mod.rs new file mode 100644 index 0000000..4c18e8c --- /dev/null +++ b/cli/src/utils/mod.rs @@ -0,0 +1,3 @@ +pub mod pretty_print; + +pub use pretty_print::*; diff --git a/cli/src/utils/pretty_print.rs b/cli/src/utils/pretty_print.rs new file mode 100644 index 0000000..b7c7b25 --- /dev/null +++ b/cli/src/utils/pretty_print.rs @@ -0,0 +1,84 @@ +use colored::Colorize; + +/// Helper function to pad a string to a specific width +pub fn pad_string(s: &str, width: usize) -> String { + if s.len() >= width { + s.to_string() + } else { + format!("{}{}", s, " ".repeat(width - s.len())) + } +} + +/// Struct to represent a column configuration for pretty printing +pub struct ColumnConfig { + pub header: String, + pub color: fn(&str) -> colored::ColoredString, + pub width: usize, +} + +/// Generic function to pretty print tabular data with formatted columns and colors +pub fn pretty_print_table( + title: &str, + version: &str, + columns: Vec, + rows: Vec>, + empty_message: &str, +) { + // Print the version information + println!("{} {}", title.purple().bold(), version); + println!(); + + if rows.is_empty() { + println!("{}", empty_message.yellow().italic()); + return; + } + + let column_spacing = 3; + + // Print header + let mut header_str = String::new(); + let mut divider_len = 0; + + for (i, col) in columns.iter().enumerate() { + if i < columns.len() - 1 { + header_str.push_str(&format!( + "{:width$}", + col.header.green().bold(), + width = col.width + column_spacing + )); + } else { + // Last column doesn't need padding + header_str.push_str(&col.header.green().bold().to_string()); + } + divider_len += col.width; + } + + // Add spacing between columns to divider length + divider_len += (columns.len() - 1) * column_spacing; + // Add extra padding for better visual appearance + divider_len += 30; + + println!("{header_str}"); + println!("{}", "-".repeat(divider_len)); + + // Print each row with the specified colors + for row in rows { + let mut row_str = String::new(); + + for (i, (value, col)) in row.iter().zip(columns.iter()).enumerate() { + if i < columns.len() - 1 { + let padded = pad_string(value, col.width); + row_str.push_str(&format!( + "{:width$}", + (col.color)(&padded), + width = col.width + column_spacing + )); + } else { + // Last column doesn't need padding + row_str.push_str(&(col.color)(value).to_string()); + } + } + + println!("{row_str}"); + } +} diff --git a/cli/tests/integration_test.rs b/cli/tests/integration_test.rs new file mode 100644 index 0000000..3fda28c --- /dev/null +++ b/cli/tests/integration_test.rs @@ -0,0 +1,17 @@ +#[cfg(test)] +mod tests { + use std::process::Command; + + #[test] + fn test_cli_help() { + let output = Command::new("cargo") + .args(["run", "--", "--help"]) + .output() + .expect("Failed to execute command"); + + assert!(output.status.success()); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("CLI client for the keys server")); + } +} diff --git a/cli/tests/unit/mod.rs b/cli/tests/unit/mod.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/cli/tests/unit/mod.rs @@ -0,0 +1 @@ + diff --git a/flake.lock b/flake.lock index 896f476..91de775 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1721924956, - "narHash": "sha256-Sb1jlyRO+N8jBXEX9Pg9Z1Qb8Bw9QyOgLDNMEpmjZ2M=", + "lastModified": 1757068644, + "narHash": "sha256-NOrUtIhTkIIumj1E/Rsv1J37Yi3xGStISEo8tZm3KW4=", "owner": "nixos", "repo": "nixpkgs", - "rev": "5ad6a14c6bf098e98800b091668718c336effc95", + "rev": "8eb28adfa3dc4de28e792e3bf49fcf9007ca8ac9", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 9b962a8..da33e14 100644 --- a/flake.nix +++ b/flake.nix @@ -13,6 +13,25 @@ ]; forAllSystems = f: genAttrs supportedSystems (system: f system); in { + packages = forAllSystems (system: + let pkgs = import nixpkgs { inherit system; }; + in { + default = pkgs.rustPlatform.buildRustPackage { + pname = "keys"; + version = "0.0.0"; + src = ./cli; + cargoLock.lockFile = ./cli/Cargo.lock; + + nativeBuildInputs = with pkgs; [ + pkg-config + ]; + + buildInputs = with pkgs; [ + openssl + ]; + }; + }); + devShells = forAllSystems (system: let pkgs = import nixpkgs { inherit system; }; in { @@ -21,6 +40,8 @@ buildInputs = with pkgs; [ deno doppler + rustc + cargo ]; }; });