diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc3b761..8861122 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,7 @@ jobs: - "3.10" - "3.12" # newest Python that is supported by pyspark steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v5 with: {fetch-depth: 0} # deep clone for git tag - uses: pdm-project/setup-pdm@v4 with: @@ -55,3 +55,29 @@ jobs: run: pdm run lint - name: Run python tests run: pdm run unit + + rust: + runs-on: ubuntu-latest + defaults: + run: + working-directory: libs/oauth-cli + env: + CARGO_TERM_COLOR: always + steps: + - uses: actions/checkout@v5 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + with: + workspaces: libs/oauth-cli + - name: Check formatting + run: cargo fmt --check + - name: Run clippy + run: cargo clippy -- -D warnings + - name: Build + run: cargo build --locked + - name: Run tests + run: cargo test --locked + - name: Audit dependencies + run: cargo install cargo-audit && cargo audit diff --git a/.gitignore b/.gitignore index e5ff6aa..8a487cd 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,6 @@ __about__.py # pdm .pdm-python .pdm-build + +# Rust +target/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4f1f522..97985e3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,4 +11,11 @@ repos: - id: ruff args: ["--fix","--exit-non-zero-on-fix","--show-fixes"] - id: ruff-format - +- repo: https://github.com/FeryET/pre-commit-rust + rev: v1.2.1 + hooks: + - id: fmt + args: ["--manifest-path", "libs/oauth-cli/Cargo.toml", "--"] + - id: cargo-check + args: ["--manifest-path", "libs/oauth-cli/Cargo.toml"] + diff --git a/docs/dev/plans/oauth-cli-code-signing.md b/docs/dev/plans/oauth-cli-code-signing.md new file mode 100644 index 0000000..7b98900 --- /dev/null +++ b/docs/dev/plans/oauth-cli-code-signing.md @@ -0,0 +1,285 @@ +# Research: Code Signing for foundry-dev-tools-oauth CLI + +## Problem + +macOS Keychain attaches an Access Control List (ACL) to each stored credential. For unsigned binaries, the ACL records the binary's path and hash. Every recompilation changes the hash, causing macOS to prompt "foundry-dev-tools-oauth wants to access your keychain" on first access after every update. + +Windows Credential Manager does **not** gate access by code signing identity -- it uses the logged-in user session. Code signing on Windows is only about SmartScreen reputation and user trust. + +## How macOS Keychain Identifies Applications + +Keychain does not match by file path or binary hash. It stores a **Designated Requirement (DR)** extracted from the binary's code signature. For a Developer ID-signed binary, the DR looks like: + +``` +identifier "com.yourcompany.foundry-dev-tools-oauth-cli" + and anchor apple generic + and certificate leaf[subject.OU] = "K1234ABCDE" +``` + +This checks: +1. The **code signing identifier** (the `--identifier` / `-i` value passed to `codesign`) +2. The certificate chain is rooted in Apple's CA +3. The leaf certificate is a Developer ID Application certificate +4. The **Team ID** (`subject.OU`) matches + +The DR does **not** depend on the binary hash, file path, file size, modification date, or specific certificate serial number. This means **any future build signed with the same identifier and Team ID is granted Keychain access silently**. + +### What would break Keychain access + +- Changing the code signing identifier +- Switching to a different Apple Developer account (different Team ID) +- Distributing an unsigned binary +- Switching certificate types (e.g., Developer ID to ad-hoc) + +## Requirements + +### macOS + +| Item | Notes | +|------|-------| +| Apple Developer Program | $99/year, mandatory, no free path | +| Developer ID Application certificate | Created in Apple Developer portal, valid 5 years | +| Stable code signing identifier | Must never change once users store Keychain items | +| Notarization | Required for Gatekeeper since macOS 10.15 | + +### Windows (optional) + +| Item | Notes | +|------|-------| +| OV code signing certificate | $200-500/year | +| Hardware-backed private key | Required since June 2023 (FIPS 140-2 Level 2) | +| Cloud HSM (Azure Key Vault or SSL.com eSigner) | Physical USB tokens impractical for CI | + +## Chosen Code Signing Identifier + +**This value is permanent. Changing it breaks Keychain access for all existing users.** + +``` +com.palantir.foundry-dev-tools-oauth-cli +``` + +## Certificate Setup (One-Time) + +### macOS + +1. Enroll in the Apple Developer Program at https://developer.apple.com ($99/year) +2. Go to Certificates, Identifiers & Profiles +3. Create a **Developer ID Application** certificate +4. Generate a CSR via Keychain Access on a Mac +5. Upload CSR, download the `.cer`, import into Keychain Access +6. Export as `.p12` (select cert + private key, right-click, "Export 2 items") +7. Base64-encode for GitHub Secrets: + ```bash + base64 -i DeveloperIDApplication.p12 -o cert_base64.txt + ``` +8. Generate an app-specific password at https://appleid.apple.com (for notarization) + +### Windows (if needed) + +1. Create an Azure Key Vault instance (Premium tier, ~$1/month) +2. Generate a certificate request with RSA-HSM key type +3. Submit CSR to a CA (DigiCert, Sectigo, SSL.com) for an OV code signing certificate +4. Merge the issued certificate back into Key Vault +5. Create an Azure AD service principal with Sign + Get permissions on the vault + +## GitHub Secrets + +### macOS (required) + +| Secret | Value | +|--------|-------| +| `MACOS_CERTIFICATE` | Base64-encoded `.p12` file | +| `MACOS_CERTIFICATE_PWD` | Password used when exporting `.p12` | +| `MACOS_CERTIFICATE_NAME` | `"Developer ID Application: Name (TEAMID)"` | +| `APPLE_ID` | Apple ID email address | +| `APPLE_TEAM_ID` | 10-character Team ID from developer.apple.com | +| `APPLE_APP_PASSWORD` | App-specific password from appleid.apple.com | +| `KEYCHAIN_PWD` | Any random password (for temporary CI keychain) | + +### Windows (optional) + +| Secret | Value | +|--------|-------| +| `AZURE_KEY_VAULT_URI` | `https://your-vault.vault.azure.net` | +| `AZURE_CLIENT_ID` | Service principal client ID | +| `AZURE_TENANT_ID` | Azure AD tenant ID | +| `AZURE_CLIENT_SECRET` | Service principal secret | +| `AZURE_CERT_NAME` | Certificate name in Key Vault | + +## GitHub Actions Workflow + +### macOS + +```yaml +jobs: + build-macos: + runs-on: macos-latest + strategy: + matrix: + target: [x86_64-apple-darwin, aarch64-apple-darwin] + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + run: rustup target add ${{ matrix.target }} + + - name: Import code signing certificate + uses: apple-actions/import-codesign-certs@v3 + with: + p12-file-base64: ${{ secrets.MACOS_CERTIFICATE }} + p12-password: ${{ secrets.MACOS_CERTIFICATE_PWD }} + + - name: Build + run: cargo build --release --target ${{ matrix.target }} + working-directory: libs/oauth-cli + + - name: Sign binary + run: | + codesign --sign "${{ secrets.MACOS_CERTIFICATE_NAME }}" \ + --force \ + --timestamp \ + --options runtime \ + --identifier "com.palantir.foundry-dev-tools-oauth-cli" \ + target/${{ matrix.target }}/release/foundry-dev-tools-oauth + working-directory: libs/oauth-cli + + - name: Verify signature + run: | + codesign --verify --verbose=2 \ + target/${{ matrix.target }}/release/foundry-dev-tools-oauth + codesign -d -r- \ + target/${{ matrix.target }}/release/foundry-dev-tools-oauth + working-directory: libs/oauth-cli + + - name: Notarize binary + run: | + xcrun notarytool store-credentials "notary-profile" \ + --apple-id "${{ secrets.APPLE_ID }}" \ + --team-id "${{ secrets.APPLE_TEAM_ID }}" \ + --password "${{ secrets.APPLE_APP_PASSWORD }}" + + cd libs/oauth-cli + zip -j foundry-dev-tools-oauth-${{ matrix.target }}.zip \ + target/${{ matrix.target }}/release/foundry-dev-tools-oauth + + xcrun notarytool submit \ + foundry-dev-tools-oauth-${{ matrix.target }}.zip \ + --keychain-profile "notary-profile" \ + --wait + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: foundry-dev-tools-oauth-${{ matrix.target }} + path: libs/oauth-cli/foundry-dev-tools-oauth-${{ matrix.target }}.zip +``` + +### Windows (optional) + +```yaml +jobs: + build-windows: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - name: Build + run: cargo build --release + working-directory: libs/oauth-cli + + - name: Sign binary with AzureSignTool + run: | + dotnet tool install --global AzureSignTool + AzureSignTool sign ` + -kvu "${{ secrets.AZURE_KEY_VAULT_URI }}" ` + -kvi "${{ secrets.AZURE_CLIENT_ID }}" ` + -kvt "${{ secrets.AZURE_TENANT_ID }}" ` + -kvs "${{ secrets.AZURE_CLIENT_SECRET }}" ` + -kvc "${{ secrets.AZURE_CERT_NAME }}" ` + -tr http://timestamp.digicert.com ` + -td sha256 ` + libs/oauth-cli/target/release/foundry-dev-tools-oauth.exe +``` + +### Linux (no signing needed) + +```yaml +jobs: + build-linux: + runs-on: ubuntu-latest + strategy: + matrix: + target: [x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu] + steps: + - uses: actions/checkout@v4 + + - name: Build + run: cargo build --release --target ${{ matrix.target }} + working-directory: libs/oauth-cli + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: foundry-dev-tools-oauth-${{ matrix.target }} + path: libs/oauth-cli/target/${{ matrix.target }}/release/foundry-dev-tools-oauth +``` + +## codesign Flags Reference + +| Flag | Purpose | +|------|---------| +| `--sign` / `-s` | Signing identity (full cert CN or Team ID hash) | +| `--force` / `-f` | Replace any existing signature | +| `--timestamp` | Embed secure timestamp (required for notarization, ensures signature validity after cert expiry) | +| `--options runtime` | Enable Hardened Runtime (required for notarization) | +| `--identifier` / `-i` | Code signing identifier in reverse-DNS format | + +## Notarization Notes + +- `xcrun notarytool submit` requires a `.zip`, `.pkg`, or `.dmg` -- not a bare Mach-O binary +- Processing typically takes 1-5 minutes, can take 30+ minutes +- `--wait` flag polls until complete +- **Stapling is not possible for bare Mach-O binaries** -- `xcrun stapler` only works on `.app`, `.pkg`, `.dmg` +- First-run Gatekeeper verification requires an internet connection (since stapling is not possible) +- To enable offline verification, distribute inside a `.pkg` and staple the ticket to the `.pkg` +- Check failure details: `xcrun notarytool log --keychain-profile "notary-profile"` + +## Certificate Renewal + +- Developer ID Application certificates are valid for **5 years** +- Renewal does **not** change the Team ID (it's tied to the Apple Developer account) +- Keychain ACLs continue to work after renewal without user prompts +- Update the `MACOS_CERTIFICATE` GitHub Secret with the new `.p12` +- Azure Key Vault credentials should be rotated every 90 days + +## Alternative: `rcodesign` (Cross-Platform Signing) + +The `apple-codesign` Rust crate provides `rcodesign`, an open-source reimplementation of Apple code signing and notarization that runs on Linux. This could allow signing on cheaper Linux runners instead of macOS runners. Still requires the Apple Developer Program membership for the certificate. + +Source: https://gregoryszorc.com/blog/2022/08/08/achieving-a-completely-open-source-implementation-of-apple-code-signing-and-notarization/ + +## Alternative: Homebrew Distribution + +`brew install` does not apply the quarantine attribute to binaries, which means Gatekeeper does not check the binary and Keychain prompts are handled differently. This is how `gh`, `ripgrep`, and `rustup` handle macOS distribution without code signing. If Homebrew is the primary distribution channel, code signing becomes less urgent (but still recommended for direct downloads). + +## Cost Summary + +| Item | Cost | Frequency | Required? | +|------|------|-----------|-----------| +| Apple Developer Program (Individual) | $99 | Annual | Yes (macOS Keychain) | +| Windows OV code signing certificate | $200-500 | Annual | No | +| Azure Key Vault (Premium) | ~$12 | Annual | Only for Windows | +| **Minimum (macOS only)** | **$99** | **Annual** | | +| **Both platforms** | **$310-610** | **Annual** | | + +## References + +- [Federico Terzi: macOS code signing in GitHub Actions](https://federicoterzi.com/blog/automatic-code-signing-and-notarization-for-macos-apps-using-github-actions/) +- [PaperAge: Notarizing Rust CLI binaries](https://www.randomerrata.com/articles/2024/notarize/) +- [Apple: Notarizing macOS software](https://developer.apple.com/documentation/security/notarizing-macos-software-before-distribution) +- [Tauri: macOS signing docs](https://v2.tauri.app/distribute/sign/macos/) +- [apple-actions/import-codesign-certs](https://github.com/marketplace/actions/import-code-signing-certificates) +- [taiki-e/upload-rust-binary-action](https://github.com/taiki-e/upload-rust-binary-action) +- [Windows code signing with EV cert on GitHub Actions](https://melatonin.dev/blog/how-to-code-sign-windows-installers-with-an-ev-cert-on-github-actions/) +- [Gregory Szorc: Open-source Apple code signing](https://gregoryszorc.com/blog/2022/08/08/achieving-a-completely-open-source-implementation-of-apple-code-signing-and-notarization/) +- [Apple TN2206: Code Signing In Depth](https://developer.apple.com/library/archive/technotes/tn2206/_index.html) diff --git a/libs/oauth-cli/Cargo.lock b/libs/oauth-cli/Cargo.lock new file mode 100644 index 0000000..0f71b75 --- /dev/null +++ b/libs/oauth-cli/Cargo.lock @@ -0,0 +1,2184 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core", +] + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + +[[package]] +name = "clap" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[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" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +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 = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[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", + "windows-sys 0.61.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 = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fd-lock" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" +dependencies = [ + "cfg-if", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "foundry-dev-tools-oauth-cli" +version = "0.1.0" +dependencies = [ + "base64", + "chrono", + "clap", + "dirs", + "fd-lock", + "keyring", + "open", + "rand", + "reqwest", + "serde", + "serde_json", + "serial_test", + "sha2", + "tempfile", + "thiserror", + "tiny_http", + "url", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "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 = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +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.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.90" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14dc6f6450b3f6d4ed5b16327f38fed626d375a886159ca555bd7822c0c3a5a6" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "log", + "security-framework 2.11.1", + "security-framework 3.7.0", + "windows-sys 0.60.2", + "zeroize", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags", + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework 3.7.0", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "open" +version = "5.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" +dependencies = [ + "is-wsl", + "libc", + "pathdiff", +] + +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags", + "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.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +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.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" +dependencies = [ + "chacha20", + "getrandom 0.4.1", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + +[[package]] +name = "reqwest" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scc" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" +dependencies = [ + "sdd", +] + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sdd" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[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 = "serial_test" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "911bd979bf1070a3f3aa7b691a3b3e9968f339ceeec89e08c280a8a22207a32f" +dependencies = [ + "futures-executor", + "futures-util", + "log", + "once_cell", + "parking_lot", + "scc", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[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.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[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 = "tempfile" +version = "3.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" +dependencies = [ + "fastrand", + "getrandom 0.4.1", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[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 = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +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 = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[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 = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[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 = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.113" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60722a937f594b7fde9adb894d7c092fc1bb6612897c46368d18e7a20208eff2" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a89f4650b770e4521aa6573724e2aed4704372151bd0de9d16a3bbabb87441a" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.113" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fac8c6395094b6b91c4af293f4c79371c163f9a6f56184d2c9a85f5a95f3950" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.113" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3fabce6159dc20728033842636887e4877688ae94382766e00b180abac9d60" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.113" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de0e091bdb824da87dc01d967388880d017a0a9bc4f3bdc0d86ee9f9336e3bb5" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.90" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "705eceb4ce901230f8625bd1d665128056ccbe4b7408faa625eec1ba80f59a97" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 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.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[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 = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/libs/oauth-cli/Cargo.toml b/libs/oauth-cli/Cargo.toml new file mode 100644 index 0000000..eb6d74d --- /dev/null +++ b/libs/oauth-cli/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "foundry-dev-tools-oauth-cli" +version = "0.1.0" +edition = "2021" +description = "Standalone OAuth2 CLI for Palantir Foundry" + +[[bin]] +name = "foundry-dev-tools-oauth" +path = "src/main.rs" + +[dependencies] +clap = { version = "4", features = ["derive"] } +reqwest = { version = "0.13", default-features = false, features = ["json", "blocking", "native-tls", "form"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +base64 = "0.22" +rand = "0.10" +tiny_http = "0.12" +open = "5" +dirs = "6" +url = "2" +chrono = { version = "0.4", features = ["serde"] } +fd-lock = "4" +keyring = { version = "3", features = ["apple-native", "windows-native"] } +thiserror = "2" + +[dev-dependencies] +tempfile = "3" +serial_test = "3" diff --git a/libs/oauth-cli/src/cache.rs b/libs/oauth-cli/src/cache.rs new file mode 100644 index 0000000..8694e41 --- /dev/null +++ b/libs/oauth-cli/src/cache.rs @@ -0,0 +1,525 @@ +use crate::error::{Error, Result}; +use crate::log; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +const LOCK_TIMEOUT_SECS: u64 = 30; +const KEYRING_SERVICE: &str = "foundry-dev-tools-oauth"; + +/// On-disk cache file (Linux only): maps hash keys to refresh tokens. +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct CacheFile { + pub tokens: HashMap, +} + +/// Compute the cache key: sha256 of "{hostname}\0{client_id}\0{sorted scopes space-joined}". +pub fn cache_key(hostname: &str, client_id: &str, scopes: &[String]) -> String { + let mut sorted_scopes = scopes.to_vec(); + sorted_scopes.sort(); + let input = format!("{}\0{}\0{}", hostname, client_id, sorted_scopes.join(" ")); + let hash = Sha256::digest(input.as_bytes()); + hex::encode(hash) +} + +/// Hex encoding (no extra dependency — small inline helper). +mod hex { + pub fn encode(bytes: impl AsRef<[u8]>) -> String { + bytes + .as_ref() + .iter() + .map(|b| format!("{:02x}", b)) + .collect() + } +} + +/// Whether to use OS keyring (macOS/Windows) or JSON file (Linux). +fn use_keyring() -> bool { + cfg!(target_os = "macos") || cfg!(target_os = "windows") +} + +// --------------------------------------------------------------------------- +// Keyring backend (macOS / Windows) +// --------------------------------------------------------------------------- + +fn keyring_load(key: &str) -> Result> { + let entry = keyring::Entry::new(KEYRING_SERVICE, key) + .map_err(|e| Error::Keyring(format!("failed to create keyring entry: {}", e)))?; + match entry.get_password() { + Ok(token) => Ok(Some(token)), + Err(keyring::Error::NoEntry) => Ok(None), + Err(e) => Err(Error::Keyring(format!( + "failed to read from keyring: {}", + e + ))), + } +} + +fn keyring_save(key: &str, refresh_token: &str) -> Result<()> { + let entry = keyring::Entry::new(KEYRING_SERVICE, key) + .map_err(|e| Error::Keyring(format!("failed to create keyring entry: {}", e)))?; + entry + .set_password(refresh_token) + .map_err(|e| Error::Keyring(format!("failed to save to keyring: {}", e))) +} + +fn keyring_delete(key: &str) -> Result { + let entry = keyring::Entry::new(KEYRING_SERVICE, key) + .map_err(|e| Error::Keyring(format!("failed to create keyring entry: {}", e)))?; + match entry.delete_credential() { + Ok(()) => Ok(true), + Err(keyring::Error::NoEntry) => Ok(false), + Err(e) => Err(Error::Keyring(format!( + "failed to delete from keyring: {}", + e + ))), + } +} + +// --------------------------------------------------------------------------- +// JSON file backend (Linux) +// --------------------------------------------------------------------------- + +/// Ensure the cache directory exists with 0o700 permissions (Unix). +/// Uses DirBuilder::mode() to set permissions at creation time, avoiding a TOCTOU race +/// between creation and chmod. +pub fn ensure_config_dir(config_dir: &Path) -> Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + match fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(config_dir) + { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(e) => { + return Err(Error::CacheDir { + path: config_dir.to_path_buf(), + source: e, + }) + } + } + } + + #[cfg(not(unix))] + { + match fs::create_dir_all(config_dir) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(e) => { + return Err(Error::CacheDir { + path: config_dir.to_path_buf(), + source: e, + }) + } + } + } + + Ok(()) +} + +fn cache_file_path(config_dir: &Path) -> PathBuf { + config_dir.join("oauth-cache.json") +} + +fn lock_file_path(config_dir: &Path) -> PathBuf { + config_dir.join("oauth-cache.lock") +} + +/// Read the cache file, returning the parsed structure. +fn read_cache_file(config_dir: &Path) -> Result { + let path = cache_file_path(config_dir); + if !path.exists() { + return Ok(CacheFile::default()); + } + let data = fs::read_to_string(&path).map_err(|e| Error::CacheIo { + path: path.clone(), + source: e, + })?; + serde_json::from_str(&data).map_err(|e| Error::CacheParse { path, source: e }) +} + +/// Write the cache file atomically with 0o600 permissions (Unix). +/// Uses OpenOptions::mode() to set permissions at creation time, avoiding a TOCTOU race +/// where the file is briefly world-readable between write and chmod. +fn write_cache_file(config_dir: &Path, cache: &CacheFile) -> Result<()> { + use std::io::Write; + + let path = cache_file_path(config_dir); + let data = serde_json::to_string_pretty(cache).expect("cache serialization cannot fail"); + + let mut opts = fs::OpenOptions::new(); + opts.write(true).create(true).truncate(true); + + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + + let mut file = opts.open(&path).map_err(|e| Error::CacheIo { + path: path.clone(), + source: e, + })?; + file.write_all(data.as_bytes()) + .map_err(|e| Error::CacheIo { + path: path.clone(), + source: e, + })?; + + Ok(()) +} + +fn file_load(config_dir: &Path, key: &str) -> Result> { + let cache = read_cache_file(config_dir)?; + Ok(cache.tokens.get(key).cloned()) +} + +fn file_save(config_dir: &Path, key: &str, refresh_token: &str) -> Result<()> { + ensure_config_dir(config_dir)?; + let mut cache = read_cache_file(config_dir)?; + cache + .tokens + .insert(key.to_string(), refresh_token.to_string()); + write_cache_file(config_dir, &cache) +} + +fn file_delete(config_dir: &Path, key: &str) -> Result { + let mut cache = read_cache_file(config_dir)?; + let removed = cache.tokens.remove(key).is_some(); + if removed { + write_cache_file(config_dir, &cache)?; + } + Ok(removed) +} + +// --------------------------------------------------------------------------- +// Public API — dispatches to keyring or file backend +// --------------------------------------------------------------------------- + +/// Load a cached refresh token for the given parameters. +pub fn load( + config_dir: &Path, + hostname: &str, + client_id: &str, + scopes: &[String], + debug: bool, +) -> Result> { + let key = cache_key(hostname, client_id, scopes); + + let result = if use_keyring() { + keyring_load(&key)? + } else { + file_load(config_dir, &key)? + }; + + match &result { + Some(_) => { + let backend = if use_keyring() { "keyring" } else { "file" }; + log::debug_log( + debug, + config_dir, + "CACHE_HIT", + &format!("refresh token found in {}", backend), + ); + } + None => { + log::debug_log( + debug, + config_dir, + "CACHE_MISS", + &format!("no refresh token for key {}", &key[..12]), + ); + } + } + + Ok(result) +} + +/// Save a refresh token to the cache. +pub fn save( + config_dir: &Path, + hostname: &str, + client_id: &str, + scopes: &[String], + refresh_token: &str, + debug: bool, +) -> Result<()> { + let key = cache_key(hostname, client_id, scopes); + + if use_keyring() { + keyring_save(&key, refresh_token)?; + } else { + file_save(config_dir, &key, refresh_token)?; + } + + let backend = if use_keyring() { "keyring" } else { "file" }; + log::debug_log( + debug, + config_dir, + "CACHE_SAVE", + &format!("refresh token saved to {}", backend), + ); + Ok(()) +} + +/// Delete the cached credential for the given parameters. +pub fn delete( + config_dir: &Path, + hostname: &str, + client_id: &str, + scopes: &[String], +) -> Result { + let key = cache_key(hostname, client_id, scopes); + + if use_keyring() { + keyring_delete(&key) + } else { + file_delete(config_dir, &key) + } +} + +/// Execute a closure while holding an exclusive file lock. +pub fn with_lock(config_dir: &Path, debug: bool, f: F) -> Result +where + F: FnOnce() -> Result, +{ + ensure_config_dir(config_dir)?; + + let lock_path = lock_file_path(config_dir); + let mut lock_opts = fs::OpenOptions::new(); + lock_opts.create(true).truncate(false).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + lock_opts.mode(0o600); + } + let lock_file = lock_opts.open(&lock_path).map_err(|e| Error::CacheIo { + path: lock_path.clone(), + source: e, + })?; + + // Try to acquire the lock with a timeout + let start = Instant::now(); + let timeout = Duration::from_secs(LOCK_TIMEOUT_SECS); + let mut lock = fd_lock::RwLock::new(lock_file); + + loop { + match lock.try_write() { + Ok(_guard) => { + log::debug_log( + debug, + config_dir, + "LOCK_ACQUIRED", + "exclusive lock acquired", + ); + let result = f(); + // _guard drops here, releasing the lock + return result; + } + Err(_) => { + if start.elapsed() >= timeout { + return Err(Error::LockTimeout { + seconds: LOCK_TIMEOUT_SECS, + }); + } + log::debug_log( + debug, + config_dir, + "LOCK_WAIT", + "waiting to acquire file lock", + ); + std::thread::sleep(Duration::from_millis(200)); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_cache_key_deterministic() { + let key1 = cache_key("host.example.com", "client123", &["offline_access".into()]); + let key2 = cache_key("host.example.com", "client123", &["offline_access".into()]); + assert_eq!(key1, key2); + } + + #[test] + fn test_cache_key_scope_order_independent() { + let key1 = cache_key( + "host.example.com", + "client123", + &["offline_access".into(), "api:read".into()], + ); + let key2 = cache_key( + "host.example.com", + "client123", + &["api:read".into(), "offline_access".into()], + ); + assert_eq!(key1, key2); + } + + #[test] + fn test_cache_key_different_hosts() { + let key1 = cache_key("host1.example.com", "client123", &["offline_access".into()]); + let key2 = cache_key("host2.example.com", "client123", &["offline_access".into()]); + assert_ne!(key1, key2); + } + + #[test] + fn test_cache_key_is_sha256_hex() { + let key = cache_key("host.example.com", "client123", &["offline_access".into()]); + assert_eq!(key.len(), 64); // sha256 hex = 64 chars + assert!(key.chars().all(|c| c.is_ascii_hexdigit())); + } + + // File-backend tests (always work, regardless of platform) + #[test] + fn test_file_cache_roundtrip() { + let dir = tempfile::tempdir().unwrap(); + let config_dir = dir.path(); + + let key = cache_key("host.example.com", "client123", &["offline_access".into()]); + + // Initially empty + assert!(file_load(config_dir, &key).unwrap().is_none()); + + // Save + file_save(config_dir, &key, "refresh_tok").unwrap(); + + // Load back + assert_eq!(file_load(config_dir, &key).unwrap().unwrap(), "refresh_tok"); + + // Delete + assert!(file_delete(config_dir, &key).unwrap()); + + // Gone + assert!(file_load(config_dir, &key).unwrap().is_none()); + } + + // Integration test using the public API (dispatches to keyring on macOS/Windows) + #[test] + fn test_cache_roundtrip() { + let dir = tempfile::tempdir().unwrap(); + let config_dir = dir.path(); + + // Initially empty + let result = load( + config_dir, + "host.example.com", + "client123", + &["offline_access".into()], + false, + ) + .unwrap(); + assert!(result.is_none()); + + // Save + save( + config_dir, + "host.example.com", + "client123", + &["offline_access".into()], + "refresh_tok", + false, + ) + .unwrap(); + + // Load back + let result = load( + config_dir, + "host.example.com", + "client123", + &["offline_access".into()], + false, + ) + .unwrap(); + assert_eq!(result.unwrap(), "refresh_tok"); + + // Delete + let removed = delete( + config_dir, + "host.example.com", + "client123", + &["offline_access".into()], + ) + .unwrap(); + assert!(removed); + + // Gone + let result = load( + config_dir, + "host.example.com", + "client123", + &["offline_access".into()], + false, + ) + .unwrap(); + assert!(result.is_none()); + } + + #[cfg(unix)] + #[test] + fn test_cache_file_permissions_0o600() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let config_dir = dir.path(); + let key = cache_key("host.example.com", "client123", &["offline_access".into()]); + + file_save(config_dir, &key, "secret_token").unwrap(); + + let path = cache_file_path(config_dir); + let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!( + mode, 0o600, + "cache file should have 0o600 permissions, got {:o}", + mode + ); + } + + #[cfg(unix)] + #[test] + fn test_config_dir_permissions_0o700() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let config_dir = dir.path().join("new-oauth-dir"); + + ensure_config_dir(&config_dir).unwrap(); + + let mode = fs::metadata(&config_dir).unwrap().permissions().mode() & 0o777; + assert_eq!( + mode, 0o700, + "config dir should have 0o700 permissions, got {:o}", + mode + ); + } + + #[cfg(unix)] + #[test] + fn test_lock_file_permissions_0o600() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let config_dir = dir.path(); + + // with_lock creates the lock file + with_lock(config_dir, false, || Ok(())).unwrap(); + + let path = lock_file_path(config_dir); + let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!( + mode, 0o600, + "lock file should have 0o600 permissions, got {:o}", + mode + ); + } +} diff --git a/libs/oauth-cli/src/cli.rs b/libs/oauth-cli/src/cli.rs new file mode 100644 index 0000000..5167d3e --- /dev/null +++ b/libs/oauth-cli/src/cli.rs @@ -0,0 +1,349 @@ +use crate::cache; +use crate::config::Config; +use crate::error::{Error, Result}; +use crate::log; +use crate::oauth; +use crate::server; +use std::io::{self, BufRead, Write}; + +/// Run the interactive login flow: open browser (or console mode), complete OAuth2, store tokens. +pub fn login(config: &Config) -> Result<()> { + log::debug_log( + config.debug, + &config.config_dir, + "STARTED", + &format!( + "login — hostname={}, scopes={}", + config.hostname, + config.scopes_str() + ), + ); + + let pkce = oauth::generate_pkce(); + let state = oauth::generate_state(); + + let (redirect_uri, code) = if config.no_browser { + // Console mode: user manually copies auth code + let redirect_uri = config.callback_url(); + let auth_url = oauth::build_authorization_url(config, &pkce, &state, &redirect_uri); + + eprintln!("Open this URL in your browser to authorize:"); + eprintln!(); + eprintln!(" {}", auth_url); + eprintln!(); + eprint!("Paste the authorization code here: "); + io::stderr().flush().ok(); + + let mut code = String::new(); + io::stdin() + .lock() + .read_line(&mut code) + .map_err(|_| Error::OAuthAuthorization("failed to read authorization code".into()))?; + let code = code.trim().to_string(); + if code.is_empty() { + return Err(Error::OAuthAuthorization("empty authorization code".into())); + } + + (redirect_uri, code) + } else { + // Browser mode: bind server first, then open browser, then wait for callback. + // This ordering ensures the port is listening before the browser redirects back. + log::debug_log( + config.debug, + &config.config_dir, + "LOGIN_TRIGGERED", + "starting browser-based login", + ); + + let callback_server = server::CallbackServer::bind(config.port)?; + let port = callback_server.port(); + let redirect_uri = config.local_redirect_uri(port); + let auth_url = oauth::build_authorization_url(config, &pkce, &state, &redirect_uri); + + // Open browser (best effort — print URL as fallback) + eprintln!("Opening browser for authentication..."); + if let Err(e) = open::that(&auth_url) { + eprintln!("Failed to open browser: {}", e); + eprintln!("Please open this URL manually:"); + eprintln!(" {}", auth_url); + } + + let callback = callback_server.wait_for_callback(300)?; + + // Validate state + if callback.state != state { + return Err(Error::StateMismatch { + expected: state, + got: callback.state, + }); + } + + (redirect_uri, callback.code) + }; + + // Exchange the authorization code for tokens + log::debug_log( + config.debug, + &config.config_dir, + "LOGIN_PENDING", + "exchanging authorization code for tokens", + ); + let token_resp = oauth::exchange_code(config, &code, &pkce.code_verifier, &redirect_uri)?; + + // Save refresh token + if let Some(ref refresh_token) = token_resp.refresh_token { + cache::with_lock(&config.config_dir, config.debug, || { + cache::save( + &config.config_dir, + &config.hostname, + &config.client_id, + &config.scopes, + refresh_token, + config.debug, + ) + })?; + } + + log::debug_log( + config.debug, + &config.config_dir, + "LOGIN_OK", + "login completed, refresh token saved", + ); + eprintln!("Login successful! Tokens cached for {}.", config.hostname); + + Ok(()) +} + +/// Get a fresh access token, refreshing via cached refresh_token. Outputs token to stdout. +pub fn token(config: &Config) -> Result<()> { + log::debug_log( + config.debug, + &config.config_dir, + "STARTED", + &format!( + "token — hostname={}, scopes={}", + config.hostname, + config.scopes_str() + ), + ); + + // Try refresh under the lock (fast path) + let result = cache::with_lock(&config.config_dir, config.debug, || { + refresh_cached_token(config) + }); + + let access_token = match result { + Ok(token) => token, + Err(Error::LoginRequired { .. }) | Err(Error::TokenRefresh { .. }) => { + // No cached token or refresh failed — try auto-login OUTSIDE the lock + // so the interactive browser flow doesn't hold the lock for 30+ seconds + if let Err(ref e) = result { + log::debug_log( + config.debug, + &config.config_dir, + "LOGIN_TRIGGERED", + &format!("attempting auto-login: {}", e), + ); + } + try_auto_login(config)? + } + Err(e) => return Err(e), + }; + + // Print access token to stdout (the ONLY thing that goes to stdout) + println!("{}", access_token); + log::debug_log( + config.debug, + &config.config_dir, + "TOKEN_OUTPUT", + "access token printed to stdout", + ); + log::debug_log(config.debug, &config.config_dir, "EXIT", "0"); + + Ok(()) +} + +/// Try to refresh using a cached token (called while holding the lock). +/// Returns LoginRequired if no cached token exists. +fn refresh_cached_token(config: &Config) -> Result { + let cached = cache::load( + &config.config_dir, + &config.hostname, + &config.client_id, + &config.scopes, + config.debug, + )?; + + match cached { + Some(refresh_tok) => { + log::debug_log( + config.debug, + &config.config_dir, + "REFRESH_START", + "sending refresh token request", + ); + + match oauth::refresh_token(config, &refresh_tok) { + Ok(resp) => { + // Save the rotated refresh token + if let Some(ref new_refresh) = resp.refresh_token { + cache::save( + &config.config_dir, + &config.hostname, + &config.client_id, + &config.scopes, + new_refresh, + config.debug, + )?; + } + log::debug_log( + config.debug, + &config.config_dir, + "REFRESH_OK", + "new access token received", + ); + Ok(resp.access_token) + } + Err(e) => { + log::debug_log( + config.debug, + &config.config_dir, + "REFRESH_FAIL", + &format!("{}", e), + ); + eprintln!("Token refresh failed: {}", e); + Err(e) + } + } + } + None => Err(Error::LoginRequired { + args: config.explicit_cli_args.clone(), + }), + } +} + +/// Attempt auto-login (interactive). If not possible, return LoginRequired error. +/// Called OUTSIDE the lock so the browser flow doesn't block other processes. +fn try_auto_login(config: &Config) -> Result { + // Check if we're in an interactive terminal + if !atty_is_terminal() { + let exe = std::env::current_exe() + .map(|p| p.display().to_string()) + .unwrap_or_else(|_| "foundry-dev-tools-oauth".to_string()); + // Intentionally print to stdout, not stderr: when this binary is used as an + // apiKeyHelper (e.g. by Claude Code), stdout is captured and displayed to the user. + // stderr is swallowed. Using stdout ensures the login instructions are visible. + println!("No cached credentials and not running interactively."); + println!( + "Run `{exe} login{args}` in a terminal first and restart the session.", + args = config.explicit_cli_args, + ); + return Err(Error::LoginRequired { + args: config.explicit_cli_args.clone(), + }); + } + + log::debug_log( + config.debug, + &config.config_dir, + "LOGIN_TRIGGERED", + "no valid token, starting interactive login", + ); + eprintln!("No cached token found. Starting login flow..."); + + // Run login flow (this may open a browser and wait — NOT under the lock) + login(config)?; + + // After login, refresh under the lock to get an access token + cache::with_lock(&config.config_dir, config.debug, || { + let refresh_tok = cache::load( + &config.config_dir, + &config.hostname, + &config.client_id, + &config.scopes, + config.debug, + )? + .ok_or(Error::LoginRequired { + args: config.explicit_cli_args.clone(), + })?; + + let resp = oauth::refresh_token(config, &refresh_tok)?; + if let Some(ref new_refresh) = resp.refresh_token { + cache::save( + &config.config_dir, + &config.hostname, + &config.client_id, + &config.scopes, + new_refresh, + config.debug, + )?; + } + + Ok(resp.access_token) + }) +} + +/// Check if running interactively (both stdin and stderr must be terminals). +/// When stdout is piped (e.g. apiKeyHelper in Claude Code), stdin won't be a terminal, +/// so we skip the interactive login flow and show an error instead. +fn atty_is_terminal() -> bool { + std::io::IsTerminal::is_terminal(&std::io::stdin()) + && std::io::IsTerminal::is_terminal(&std::io::stderr()) +} + +/// Show authentication status. +pub fn status(config: &Config) -> Result<()> { + let has_token = cache::load( + &config.config_dir, + &config.hostname, + &config.client_id, + &config.scopes, + config.debug, + )? + .is_some(); + + eprintln!(" Hostname: {}", config.hostname); + eprintln!(" Scopes: {}", config.scopes_str()); + eprintln!(" Has token: {}", if has_token { "yes" } else { "no" }); + + if !has_token { + let exe = std::env::current_exe() + .map(|p| p.display().to_string()) + .unwrap_or_else(|_| "foundry-dev-tools-oauth".to_string()); + eprintln!(); + eprintln!( + "Run `{exe} login{args}` to authenticate.", + args = config.explicit_cli_args + ); + } + + Ok(()) +} + +/// Clear stored credentials. +pub fn logout(config: &Config) -> Result<()> { + log::debug_log( + config.debug, + &config.config_dir, + "STARTED", + &format!("logout — hostname={}", config.hostname), + ); + + let removed = cache::with_lock(&config.config_dir, config.debug, || { + cache::delete( + &config.config_dir, + &config.hostname, + &config.client_id, + &config.scopes, + ) + })?; + + if removed { + eprintln!("Credentials for {} removed.", config.hostname); + } else { + eprintln!("No credentials found for {}.", config.hostname); + } + + Ok(()) +} diff --git a/libs/oauth-cli/src/config.rs b/libs/oauth-cli/src/config.rs new file mode 100644 index 0000000..ba2086e --- /dev/null +++ b/libs/oauth-cli/src/config.rs @@ -0,0 +1,387 @@ +use crate::error::{Error, Result}; +use std::path::PathBuf; + +/// Default OAuth2 scopes requested when none are explicitly provided. +pub const DEFAULT_SCOPES: &[&str] = &["offline_access", "api:read-data"]; + +/// Resolved configuration for the CLI. +#[derive(Debug, Clone)] +pub struct Config { + pub hostname: String, + pub client_id: String, + pub client_secret: Option, + pub scopes: Vec, + pub config_dir: PathBuf, + pub port: u16, + pub no_browser: bool, + pub debug: bool, + /// CLI flags that were explicitly passed (not from env vars), + /// formatted as command-line arguments for display in error messages. + pub explicit_cli_args: String, +} + +/// Raw values from CLI flags (all optional — flags override env vars). +#[derive(Debug, Default)] +pub struct CliFlags { + pub hostname: Option, + pub client_id: Option, + pub client_secret: Option, + pub scopes: Option, + pub port: Option, + pub no_browser: bool, + pub debug: bool, +} + +/// Resolve the OAuth config directory by checking for an existing `config.toml` +/// in the same candidate directories the Python side uses (config.py:63-66). +/// Returns `{config_parent}/oauth/` where `config_parent` is the first directory +/// containing a `config.toml`, or `~/.foundry-dev-tools/` as the default. +fn resolve_config_dir() -> PathBuf { + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); + + // Candidates in the same order as the Python side (config.py:63-66) + let candidates = [ + home.join(".foundry-dev-tools"), + home.join(".config").join("foundry-dev-tools"), + dirs::config_dir() + .unwrap_or_else(|| home.join(".config")) + .join("foundry-dev-tools"), + ]; + + // Use the first dir where config.toml exists (matches Python behavior) + for candidate in &candidates { + if candidate.join("config.toml").exists() { + return candidate.join("oauth"); + } + } + + // No config.toml found anywhere — default to ~/.foundry-dev-tools/oauth/ + candidates[0].join("oauth") +} + +/// Validate that the hostname is a plain domain name, not a URL or path-injected string. +/// Rejects any hostname containing characters that could cause URL injection when +/// interpolated into `https://{hostname}/multipass/api/...`. +fn validate_hostname(hostname: &str) -> Result<()> { + if hostname.is_empty() { + return Err(Error::InvalidHostname { + hostname: hostname.to_string(), + }); + } + + // Must contain only valid domain characters: alphanumeric, hyphens, dots + let is_valid = hostname + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.'); + + if !is_valid { + return Err(Error::InvalidHostname { + hostname: hostname.to_string(), + }); + } + + // Must not start or end with a dot or hyphen + if hostname.starts_with('.') + || hostname.starts_with('-') + || hostname.ends_with('.') + || hostname.ends_with('-') + { + return Err(Error::InvalidHostname { + hostname: hostname.to_string(), + }); + } + + Ok(()) +} + +impl Config { + /// Build a resolved Config by merging: CLI flags → FDT env vars → defaults. + pub fn resolve(flags: CliFlags) -> Result { + // Track which args were explicitly passed as CLI flags + let mut cli_args = Vec::new(); + + let hostname = flags + .hostname + .inspect(|h| cli_args.push(format!("--hostname {h}"))) + .or_else(|| std::env::var("FDT_CREDENTIALS__DOMAIN").ok()) + .ok_or(Error::MissingConfig( + "hostname (--hostname or FDT_CREDENTIALS__DOMAIN)", + ))?; + + validate_hostname(&hostname)?; + + let client_id = flags + .client_id + .inspect(|c| cli_args.push(format!("--client-id {c}"))) + .or_else(|| std::env::var("FDT_CREDENTIALS__OAUTH__CLIENT_ID").ok()) + .ok_or(Error::MissingConfig( + "client_id (--client-id or FDT_CREDENTIALS__OAUTH__CLIENT_ID)", + ))?; + + let client_secret = flags + .client_secret + .inspect(|s| cli_args.push(format!("--client-secret {s}"))) + .or_else(|| std::env::var("FDT_CREDENTIALS__OAUTH__CLIENT_SECRET").ok()); + + let scopes = flags + .scopes + .inspect(|s| cli_args.push(format!("--scopes \"{s}\""))) + .or_else(|| std::env::var("FDT_CREDENTIALS__OAUTH__SCOPES").ok()) + .map(|s| s.split_whitespace().map(String::from).collect()) + .unwrap_or_else(|| DEFAULT_SCOPES.iter().map(|s| String::from(*s)).collect()); + + let config_dir = resolve_config_dir(); + + let port = flags + .port + .inspect(|p| cli_args.push(format!("--port {p}"))) + .or_else(|| { + std::env::var("FDT_CREDENTIALS__OAUTH__PORT") + .ok() + .and_then(|s| s.parse().ok()) + }) + .unwrap_or(9876); + + if flags.no_browser { + cli_args.push("--no-browser".to_string()); + } + if flags.debug { + cli_args.push("--debug".to_string()); + } + + let debug = flags.debug + || std::env::var("FDT_CREDENTIALS__OAUTH__DEBUG") + .ok() + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false); + + let explicit_cli_args = if cli_args.is_empty() { + String::new() + } else { + format!(" {}", cli_args.join(" ")) + }; + + Ok(Config { + hostname, + client_id, + client_secret, + scopes, + config_dir, + port, + no_browser: flags.no_browser, + debug, + explicit_cli_args, + }) + } + + /// Scopes as a space-delimited string (for OAuth requests). + pub fn scopes_str(&self) -> String { + self.scopes.join(" ") + } + + /// Authorization endpoint URL. + pub fn authorize_url(&self) -> String { + format!("https://{}/multipass/api/oauth2/authorize", self.hostname) + } + + /// Token endpoint URL. + pub fn token_url(&self) -> String { + format!("https://{}/multipass/api/oauth2/token", self.hostname) + } + + /// Callback URL used for console (no-browser) mode. + pub fn callback_url(&self) -> String { + format!("https://{}/multipass/api/oauth2/callback", self.hostname) + } + + /// Local redirect URI for browser-based flow. + pub fn local_redirect_uri(&self, port: u16) -> String { + format!("http://127.0.0.1:{}/", port) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + + fn flags_with_required(hostname: &str, client_id: &str) -> CliFlags { + CliFlags { + hostname: Some(hostname.to_string()), + client_id: Some(client_id.to_string()), + ..Default::default() + } + } + + #[test] + #[serial] + fn test_resolve_with_flags() { + let config = Config::resolve(flags_with_required("host.example.com", "my-client")).unwrap(); + assert_eq!(config.hostname, "host.example.com"); + assert_eq!(config.client_id, "my-client"); + assert_eq!(config.scopes, vec!["offline_access", "api:read-data"]); + assert_eq!(config.port, 9876); + assert!(!config.debug); + assert!(!config.no_browser); + } + + #[test] + #[serial] + fn test_resolve_missing_hostname() { + let flags = CliFlags { + client_id: Some("id".into()), + ..Default::default() + }; + std::env::remove_var("FDT_CREDENTIALS__DOMAIN"); + let result = Config::resolve(flags); + assert!(result.is_err()); + } + + #[test] + #[serial] + fn test_resolve_missing_client_id() { + let flags = CliFlags { + hostname: Some("host".into()), + ..Default::default() + }; + std::env::remove_var("FDT_CREDENTIALS__OAUTH__CLIENT_ID"); + let result = Config::resolve(flags); + assert!(result.is_err()); + } + + #[test] + #[serial] + fn test_resolve_custom_scopes() { + let mut flags = flags_with_required("host", "id"); + flags.scopes = Some("api:read offline_access".into()); + let config = Config::resolve(flags).unwrap(); + assert_eq!(config.scopes, vec!["api:read", "offline_access"]); + } + + #[test] + #[serial] + fn test_resolve_custom_port() { + let mut flags = flags_with_required("host", "id"); + flags.port = Some(9999); + let config = Config::resolve(flags).unwrap(); + assert_eq!(config.port, 9999); + } + + #[test] + #[serial] + fn test_resolve_debug_flag() { + let mut flags = flags_with_required("host", "id"); + flags.debug = true; + let config = Config::resolve(flags).unwrap(); + assert!(config.debug); + } + + #[test] + #[serial] + fn test_scopes_str() { + let config = Config::resolve(flags_with_required("host", "id")).unwrap(); + assert_eq!(config.scopes_str(), "offline_access api:read-data"); + } + + #[test] + #[serial] + fn test_authorize_url() { + let config = Config::resolve(flags_with_required("foundry.example.com", "id")).unwrap(); + assert_eq!( + config.authorize_url(), + "https://foundry.example.com/multipass/api/oauth2/authorize" + ); + } + + #[test] + #[serial] + fn test_token_url() { + let config = Config::resolve(flags_with_required("foundry.example.com", "id")).unwrap(); + assert_eq!( + config.token_url(), + "https://foundry.example.com/multipass/api/oauth2/token" + ); + } + + #[test] + #[serial] + fn test_callback_url() { + let config = Config::resolve(flags_with_required("foundry.example.com", "id")).unwrap(); + assert_eq!( + config.callback_url(), + "https://foundry.example.com/multipass/api/oauth2/callback" + ); + } + + #[test] + #[serial] + fn test_local_redirect_uri() { + let config = Config::resolve(flags_with_required("host", "id")).unwrap(); + assert_eq!(config.local_redirect_uri(9876), "http://127.0.0.1:9876/"); + assert_eq!(config.local_redirect_uri(9000), "http://127.0.0.1:9000/"); + } + + // ---- hostname validation tests ---- + + #[test] + fn test_validate_hostname_valid() { + assert!(validate_hostname("foundry.example.com").is_ok()); + assert!(validate_hostname("my-host.example.co.uk").is_ok()); + assert!(validate_hostname("localhost").is_ok()); + assert!(validate_hostname("a").is_ok()); + assert!(validate_hostname("host-1.test").is_ok()); + } + + #[test] + fn test_validate_hostname_rejects_empty() { + assert!(validate_hostname("").is_err()); + } + + #[test] + fn test_validate_hostname_rejects_slashes() { + // URL injection: evil.com/steal?x= would redirect credentials + assert!(validate_hostname("evil.com/steal?x=").is_err()); + assert!(validate_hostname("host/path").is_err()); + } + + #[test] + fn test_validate_hostname_rejects_colons() { + assert!(validate_hostname("host:8080").is_err()); + assert!(validate_hostname("https://host").is_err()); + } + + #[test] + fn test_validate_hostname_rejects_query_and_fragment() { + assert!(validate_hostname("host?query=1").is_err()); + assert!(validate_hostname("host#fragment").is_err()); + } + + #[test] + fn test_validate_hostname_rejects_at_sign() { + // Userinfo injection: user@evil.com + assert!(validate_hostname("user@evil.com").is_err()); + } + + #[test] + fn test_validate_hostname_rejects_spaces_and_special() { + assert!(validate_hostname("host name").is_err()); + assert!(validate_hostname("host\tname").is_err()); + assert!(validate_hostname("host\nname").is_err()); + } + + #[test] + fn test_validate_hostname_rejects_leading_trailing_dot_hyphen() { + assert!(validate_hostname(".example.com").is_err()); + assert!(validate_hostname("example.com.").is_err()); + assert!(validate_hostname("-example.com").is_err()); + assert!(validate_hostname("example.com-").is_err()); + } + + #[test] + #[serial] + fn test_resolve_rejects_injected_hostname() { + let flags = flags_with_required("evil.com/steal?x=", "id"); + let result = Config::resolve(flags); + assert!(result.is_err()); + } +} diff --git a/libs/oauth-cli/src/error.rs b/libs/oauth-cli/src/error.rs new file mode 100644 index 0000000..0b6fa04 --- /dev/null +++ b/libs/oauth-cli/src/error.rs @@ -0,0 +1,80 @@ +use std::path::PathBuf; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + // Configuration errors + #[error("missing required configuration: {0}")] + MissingConfig(&'static str), + + #[error("invalid hostname '{hostname}': must be a plain domain name (no slashes, ports, or path components)")] + InvalidHostname { hostname: String }, + + // OAuth errors + #[error("OAuth authorization failed: {0}")] + OAuthAuthorization(String), + + #[error("token exchange failed (HTTP {status}): {body}")] + TokenExchange { status: u16, body: String }, + + #[error("token refresh failed (HTTP {status}): {body}")] + TokenRefresh { status: u16, body: String }, + + #[error("state mismatch: expected {expected}, got {got}")] + StateMismatch { expected: String, got: String }, + + // Server errors + #[error("failed to bind callback server on {addr}: {source}")] + ServerBind { + addr: String, + source: std::io::Error, + }, + + #[error("callback server timed out waiting for authorization code")] + ServerTimeout, + + #[error("callback server error: {0}")] + ServerCallback(String), + + // Cache / storage errors + #[error("cache directory error ({path}): {source}")] + CacheDir { + path: PathBuf, + source: std::io::Error, + }, + + #[error("cache file I/O error ({path}): {source}")] + CacheIo { + path: PathBuf, + source: std::io::Error, + }, + + #[error("cache file parse error ({path}): {source}")] + CacheParse { + path: PathBuf, + source: serde_json::Error, + }, + + #[error("failed to acquire file lock after {seconds}s")] + LockTimeout { seconds: u64 }, + + #[error("keyring error: {0}")] + Keyring(String), + + // HTTP / network errors + #[error("HTTP request failed: {0}")] + Http(#[from] reqwest::Error), + + // General I/O + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + + // Login required (no cached token, non-interactive context) + #[error("no cached credentials found — run `{exe} login{args}` first", exe = std::env::current_exe().map(|p| p.display().to_string()).unwrap_or_else(|_| "foundry-dev-tools-oauth".into()))] + LoginRequired { + /// Explicit CLI args to include in the error message (e.g. " --hostname foo"). + /// Empty string when no extra args are needed. + args: String, + }, +} + +pub type Result = std::result::Result; diff --git a/libs/oauth-cli/src/log.rs b/libs/oauth-cli/src/log.rs new file mode 100644 index 0000000..4ce0ddb --- /dev/null +++ b/libs/oauth-cli/src/log.rs @@ -0,0 +1,29 @@ +use std::fs::OpenOptions; +use std::io::Write; +use std::path::Path; + +/// Append a debug log entry to the debug log file if debug mode is enabled. +pub fn debug_log(enabled: bool, config_dir: &Path, event: &str, message: &str) { + if !enabled { + return; + } + + let log_path = config_dir.join("oauth-debug.log"); + let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S%.3f"); + + let line = format!("[{}] {} — {}\n", timestamp, event, message); + + // Best-effort: silently ignore write failures for debug logging + let mut opts = OpenOptions::new(); + opts.create(true).append(true); + + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + + if let Ok(mut file) = opts.open(&log_path) { + let _ = file.write_all(line.as_bytes()); + } +} diff --git a/libs/oauth-cli/src/main.rs b/libs/oauth-cli/src/main.rs new file mode 100644 index 0000000..75f5107 --- /dev/null +++ b/libs/oauth-cli/src/main.rs @@ -0,0 +1,110 @@ +mod cache; +mod cli; +mod config; +mod error; +mod log; +mod oauth; +mod server; + +use clap::{Parser, Subcommand}; +use config::CliFlags; +use error::Error; +use std::process; + +#[derive(Parser)] +#[command( + name = "foundry-dev-tools-oauth", + about = "OAuth2 CLI for Palantir Foundry — obtain and manage Bearer tokens", + version +)] +struct Cli { + #[command(subcommand)] + command: Command, + + /// Foundry hostname (e.g., foundry.example.com) + #[arg(long, global = true)] + hostname: Option, + + /// OAuth2 client ID + #[arg(long, global = true)] + client_id: Option, + + /// OAuth2 client secret (for confidential clients) + #[arg(long, global = true)] + client_secret: Option, + + /// OAuth2 scopes (space-separated, default: "offline_access api:read-data") + #[arg(long, global = true)] + scopes: Option, + + /// Local server port for OAuth callback (default: 9876) + #[arg(long, global = true)] + port: Option, + + /// Enable debug logging to {config_dir}/oauth-debug.log + #[arg(long, global = true)] + debug: bool, + + /// Use console mode instead of browser (for headless/SSH environments) + #[arg(long, global = true)] + no_browser: bool, +} + +#[derive(Subcommand)] +enum Command { + /// Interactive login: open browser, complete OAuth2 flow, store refresh token + Login, + + /// Output a fresh access token to stdout (refresh if needed) + Token, + + /// Show authentication status and cached credentials + Status, + + /// Clear stored credentials + Logout, +} + +fn main() { + let cli = Cli::parse(); + + let flags = CliFlags { + hostname: cli.hostname, + client_id: cli.client_id, + client_secret: cli.client_secret, + scopes: cli.scopes, + port: cli.port, + no_browser: cli.no_browser, + debug: cli.debug, + }; + + let config = match config::Config::resolve(flags) { + Ok(c) => c, + Err(e) => { + eprintln!("Configuration error: {}", e); + process::exit(1); + } + }; + + let result = match cli.command { + Command::Login => cli::login(&config), + Command::Token => cli::token(&config), + Command::Status => cli::status(&config), + Command::Logout => cli::logout(&config), + }; + + if let Err(e) = result { + log::debug_log( + config.debug, + &config.config_dir, + "EXIT", + &format!("1 — {}", e), + ); + // LoginRequired already printed a helpful message to stdout in try_auto_login, + // so skip the redundant stderr error for that case. + if !matches!(e, Error::LoginRequired { .. }) { + eprintln!("Error: {}", e); + } + process::exit(1); + } +} diff --git a/libs/oauth-cli/src/oauth.rs b/libs/oauth-cli/src/oauth.rs new file mode 100644 index 0000000..d5e6934 --- /dev/null +++ b/libs/oauth-cli/src/oauth.rs @@ -0,0 +1,228 @@ +use crate::config::Config; +use crate::error::{Error, Result}; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use rand::RngExt; +use serde::Deserialize; +use sha2::{Digest, Sha256}; + +/// PKCE pair: code_verifier and the derived code_challenge. +#[derive(Debug)] +pub struct Pkce { + pub code_verifier: String, + pub code_challenge: String, +} + +/// Token response from the Foundry OAuth2 token endpoint. +#[derive(Debug, Deserialize)] +pub struct TokenResponse { + pub access_token: String, + pub refresh_token: Option, +} + +/// Characters allowed in a PKCE code_verifier (RFC 7636 §4.1). +const PKCE_CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"; + +/// Generate a PKCE code_verifier and code_challenge (S256). +pub fn generate_pkce() -> Pkce { + let mut rng = rand::rng(); + let verifier: String = (0..128) + .map(|_| { + let idx = rng.random_range(0..PKCE_CHARS.len()); + PKCE_CHARS[idx] as char + }) + .collect(); + + let digest = Sha256::digest(verifier.as_bytes()); + let challenge = URL_SAFE_NO_PAD.encode(digest); + + Pkce { + code_verifier: verifier, + code_challenge: challenge, + } +} + +/// Generate a random state parameter for CSRF protection. +pub fn generate_state() -> String { + let mut rng = rand::rng(); + let bytes: Vec = (0..32).map(|_| rng.random()).collect(); + URL_SAFE_NO_PAD.encode(&bytes) +} + +/// Build the full authorization URL for the browser redirect. +pub fn build_authorization_url( + config: &Config, + pkce: &Pkce, + state: &str, + redirect_uri: &str, +) -> String { + let scopes = config.scopes_str(); + let query = url::form_urlencoded::Serializer::new(String::new()) + .append_pair("response_type", "code") + .append_pair("client_id", &config.client_id) + .append_pair("redirect_uri", redirect_uri) + .append_pair("scope", &scopes) + .append_pair("state", state) + .append_pair("code_challenge", &pkce.code_challenge) + .append_pair("code_challenge_method", "S256") + .finish(); + + format!("{}?{}", config.authorize_url(), query) +} + +/// Exchange an authorization code for tokens. +pub fn exchange_code( + config: &Config, + code: &str, + code_verifier: &str, + redirect_uri: &str, +) -> Result { + let mut params: Vec<(&str, &str)> = vec![ + ("grant_type", "authorization_code"), + ("code", code), + ("redirect_uri", redirect_uri), + ("client_id", &config.client_id), + ("code_verifier", code_verifier), + ]; + if let Some(ref secret) = config.client_secret { + params.push(("client_secret", secret)); + } + + let resp = http_client() + .post(config.token_url()) + .form(¶ms) + .send()?; + + let status = resp.status().as_u16(); + if status != 200 { + let body = resp.text().unwrap_or_default(); + return Err(Error::TokenExchange { status, body }); + } + + Ok(resp.json()?) +} + +/// Refresh an access token using a refresh_token. +pub fn refresh_token(config: &Config, refresh_tok: &str) -> Result { + let mut params: Vec<(&str, &str)> = vec![ + ("grant_type", "refresh_token"), + ("refresh_token", refresh_tok), + ("client_id", &config.client_id), + ]; + if let Some(ref secret) = config.client_secret { + params.push(("client_secret", secret)); + } + + let resp = http_client() + .post(config.token_url()) + .form(¶ms) + .send()?; + + let status = resp.status().as_u16(); + if status != 200 { + let body = resp.text().unwrap_or_default(); + return Err(Error::TokenRefresh { status, body }); + } + + Ok(resp.json()?) +} + +/// Shared HTTP client. Reuses connection pool and TLS sessions across requests. +/// Redirects are disabled per RFC 6749 §3.2 / OAuth 2.0 Security BCP §4.11: +/// token endpoint requests must not follow redirects, as doing so could leak +/// credentials (auth codes, client secrets, PKCE verifiers) to the redirect target. +fn http_client() -> reqwest::blocking::Client { + use std::sync::OnceLock; + static CLIENT: OnceLock = OnceLock::new(); + CLIENT + .get_or_init(|| { + reqwest::blocking::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .expect("failed to build HTTP client") + }) + .clone() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_pkce_verifier_length() { + let pkce = generate_pkce(); + assert_eq!(pkce.code_verifier.len(), 128); + } + + #[test] + fn test_generate_pkce_verifier_chars() { + let pkce = generate_pkce(); + let allowed: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"; + for c in pkce.code_verifier.chars() { + assert!( + allowed.contains(c), + "unexpected char '{}' in code_verifier", + c + ); + } + } + + #[test] + fn test_generate_pkce_challenge_is_base64url() { + let pkce = generate_pkce(); + // base64url_no_pad of sha256(128 bytes) = 43 chars + assert_eq!(pkce.code_challenge.len(), 43); + // Should only contain base64url chars + for c in pkce.code_challenge.chars() { + assert!( + c.is_ascii_alphanumeric() || c == '-' || c == '_', + "unexpected char '{}' in code_challenge", + c + ); + } + } + + #[test] + fn test_generate_pkce_challenge_is_sha256_of_verifier() { + let pkce = generate_pkce(); + let expected_digest = sha2::Sha256::digest(pkce.code_verifier.as_bytes()); + let expected_challenge = URL_SAFE_NO_PAD.encode(expected_digest); + assert_eq!(pkce.code_challenge, expected_challenge); + } + + #[test] + fn test_generate_state_unique() { + let s1 = generate_state(); + let s2 = generate_state(); + assert_ne!(s1, s2); + } + + #[test] + fn test_build_authorization_url() { + let config = Config { + hostname: "foundry.example.com".into(), + client_id: "my-client-id".into(), + client_secret: None, + scopes: vec!["offline_access".into()], + config_dir: "/tmp/test".into(), + port: 8888, + no_browser: false, + debug: false, + explicit_cli_args: String::new(), + }; + let pkce = Pkce { + code_verifier: "test-verifier".into(), + code_challenge: "test-challenge".into(), + }; + let url = build_authorization_url(&config, &pkce, "test-state", "http://127.0.0.1:8888/"); + + assert!(url.starts_with("https://foundry.example.com/multipass/api/oauth2/authorize?")); + assert!(url.contains("response_type=code")); + assert!(url.contains("client_id=my-client-id")); + assert!(url.contains("redirect_uri=http")); + assert!(url.contains("scope=offline_access")); + assert!(url.contains("state=test-state")); + assert!(url.contains("code_challenge=test-challenge")); + assert!(url.contains("code_challenge_method=S256")); + } +} diff --git a/libs/oauth-cli/src/server.rs b/libs/oauth-cli/src/server.rs new file mode 100644 index 0000000..5a7ea2b --- /dev/null +++ b/libs/oauth-cli/src/server.rs @@ -0,0 +1,250 @@ +use crate::error::{Error, Result}; +use std::collections::HashMap; +use std::time::Duration; +use url::Url; + +/// Result of the callback server: the authorization code and state parameter. +pub struct CallbackResult { + pub code: String, + pub state: String, +} + +const SUCCESS_HTML: &str = r#" + +Authorization Successful + +

Authorization successful!

+

You can close this browser tab and return to the terminal.

+ +"#; + +const ERROR_HTML: &str = r#" + +Authorization Failed + +

Authorization failed

+

Missing authorization code. Please try again.

+ +"#; + +/// A bound callback server ready to accept the OAuth redirect. +pub struct CallbackServer { + server: tiny_http::Server, + port: u16, +} + +impl CallbackServer { + /// Bind a local HTTP server on 127.0.0.1:{port}. + /// Call this before opening the browser so the port is ready to receive the callback. + pub fn bind(port: u16) -> Result { + let addr = format!("127.0.0.1:{}", port); + let server = tiny_http::Server::http(&addr).map_err(|e| Error::ServerBind { + addr, + source: std::io::Error::new(std::io::ErrorKind::AddrInUse, e.to_string()), + })?; + // Resolve the actual bound port (important when port 0 is used for OS-assigned ports) + let actual_port = match server.server_addr() { + tiny_http::ListenAddr::IP(addr) => addr.port(), + _ => port, + }; + Ok(Self { + server, + port: actual_port, + }) + } + + /// The port this server is bound to. + pub fn port(&self) -> u16 { + self.port + } + + /// Wait for the OAuth callback, blocking up to `timeout_secs`. + /// Only accepts GET requests to "/" (the expected redirect path). + /// Ignores unrelated requests (e.g. from browser extensions, favicon fetches) + /// and keeps waiting until a valid callback arrives or the timeout expires. + pub fn wait_for_callback(self, timeout_secs: u64) -> Result { + let deadline = std::time::Instant::now() + Duration::from_secs(timeout_secs); + + loop { + let remaining = deadline + .checked_duration_since(std::time::Instant::now()) + .ok_or(Error::ServerTimeout)?; + + let request = self + .server + .recv_timeout(remaining) + .map_err(|_| Error::ServerTimeout)? + .ok_or(Error::ServerTimeout)?; + + // Only accept GET requests + if request.method() != &tiny_http::Method::Get { + respond_html(request, ERROR_HTML); + continue; + } + + // Only accept requests to the root path (with query string) + let request_url = request.url(); + if !request_url.starts_with("/?") && request_url != "/" { + respond_html(request, ERROR_HTML); + continue; + } + + let url_str = format!("http://127.0.0.1:{}{}", self.port, request_url); + let parsed = Url::parse(&url_str).map_err(|e| Error::ServerCallback(e.to_string()))?; + let params: HashMap = parsed.query_pairs().into_owned().collect(); + + if let (Some(code), Some(state)) = (params.get("code"), params.get("state")) { + respond_html(request, SUCCESS_HTML); + return Ok(CallbackResult { + code: code.clone(), + state: state.clone(), + }); + } else if let Some(error) = params.get("error") { + let desc = params.get("error_description").cloned().unwrap_or_default(); + respond_html(request, ERROR_HTML); + return Err(Error::OAuthAuthorization(format!("{}: {}", error, desc))); + } else { + // Missing code/state but on the right path — could be a partial redirect. + // Keep waiting rather than failing, in case the real callback follows. + respond_html(request, ERROR_HTML); + continue; + } + } + } +} + +fn respond_html(request: tiny_http::Request, body: &str) { + let response = tiny_http::Response::from_string(body).with_header( + tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..]) + .unwrap(), + ); + let _ = request.respond(response); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{Read, Write}; + use std::net::TcpStream; + + /// Helper: send a raw HTTP request to the server and return the response. + fn send_raw_request(port: u16, request: &str) -> String { + let mut stream = TcpStream::connect(format!("127.0.0.1:{}", port)).unwrap(); + stream.write_all(request.as_bytes()).unwrap(); + stream.flush().unwrap(); + let mut response = String::new(); + // Read with a short timeout so we don't block forever + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let _ = stream.read_to_string(&mut response); + response + } + + #[test] + fn test_callback_accepts_valid_get_with_code_and_state() { + let server = CallbackServer::bind(0).unwrap(); + let port = server.port(); + + let handle = std::thread::spawn(move || server.wait_for_callback(5)); + + send_raw_request( + port, + "GET /?code=test_code&state=test_state HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", + ); + + let result = handle.join().unwrap().unwrap(); + assert_eq!(result.code, "test_code"); + assert_eq!(result.state, "test_state"); + } + + #[test] + fn test_callback_ignores_post_then_accepts_valid_get() { + let server = CallbackServer::bind(0).unwrap(); + let port = server.port(); + + let handle = std::thread::spawn(move || server.wait_for_callback(5)); + + // First: POST request — should be ignored + send_raw_request( + port, + "POST /?code=bad&state=bad HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Length: 0\r\n\r\n", + ); + + // Then: valid GET + send_raw_request( + port, + "GET /?code=real_code&state=real_state HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", + ); + + let result = handle.join().unwrap().unwrap(); + assert_eq!(result.code, "real_code"); + assert_eq!(result.state, "real_state"); + } + + #[test] + fn test_callback_ignores_favicon_then_accepts_valid_get() { + let server = CallbackServer::bind(0).unwrap(); + let port = server.port(); + + let handle = std::thread::spawn(move || server.wait_for_callback(5)); + + // First: favicon request — wrong path, should be ignored + send_raw_request(port, "GET /favicon.ico HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n"); + + // Then: valid callback + send_raw_request( + port, + "GET /?code=abc&state=xyz HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", + ); + + let result = handle.join().unwrap().unwrap(); + assert_eq!(result.code, "abc"); + assert_eq!(result.state, "xyz"); + } + + #[test] + fn test_callback_ignores_root_without_params_then_accepts_valid() { + let server = CallbackServer::bind(0).unwrap(); + let port = server.port(); + + let handle = std::thread::spawn(move || server.wait_for_callback(5)); + + // GET / with no query params — should be ignored (missing code/state) + send_raw_request(port, "GET / HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n"); + + // Then: valid callback + send_raw_request( + port, + "GET /?code=c&state=s HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", + ); + + let result = handle.join().unwrap().unwrap(); + assert_eq!(result.code, "c"); + assert_eq!(result.state, "s"); + } + + #[test] + fn test_callback_returns_error_on_oauth_error_response() { + let server = CallbackServer::bind(0).unwrap(); + let port = server.port(); + + let handle = std::thread::spawn(move || server.wait_for_callback(5)); + + send_raw_request( + port, + "GET /?error=access_denied&error_description=user+denied HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", + ); + + let result = handle.join().unwrap(); + assert!(result.is_err()); + } + + #[test] + fn test_callback_timeout() { + let server = CallbackServer::bind(0).unwrap(); + // Very short timeout — should expire with no requests + let result = server.wait_for_callback(1); + assert!(result.is_err()); + } +}