From 9f8866f7707bcd83e9496379d80a5b7bd010d0ca Mon Sep 17 00:00:00 2001 From: xscriptor Date: Sat, 25 Apr 2026 16:09:18 +0200 Subject: [PATCH 1/3] update docs fix first screen --- .gitignore | 2 ++ README.md | 3 +++ assets/gitnapse-icon.png | Bin 0 -> 98662 bytes 3 files changed, 5 insertions(+) create mode 100644 assets/gitnapse-icon.png diff --git a/.gitignore b/.gitignore index ea8c4bf..21e0601 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ /target +.env +*.pem \ No newline at end of file diff --git a/README.md b/README.md index ee5d4e9..e7471ee 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,9 @@ wget -qO- https://raw.githubusercontent.com/xscriptor/gitnapse/main/scripts/inst

X

+
+ +
X Web diff --git a/assets/gitnapse-icon.png b/assets/gitnapse-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..9d9b849d6b53fd0f299bf7761e5ff972b727662a GIT binary patch literal 98662 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 Date: Sat, 25 Apr 2026 22:57:25 +0200 Subject: [PATCH 2/3] upload docs - update oauthmodules - link docs - upload tests - upload logo --- .github/workflows/release.yml | 171 +++++ .github/workflows/security.yml | 36 + CODE_OF_CONDUCT.md | 33 + CONTRIBUTING.md | 41 ++ Cargo.lock | 1160 +++++++++++++++++++++++++++++++- Cargo.toml | 16 +- README.md | 27 +- SECURITY.md | 28 + docs/ARCHITECTURE.md | 5 + docs/COLLABORATIVE_SECTION.md | 72 ++ docs/IMPLEMENTATION_LOG.md | 2 + docs/OAUTH_AUTHENTICATION.md | 132 ++++ docs/RELEASE_WORKFLOW.md | 66 ++ docs/USAGE.md | 32 + docs/tests/README.md | 33 + docs/tests/SECURITY_AUDIT.md | 31 + docs/tests/TEST_COVERAGE.md | 17 + src/app/mod.rs | 173 +++-- src/app/render.rs | 56 +- src/app/theme.rs | 23 +- src/auth.rs | 89 ++- src/cache.rs | 3 +- src/config.rs | 17 +- src/github.rs | 141 +++- src/main.rs | 49 +- src/oauth.rs | 165 +++++ src/oauth_session.rs | 232 +++++++ src/secure_store.rs | 158 +++++ src/syntax.rs | 40 +- tests/auth_precedence_tests.rs | 26 + tests/github_search_tests.rs | 138 ++++ tests/secure_store_tests.rs | 61 ++ 32 files changed, 3134 insertions(+), 139 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/security.yml create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md create mode 100644 docs/COLLABORATIVE_SECTION.md create mode 100644 docs/OAUTH_AUTHENTICATION.md create mode 100644 docs/RELEASE_WORKFLOW.md create mode 100644 docs/tests/README.md create mode 100644 docs/tests/SECURITY_AUDIT.md create mode 100644 docs/tests/TEST_COVERAGE.md create mode 100644 src/oauth.rs create mode 100644 src/oauth_session.rs create mode 100644 src/secure_store.rs create mode 100644 tests/auth_precedence_tests.rs create mode 100644 tests/github_search_tests.rs create mode 100644 tests/secure_store_tests.rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..3734ac6 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,171 @@ +name: Release + +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + release_tag: + description: "Existing tag to publish (for example: v1.2.3)" + required: true + type: string + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref_name }} + +jobs: + build-linux-ubuntu: + name: Build Linux (Ubuntu) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Build + run: cargo build --release --locked + - name: Package + run: | + asset="gitnapse-${RELEASE_TAG}-linux-ubuntu-x86_64.tar.gz" + tar -C target/release -czf "${asset}" gitnapse + echo "ASSET=${asset}" >> "$GITHUB_ENV" + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: asset-linux-ubuntu + path: ${{ env.ASSET }} + if-no-files-found: error + retention-days: 7 + + build-linux-arch: + name: Build Linux (Arch) + runs-on: ubuntu-latest + container: + image: archlinux:latest + steps: + - name: Install build dependencies + run: pacman -Syu --noconfirm --needed base-devel curl ca-certificates git openssl pkgconf + - name: Install Rust + run: | + curl https://sh.rustup.rs -sSf | sh -s -- -y --profile minimal + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + - name: Checkout + uses: actions/checkout@v4 + - name: Build + run: cargo build --release --locked + - name: Package + run: | + asset="gitnapse-${RELEASE_TAG}-linux-arch-x86_64.tar.gz" + tar -C target/release -czf "${asset}" gitnapse + echo "ASSET=${asset}" >> "$GITHUB_ENV" + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: asset-linux-arch + path: ${{ env.ASSET }} + if-no-files-found: error + retention-days: 7 + + build-linux-fedora: + name: Build Linux (Fedora) + runs-on: ubuntu-latest + container: + image: fedora:latest + steps: + - name: Install build dependencies + run: dnf -y install curl gcc gcc-c++ make pkgconf-pkg-config openssl-devel ca-certificates git tar gzip + - name: Install Rust + run: | + curl https://sh.rustup.rs -sSf | sh -s -- -y --profile minimal + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + - name: Checkout + uses: actions/checkout@v4 + - name: Build + run: cargo build --release --locked + - name: Package + run: | + asset="gitnapse-${RELEASE_TAG}-linux-fedora-x86_64.tar.gz" + tar -C target/release -czf "${asset}" gitnapse + echo "ASSET=${asset}" >> "$GITHUB_ENV" + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: asset-linux-fedora + path: ${{ env.ASSET }} + if-no-files-found: error + retention-days: 7 + + build-windows: + name: Build Windows + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Build + run: cargo build --release --locked + - name: Package + shell: pwsh + run: | + $asset = "gitnapse-$env:RELEASE_TAG-windows-x86_64.zip" + Compress-Archive -Path "target/release/gitnapse.exe" -DestinationPath $asset -Force + "ASSET=$asset" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: asset-windows + path: ${{ env.ASSET }} + if-no-files-found: error + retention-days: 7 + + build-macos: + name: Build macOS + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Build + run: cargo build --release --locked + - name: Package + run: | + arch="$(uname -m)" + asset="gitnapse-${RELEASE_TAG}-macos-${arch}.tar.gz" + tar -C target/release -czf "${asset}" gitnapse + echo "ASSET=${asset}" >> "$GITHUB_ENV" + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: asset-macos + path: ${{ env.ASSET }} + if-no-files-found: error + retention-days: 7 + + publish-release: + name: Publish GitHub Release + runs-on: ubuntu-latest + needs: + - build-linux-ubuntu + - build-linux-arch + - build-linux-fedora + - build-windows + - build-macos + permissions: + contents: write + steps: + - name: Download build artifacts + uses: actions/download-artifact@v5 + with: + pattern: asset-* + path: dist + merge-multiple: true + - name: Show assets + run: ls -lah dist + - name: Create or update release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release view "${RELEASE_TAG}" >/dev/null 2>&1 || \ + gh release create "${RELEASE_TAG}" --title "${RELEASE_TAG}" --generate-notes + gh release upload "${RELEASE_TAG}" dist/* --clobber diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..e0572ad --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,36 @@ +name: Security And Tests + +on: + push: + branches: + - main + pull_request: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +jobs: + checks: + name: Rust Checks + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install cargo-audit + run: cargo install cargo-audit --locked + + - name: Format check + run: cargo fmt --all -- --check + + - name: Lints + run: cargo clippy --all-targets --all-features -- -D warnings + + - name: Tests + run: cargo test --all-targets --all-features + + - name: Dependency vulnerability audit + run: cargo audit --ignore RUSTSEC-2023-0071 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..bd5e031 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,33 @@ +# Code of Conduct + +## Our Commitment + +We are committed to providing a respectful, inclusive, and harassment-free environment for everyone participating in GitNapse. + +## Expected Behavior + +- Be respectful in communication and reviews. +- Focus on constructive feedback. +- Assume positive intent and ask clarifying questions. +- Accept and provide feedback professionally. + +## Unacceptable Behavior + +- Harassment, threats, or discriminatory language. +- Personal attacks, insults, or trolling. +- Publishing private information without consent. +- Any conduct that harms community collaboration. + +## Scope + +This Code of Conduct applies to repository discussions, issues, pull requests, and any project-related communication channels. + +## Enforcement + +Project maintainers are responsible for clarifying and enforcing this policy. Reports are reviewed confidentially and handled fairly. + +## Reporting + +To report violations, contact: + +- [x@xscriptor.com](mailto:x@xscriptor.com) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..10abc1d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,41 @@ +# Contributing to GitNapse + +Thanks for your interest in contributing. + +## Workflow + +1. Create a branch from `main`. +2. Implement your change. +3. Run local validation. +4. Open a Pull Request targeting `main`. + +`main` is protected. Direct pushes are not allowed. + +## Local Validation + +Run at minimum: + +```bash +cargo check +``` + +If your changes affect behavior, update documentation in `README.md` and `docs/`. + +## Pull Request Guidelines + +- Keep PRs focused and scoped. +- Describe motivation, implementation details, and test evidence. +- Link related issues if available. +- Resolve review comments before merge. + +## Commit Guidance + +Use clear commit messages, for example: + +- `feat: add authenticated @me repository listing` +- `fix: handle oauth runtime initialization` +- `docs: add release collaboration section` + +## Security + +Do not open public issues for sensitive vulnerabilities. Use the process in [SECURITY.md](./SECURITY.md). diff --git a/Cargo.lock b/Cargo.lock index 49a5dcb..73fa98e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,15 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[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 = "1.0.0" @@ -53,7 +62,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -64,7 +73,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -73,6 +82,36 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arc-swap" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +dependencies = [ + "rustversion", +] + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "atomic" version = "0.6.1" @@ -116,12 +155,24 @@ dependencies = [ "fs_extra", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bit-set" version = "0.5.3" @@ -158,6 +209,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -176,6 +236,39 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0061da739915fae12ea00e16397555ed4371a6bb285431aab930f61b0aa4ba" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "cargo_metadata" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "castaway" version = "0.2.4" @@ -215,6 +308,20 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[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 = "clap" version = "4.6.1" @@ -270,6 +377,15 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "colored" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +dependencies = [ + "windows-sys 0.59.0", +] + [[package]] name = "combine" version = "4.6.7" @@ -294,6 +410,18 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "convert_case" version = "0.10.0" @@ -328,6 +456,15 @@ 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 = "crossterm" version = "0.29.0" @@ -355,6 +492,18 @@ dependencies = [ "winapi", ] +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -365,6 +514,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +dependencies = [ + "hybrid-array", +] + [[package]] name = "csscolorparser" version = "0.6.2" @@ -375,6 +533,33 @@ dependencies = [ "phf", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "darling" version = "0.23.0" @@ -415,6 +600,17 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -452,8 +648,21 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" +dependencies = [ + "block-buffer 0.12.0", + "const-oid 0.10.2", + "crypto-common 0.2.1", ] [[package]] @@ -474,7 +683,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -497,18 +706,83 @@ dependencies = [ "litrs", ] +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + [[package]] name = "dunce" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -522,7 +796,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -544,6 +818,28 @@ dependencies = [ "regex", ] +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "filedescriptor" version = "0.8.3" @@ -606,6 +902,21 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -622,12 +933,34 @@ 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-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "futures-sink" version = "0.3.32" @@ -646,8 +979,10 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", "futures-io", + "futures-macro", "futures-sink", "futures-task", "memchr", @@ -663,6 +998,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -714,12 +1050,54 @@ dependencies = [ "clap", "crossterm", "directories", + "dotenvy", + "http", + "keyring", + "mockito", + "octocrab", "ratatui", "reqwest", "rpassword", + "rustls", + "secrecy", "serde", "serde_json", - "sha2", + "serial_test", + "sha2 0.11.0", + "tempfile", + "tokio", + "url", + "webbrowser", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", ] [[package]] @@ -760,6 +1138,24 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "http" version = "1.4.0" @@ -799,6 +1195,21 @@ 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 = "hybrid-array" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d46837a0ed51fe95bd3b05de33cd64a1ee88fc797477ca48446872504507c5" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.9.0" @@ -809,9 +1220,11 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -828,12 +1241,27 @@ dependencies = [ "http", "hyper", "hyper-util", + "log", "rustls", + "rustls-native-certs", "tokio", "tokio-rustls", "tower-service", ] +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -857,6 +1285,30 @@ dependencies = [ "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.2.0" @@ -1059,6 +1511,36 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.117", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -1109,6 +1591,29 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "10.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1" +dependencies = [ + "base64", + "ed25519-dalek", + "getrandom 0.2.17", + "hmac", + "js-sys", + "p256", + "p384", + "pem", + "rand 0.8.6", + "rsa", + "serde", + "serde_json", + "sha2 0.10.9", + "signature", + "simple_asn1", +] + [[package]] name = "kasuari" version = "0.4.12" @@ -1120,6 +1625,16 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "log", + "zeroize", +] + [[package]] name = "lab" version = "0.11.0" @@ -1131,6 +1646,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] name = "leb128fmt" @@ -1144,6 +1662,12 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" version = "0.1.16" @@ -1259,6 +1783,37 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "mockito" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90820618712cab19cfc46b274c6c22546a82affcb3c3bdf0f29e3db8e1bb92c0" +dependencies = [ + "assert-json-diff", + "bytes", + "colored", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "log", + "pin-project-lite", + "rand 0.9.4", + "regex", + "serde_json", + "serde_urlencoded", + "similar", + "tokio", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + [[package]] name = "nix" version = "0.29.0" @@ -1282,6 +1837,32 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.6", + "smallvec", + "zeroize", +] + [[package]] name = "num-conv" version = "0.2.1" @@ -1299,6 +1880,26 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1306,6 +1907,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -1317,6 +1919,73 @@ dependencies = [ "libc", ] +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.11.1", + "objc2", +] + +[[package]] +name = "octocrab" +version = "0.49.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a559d5d4b3e86c6a0459af93d6e09adc61962b757497f7ec811e5cdd4b7a857b" +dependencies = [ + "arc-swap", + "async-trait", + "base64", + "bytes", + "cargo_metadata", + "cfg-if", + "chrono", + "either", + "futures", + "futures-util", + "getrandom 0.2.17", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-timeout", + "hyper-util", + "jsonwebtoken", + "once_cell", + "percent-encoding", + "pin-project", + "secrecy", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "snafu", + "tokio", + "tower", + "tower-http", + "tracing", + "url", + "web-time", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1350,6 +2019,30 @@ dependencies = [ "num-traits", ] +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -1373,6 +2066,25 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1419,7 +2131,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" dependencies = [ "pest", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -1475,10 +2187,51 @@ dependencies = [ ] [[package]] -name = "pin-project-lite" -version = "0.2.17" +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] [[package]] name = "portable-atomic" @@ -1520,6 +2273,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1612,6 +2374,8 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ + "libc", + "rand_chacha 0.3.1", "rand_core 0.6.4", ] @@ -1621,10 +2385,20 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ - "rand_chacha", + "rand_chacha 0.9.0", "rand_core 0.9.5", ] +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + [[package]] name = "rand_chacha" version = "0.9.0" @@ -1640,6 +2414,9 @@ name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] [[package]] name = "rand_core" @@ -1823,6 +2600,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -1848,6 +2635,26 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid 0.9.6", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rtoolbox" version = "0.0.5" @@ -1883,7 +2690,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1893,7 +2700,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e" dependencies = [ "aws-lc-rs", + "log", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -1930,7 +2739,7 @@ checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" dependencies = [ "core-foundation", "core-foundation-sys", - "jni", + "jni 0.21.1", "log", "once_cell", "rustls", @@ -1940,7 +2749,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1982,6 +2791,15 @@ dependencies = [ "winapi-util", ] +[[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.29" @@ -1997,6 +2815,35 @@ 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 = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -2025,6 +2872,10 @@ name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "serde" @@ -2069,6 +2920,55 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[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 2.0.117", +] + [[package]] name = "sha2" version = "0.10.9" @@ -2076,8 +2976,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.2", ] [[package]] @@ -2117,6 +3028,50 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + [[package]] name = "siphasher" version = "1.0.2" @@ -2135,6 +3090,27 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "snafu" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2" +dependencies = [ + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "socket2" version = "0.6.3" @@ -2142,7 +3118,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", ] [[package]] @@ -2232,6 +3224,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.59.0", +] + [[package]] name = "terminfo" version = "0.9.0" @@ -2278,7 +3283,7 @@ dependencies = [ "pest", "pest_derive", "phf", - "sha2", + "sha2 0.10.9", "signal-hook", "siphasher", "terminfo", @@ -2342,12 +3347,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", + "itoa", "libc", "num-conv", "num_threads", "powerfmt", "serde_core", "time-core", + "time-macros", ] [[package]] @@ -2356,6 +3363,16 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -2390,6 +3407,7 @@ dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", "socket2", "windows-sys 0.61.2", @@ -2405,6 +3423,19 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "tower" version = "0.5.3" @@ -2416,8 +3447,10 @@ dependencies = [ "pin-project-lite", "sync_wrapper", "tokio", + "tokio-util", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -2436,6 +3469,7 @@ dependencies = [ "tower", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -2456,10 +3490,23 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -2538,6 +3585,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -2728,9 +3776,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" dependencies = [ "js-sys", + "serde", "wasm-bindgen", ] +[[package]] +name = "webbrowser" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc95580916af1e68ff6a7be07446fc5db73ebf71cf092de939bbf5f7e189f72" +dependencies = [ + "core-foundation", + "jni 0.22.4", + "log", + "ndk-context", + "objc2", + "objc2-foundation", + "url", + "web-sys", +] + [[package]] name = "webpki-root-certs" version = "1.0.7" @@ -2758,7 +3823,7 @@ checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" dependencies = [ "getrandom 0.3.4", "mac_address", - "sha2", + "sha2 0.10.9", "thiserror 1.0.69", "uuid", ] @@ -2834,7 +3899,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2843,12 +3908,65 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[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 2.0.117", +] + +[[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 2.0.117", +] + [[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.45.0" diff --git a/Cargo.toml b/Cargo.toml index aa686d0..c6ca552 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,9 +9,23 @@ base64 = "0.22" clap = { version = "4.6.1", features = ["derive"] } crossterm = "0.29" directories = "6.0" +dotenvy = "0.15" +http = "1.3" +keyring = "3.6" +octocrab = { version = "0.49.8", default-features = true } ratatui = "0.30.0" reqwest = { version = "0.13.2", default-features = false, features = ["blocking", "json", "rustls"] } rpassword = "7.4" +secrecy = "0.10" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -sha2 = "0.10" +sha2 = "0.11" +rustls = { version = "0.23", features = ["ring"] } +tokio = { version = "1.48", features = ["rt", "time"] } +url = "2.5" +webbrowser = "1.0" + +[dev-dependencies] +mockito = "1.7" +serial_test = "3.2" +tempfile = "3.20" diff --git a/README.md b/README.md index e7471ee..31173d0 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@
  • Current Status
  • Quick Start
  • Remote Install / Uninstall
  • +
  • Release Automation
  • Documentation
  • X
  • @@ -73,6 +74,7 @@
    gitnapse
     gitnapse run --query "xscriptor" --page 1 --per-page 30 --cache-ttl-secs 900
     gitnapse auth set
    +gitnapse auth oauth login --client-id YOUR_OAUTH_CLIENT_ID --scope read:user --scope repo
     

    Remote Install / Uninstall

    @@ -91,13 +93,26 @@ wget -qO- https://raw.githubusercontent.com/xscriptor/gitnapse/main/scripts/inst & ([scriptblock]::Create((irm https://raw.githubusercontent.com/xscriptor/gitnapse/main/scripts/install.ps1))) -Action uninstall -Cleanup +

    Release Automation

    +

    + GitHub Actions release pipeline is available in .github/workflows/release.yml. + Push a version tag like v1.0.0 to build Windows, Linux (Ubuntu/Arch/Fedora), and macOS assets and publish them in GitHub Releases. +

    +

    Documentation

      -
    • docs/INSTALLATION.md - full install and uninstall by platform
    • -
    • docs/REMOTE_INSTALLATION.md - remote scripts, parameters, and examples
    • -
    • docs/USAGE.md - full command and in-app usage guide
    • -
    • docs/ARCHITECTURE.md - technical architecture details
    • -
    • docs/IMPLEMENTATION_LOG.md - implementation materialization log
    • +
    • INSTALLATION.md - full install and uninstall by platform
    • +
    • REMOTE_INSTALLATION.md - remote scripts, parameters, and examples
    • +
    • OAUTH_AUTHENTICATION.md - OAuth login flows with octocrab and secure setup
    • +
    • COLLABORATIVE_SECTION.md - branch protection, PR workflow, and release publishing collaboration guide
    • +
    • RELEASE_WORKFLOW.md - release build/publish workflow and versioning commands
    • +
    • USAGE.md - full command and in-app usage guide
    • +
    • ARCHITECTURE.md - technical architecture details
    • +
    • IMPLEMENTATION_LOG.md - implementation materialization log
    • +
    • docs/tests/README.md - test and security audit documentation index
    • +
    • SECURITY.md - vulnerability reporting and response policy
    • +
    • CODE_OF_CONDUCT.md - expected behavior and community standards
    • +
    • CONTRIBUTING.md - contribution workflow and pull request guidelines
    @@ -119,4 +134,4 @@ wget -qO- https://raw.githubusercontent.com/xscriptor/gitnapse/main/scripts/inst Xscriptor web -
    \ No newline at end of file + diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..597b17a --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,28 @@ +# Security Policy + +## Supported Versions + +Security fixes are provided for the latest `main` branch state and the most recent tagged release. + +## Reporting a Vulnerability + +Please report vulnerabilities privately by email: + +- Contact: [x@xscriptor.com](mailto:x@xscriptor.com) +- Subject: `GitNapse Security Report` + +When possible, include: + +1. A clear description of the issue. +2. Steps to reproduce. +3. Impact assessment. +4. Suggested remediation (optional). + +## Response Process + +1. Initial acknowledgment target: within 72 hours. +2. Triage and severity classification. +3. Fix development and validation. +4. Coordinated disclosure after patch availability. + +Please do not publish proof-of-concept details before a fix is available. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c8774d7..a221dfe 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -20,6 +20,8 @@
  • src/cache.rs: local preview cache with TTL and disk persistence.
  • src/github.rs: GitHub API client for search/branches/tree/content/auth-user.
  • src/auth.rs: token loading, secure storage, token CLI subcommands.
  • +
  • src/oauth.rs: OAuth device-flow login powered by octocrab.
  • +
  • src/oauth_session.rs: secure OAuth session persistence, expiry metadata, and refresh attempt path.
  • src/config.rs: persisted account preferences (account.json).
  • src/models.rs: DTO/domain models for GitHub responses and internal tree nodes.
  • src/syntax.rs: preview syntax-aware formatting.
  • @@ -40,6 +42,8 @@
    • Preferred source: GITHUB_TOKEN.
    • Fallback source: local stored token under user config directory.
    • +
    • OAuth device flow is available via gitnapse auth oauth login using octocrab.
    • +
    • OAuth session metadata is persisted to support token lifecycle handling and optional refresh.
    • UNIX permissions are restricted for token file (0600).
    • Token can be updated inside TUI via modal and validated against /user.
    @@ -75,6 +79,7 @@

    Network and Integration Notes

    • HTTP layer: reqwest blocking client for deterministic TUI loop behavior.
    • +
    • OAuth device flow exchange uses octocrab against https://github.com/login/* routes.
    • GitHub endpoints:
      • /search/repositories
      • diff --git a/docs/COLLABORATIVE_SECTION.md b/docs/COLLABORATIVE_SECTION.md new file mode 100644 index 0000000..75620aa --- /dev/null +++ b/docs/COLLABORATIVE_SECTION.md @@ -0,0 +1,72 @@ +

        GitNapse Collaborative Section

        + +
        +

        Contents

        + + +

        Branch Policy

        +
          +
        • main is protected and must not receive direct pushes.
        • +
        • All changes must be developed in topic branches and merged through Pull Requests.
        • +
        • Required checks (CI/build/tests) must pass before merge.
        • +
        • Use squash merge or rebase merge according to repository settings.
        • +
        + +

        Pull Request Flow

        +
          +
        1. Create a branch from updated main (example: feat/oauth-improvements).
        2. +
        3. Implement the change, run local validation, and update docs when behavior changes.
        4. +
        5. Push the branch and open a PR targeting main.
        6. +
        7. Request review and address comments with follow-up commits.
        8. +
        9. Merge only after required checks and review approvals are complete.
        10. +
        + +

        Suggested local command sequence:

        +
        git checkout main
        +git pull --ff-only
        +git checkout -b feat/short-description
        +# code changes
        +cargo check
        +git add .
        +git commit -m "feat: short description"
        +git push -u origin feat/short-description
        +
        + +

        Release Publishing Flow

        +

        + Releases are automated by .github/workflows/release.yml. When a version tag is pushed, the workflow compiles binaries for + Windows, Linux (Ubuntu, Arch, Fedora), and macOS, then uploads assets to GitHub Releases. +

        +
          +
        1. Ensure the target commit is already merged into main through PR.
        2. +
        3. Create an annotated semantic version tag (example: v1.2.0).
        4. +
        5. Push the tag to origin to trigger the release workflow.
        6. +
        7. Wait for Actions jobs to complete and verify uploaded assets in the Release page.
        8. +
        + +

        Release command sequence:

        +
        git checkout main
        +git pull --ff-only
        +git tag -a v1.2.0 -m "GitNapse v1.2.0"
        +git push origin v1.2.0
        +
        + +

        Manual rebuild of an existing release tag:

        +
          +
        • Open GitHub -> Actions -> Release.
        • +
        • Run workflow manually with release_tag set to an existing tag.
        • +
        • The workflow will upload/update assets for that release tag.
        • +
        + +

        Maintainer Checklist

        +
          +
        • Confirm PR merged into main and CI green.
        • +
        • Confirm docs are aligned with user-visible changes.
        • +
        • Create semantic version tag from main.
        • +
        • Validate release assets for all target platforms after workflow completion.
        • +
        diff --git a/docs/IMPLEMENTATION_LOG.md b/docs/IMPLEMENTATION_LOG.md index 3c01bd9..a502ac0 100644 --- a/docs/IMPLEMENTATION_LOG.md +++ b/docs/IMPLEMENTATION_LOG.md @@ -47,6 +47,8 @@
      • CLI single-file download command (gitnapse download-file).
      • Tree file-name search shortcut and full tree-text view toggle.
      • Token management commands and runtime validation against GitHub user endpoint.
      • +
      • OAuth device-flow login implemented with octocrab and secure token persistence.
      • +
      • OAuth session lifecycle handling added (expiry metadata + optional refresh flow with client secret env variables).
      • Full palette-based navigation coloring with contrast-safe foreground.
      diff --git a/docs/OAUTH_AUTHENTICATION.md b/docs/OAUTH_AUTHENTICATION.md new file mode 100644 index 0000000..66f0390 --- /dev/null +++ b/docs/OAUTH_AUTHENTICATION.md @@ -0,0 +1,132 @@ +

      GitNapse OAuth Authentication

      + +
      +

      Contents

      + + +

      Overview

      +

      + GitNapse supports multiple authentication paths and now includes OAuth device login implemented + with octocrab. The implementation is optimized for terminal UX and avoids embedding + user credentials in CLI history. +

      + +

      Current Login Modes in GitNapse

      + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
      ModeHow it WorksBest ForStorage
      Environment tokenReads GITHUB_TOKEN at runtimeCI/CD and ephemeral sessionsNot persisted by app
      Manual tokengitnapse auth set stores a tokenLocal personal workflowUser config directory, secure file permissions on UNIX
      OAuth device flowgitnapse auth oauth login ... uses browser authorization and exchanges token via octocrabSafer interactive sign-in without pasting long tokensStored in OS keyring when available; secure file fallback otherwise
      + +

      OAuth Device Flow with octocrab

      +
        +
      1. GitNapse requests a device code from GitHub using octocrab against https://github.com.
      2. +
      3. GitNapse tries to open verification_uri in your default browser automatically.
      4. +
      5. If auto-open is unavailable, the terminal shows a clickable OSC8 hyperlink (when terminal supports it) plus the plain URL.
      6. +
      7. User authorizes in browser with GitHub account.
      8. +
      9. GitNapse polls token endpoint using octocrab's recommended flow logic, respecting interval and slow_down responses.
      10. +
      11. The resulting OAuth access token is stored securely and then validated against /user.
      12. +
      + +

      GitHub Configuration

      +

      + To use OAuth login, you need an OAuth App in GitHub settings. +

      +
        +
      • Create an OAuth App in GitHub developer settings.
      • +
      • Copy the Client ID from the app.
      • +
      • For terminal device flow, no local redirect listener is required.
      • +
      • Use minimum scopes first (recommended: read:user), then add repo only if private repository access is needed.
      • +
      +

      + GitNapse accepts either GITNAPSE_GITHUB_OAUTH_CLIENT_ID or compatibility fallback + GITHUB_CLIENT_ID as Client ID source, and includes a built-in default Client ID for the official GitNapse OAuth app. +

      +

      + If you currently have a GitHub App but not an OAuth App, create the OAuth App as a separate credential set for user login. +

      + +

      Commands

      +

      One-time login with explicit Client ID:

      +
      gitnapse auth oauth login --client-id YOUR_OAUTH_CLIENT_ID --scope read:user --scope repo
      +
      + +

      Use environment for Client ID:

      +
      export GITNAPSE_GITHUB_OAUTH_CLIENT_ID=YOUR_OAUTH_CLIENT_ID
      +gitnapse auth oauth login --scope read:user --scope repo
      +
      + +

      Compatibility environment variable:

      +
      export GITHUB_CLIENT_ID=YOUR_OAUTH_CLIENT_ID
      +gitnapse auth oauth login --scope read:user
      +
      + +

      TUI shortcut:

      +
      # Inside the app, press:
      +o
      +
      +

      + The app opens an OAuth Client ID modal and then starts the device login flow. +

      + +

      Short timeout tuning:

      +
      gitnapse auth oauth login --client-id YOUR_OAUTH_CLIENT_ID --timeout-secs 1200
      +
      + +

      Security Notes

      +
        +
      • Client secret is intentionally not required for this terminal device flow implementation.
      • +
      • OAuth access token is never printed back to terminal output.
      • +
      • Primary storage is OS keyring (Credential Manager on Windows, Keychain on macOS, Secret Service/libsecret on Linux when available).
      • +
      • WSL and no-keyring environments automatically fallback to local file storage with strict UNIX permissions (0600).
      • +
      • OAuth session metadata is persisted separately (expiry, refresh token, scopes, client id) to support safer session lifecycle handling.
      • +
      • If GITNAPSE_GITHUB_OAUTH_CLIENT_SECRET or GITHUB_CLIENT_SECRET is present, GitNapse attempts refresh-token exchange when access token is near expiry.
      • +
      • Prefer least-privilege scopes and rotate/revoke tokens when no longer needed.
      • +
      • For shared machines, prefer environment-based or ephemeral auth over persistent local token storage.
      • +
      • On logout (gitnapse auth clear), GitNapse removes credentials from keyring and also deletes fallback file if present.
      • +
      + +

      Session Storage Recommendation

      +

      + Current implementation uses OS keyring when available and falls back to secure local file storage in unsupported environments + (for example WSL/headless Linux sessions without keyring service). +

      + +

      OAuth Troubleshooting

      +
        +
      • If you previously saw a rustls CryptoProvider panic, update to this build; GitNapse now installs a rustls provider explicitly before OAuth login.
      • +
      • .env is loaded at startup, so GITHUB_CLIENT_ID and related auth vars are available without manual export.
      • +
      • If browser auto-open does not work in your terminal/session, copy the displayed URL and open it manually.
      • +
      diff --git a/docs/RELEASE_WORKFLOW.md b/docs/RELEASE_WORKFLOW.md new file mode 100644 index 0000000..2b4b387 --- /dev/null +++ b/docs/RELEASE_WORKFLOW.md @@ -0,0 +1,66 @@ +

      GitNapse Release Workflow

      + +
      +

      Contents

      + + +

      Overview

      +

      + GitNapse uses .github/workflows/release.yml to compile release binaries and publish them as GitHub Release assets. + The pipeline builds platform artifacts for Windows, Linux (Ubuntu, Arch, Fedora), and macOS, then uploads them to the release tag. +

      + +

      Workflow Triggers

      +
        +
      • push on tags that match v* (example: v1.0.0)
      • +
      • workflow_dispatch with an existing release_tag input
      • +
      + +

      Build Artifacts

      +
        +
      • gitnapse-<tag>-linux-ubuntu-x86_64.tar.gz
      • +
      • gitnapse-<tag>-linux-arch-x86_64.tar.gz
      • +
      • gitnapse-<tag>-linux-fedora-x86_64.tar.gz
      • +
      • gitnapse-<tag>-windows-x86_64.zip
      • +
      • gitnapse-<tag>-macos-<arch>.tar.gz
      • +
      + +

      Versioning Commands

      +

      Create and publish a new version tag:

      +
      git checkout main
      +git pull --ff-only
      +git tag -a v1.0.0 -m "GitNapse v1.0.0"
      +git push origin v1.0.0
      +
      +

      + After the tag push, GitHub Actions runs the release workflow and publishes assets in the corresponding GitHub Release. +

      + +

      Manual Release Run

      +
        +
      1. Open GitHub -> Actions -> Release.
      2. +
      3. Click Run workflow.
      4. +
      5. Set release_tag to an existing tag (example: v1.0.0).
      6. +
      7. Run the workflow to rebuild and upload assets with --clobber behavior.
      8. +
      + +

      Collaboration Policy

      +

      + For protected-branch collaboration flow (main via Pull Requests only), see + COLLABORATIVE_SECTION.md. +

      + +

      Official Notes

      +
        +
      • The workflow uses GITHUB_TOKEN with least privilege, elevating contents: write only in the publish job.
      • +
      • Release notes are generated by GitHub using gh release create --generate-notes.
      • +
      • Artifact passing between jobs uses actions/upload-artifact and actions/download-artifact.
      • +
      diff --git a/docs/USAGE.md b/docs/USAGE.md index cf6a081..c2494a9 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -6,6 +6,7 @@
    • Requirements
    • CLI Command Table
    • In-App Control Table
    • +
    • My Private Repositories
    • Core Workflows
    • Troubleshooting
    @@ -40,6 +41,12 @@ gitnapse run --query "xscriptor" --page 1 --per-page 30 --cache-ttl-secs 900 Controls search bootstrap and preview cache TTL + + gitnapse run --query "@me" + List authenticated repositories (including private) + gitnapse run --query "@me" + Requires valid login/token; supports optional filter: @me keyword + gitnapse auth set Store GitHub token interactively @@ -64,6 +71,12 @@ gitnapse auth clear Does not modify GITHUB_TOKEN env variable + + gitnapse auth oauth login ... + OAuth login (device flow via octocrab) + gitnapse auth oauth login --client-id YOUR_OAUTH_CLIENT_ID --scope read:user --scope repo + Starts browser-based device authorization and stores access token securely + gitnapse download-file ... Download one file (curl/wget-like) @@ -100,12 +113,27 @@ dPreviewDownload modalSave current previewed file to local path DelPath modalsClear path inputWorks in clone/download path inputs tGlobalToken modalSave token from inside the TUI + oGlobalOAuth modalClient ID is optional; press Enter empty to use default and start device-flow login qGlobalQuitExit application Mouse left clickTree / Preview / ReposFocus & selectSingle click selects, double click opens (repo/file) Mouse wheelTree / PreviewScrollScroll behavior depends on pointer position +

    My Private Repositories

    +

    + GitHub search endpoint does not guarantee full private-repository discovery by username query. + To list your own repositories (including private ones), use the authenticated query mode: +

    +
      +
    • Inside TUI search input (/): @me
    • +
    • Optional filter: @me rust or me:rust
    • +
    • CLI start: gitnapse run --query "@me"
    • +
    +

    + This mode requires a valid authenticated session/token and uses your account repository listing API scope. +

    +

    Core Workflows

    Open and Explore a Repository

    @@ -141,6 +169,10 @@

    Troubleshooting

    • If API limits are hit, set a token with gitnapse auth set or export GITHUB_TOKEN.
    • +
    • For OAuth device flow, you can provide --client-id; if omitted, GitNapse uses env variables and then built-in default OAuth Client ID.
    • +
    • You can also use GITHUB_CLIENT_ID as compatibility fallback for OAuth client ID.
    • +
    • If OAuth URL is not clickable in your terminal, GitNapse still tries to auto-open browser; otherwise copy/open the displayed URL manually.
    • +
    • To inspect your private repositories from TUI search, use @me (or @me keyword to filter).
    • If token is saved but requests fail, run gitnapse auth status and validate token permissions.
    • If clone/download fails, verify destination path permissions and filesystem access.
    • If no repos appear, refine query terms (owner/org/repo keywords).
    • diff --git a/docs/tests/README.md b/docs/tests/README.md new file mode 100644 index 0000000..e5476b3 --- /dev/null +++ b/docs/tests/README.md @@ -0,0 +1,33 @@ +

      GitNapse Test Documentation

      + +
      +

      Contents

      + + +

      Overview

      +

      + This section documents automated tests and security-oriented checks implemented for GitNapse. + Tests are intentionally placed under the repository-level tests/ directory to keep them separated from application modules. +

      + +

      Test Files

      +
        +
      • tests/github_search_tests.rs - API behavior tests for general search and @me private-repo mode using mocked HTTP endpoints
      • +
      • tests/secure_store_tests.rs - secret storage fallback and file-permission checks
      • +
      • tests/auth_precedence_tests.rs - authentication source precedence checks
      • +
      + +

      How To Run

      +
      cargo test
      +
      + + + diff --git a/docs/tests/SECURITY_AUDIT.md b/docs/tests/SECURITY_AUDIT.md new file mode 100644 index 0000000..2a137c9 --- /dev/null +++ b/docs/tests/SECURITY_AUDIT.md @@ -0,0 +1,31 @@ +

      Security Audit Guide

      + +

      Automated Audit In CI

      +

      + GitNapse runs a dedicated GitHub Actions workflow at .github/workflows/security.yml. +

      +
        +
      • cargo fmt --all -- --check
      • +
      • cargo clippy --all-targets --all-features -- -D warnings
      • +
      • cargo test --all-targets --all-features
      • +
      • cargo audit --ignore RUSTSEC-2023-0071
      • +
      + +

      Local Audit Commands

      +
      cargo install cargo-audit --locked
      +cargo audit --ignore RUSTSEC-2023-0071
      +
      + +

      Scope

      +
        +
      • Dependency CVE scanning
      • +
      • Static quality and lint hardening
      • +
      • Regression checks on authentication and secure storage paths
      • +
      + +

      Current Advisory Exception

      +

      + The advisory RUSTSEC-2023-0071 is currently transitive through + octocrab -> jsonwebtoken -> rsa and has no fixed upgrade available in the current dependency line. + The workflow keeps this ID explicitly ignored until upstream provides a fix. +

      diff --git a/docs/tests/TEST_COVERAGE.md b/docs/tests/TEST_COVERAGE.md new file mode 100644 index 0000000..820cba0 --- /dev/null +++ b/docs/tests/TEST_COVERAGE.md @@ -0,0 +1,17 @@ +

      Test Coverage Notes

      + +

      Implemented Coverage

      +
        +
      • General repository search: validates the public search endpoint path and query handling.
      • +
      • Authenticated private repository mode: validates @me request path and filtering behavior.
      • +
      • Unauthorized handling: validates expected failure when @me runs without valid authentication.
      • +
      • Secure storage fallback: validates file backend save/load/clear.
      • +
      • Unix file permissions: validates secure permission mode 0600 when fallback file storage is used.
      • +
      • Authentication precedence: validates environment token precedence over other sources.
      • +
      + +

      Why Integration Tests

      +

      + Tests are located under repository-level tests/ to keep them outside application modules and closer to real user flows. + Mocked HTTP responses are used to avoid external network dependency and improve deterministic results. +

      diff --git a/src/app/mod.rs b/src/app/mod.rs index 1c658bc..d05227f 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -6,6 +6,7 @@ use crate::cache::PreviewCache; use crate::config::AccountConfig; use crate::github::GitHubClient; use crate::models::{RepoNode, RepoSummary}; +use crate::oauth; use crate::syntax::highlight_content; use anyhow::{Context, Result, anyhow}; use crossterm::event::{ @@ -16,9 +17,9 @@ use crossterm::execute; use crossterm::terminal::{ EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, }; +use ratatui::Terminal; use ratatui::backend::CrosstermBackend; use ratatui::text::Line; -use ratatui::Terminal; use std::io::stdout; use std::path::PathBuf; use std::process::Command; @@ -56,6 +57,7 @@ pub enum Focus { DownloadPath, ClonePath, TokenInput, + OAuthClientIdInput, BranchPicker, } @@ -80,6 +82,7 @@ pub struct App { pub status: String, pub focus: Focus, pub input_buffer: String, + pub oauth_client_id_input: String, pub clone_path_input: String, pub should_quit: bool, pub current_repo: Option, @@ -126,11 +129,17 @@ impl App { tree_text_mode: false, status: match auth_user.as_ref() { Some(login) => format!("Authenticated as {login}. Press / to search."), - None => "No validated token. Press t to save one or continue anonymously." - .to_string(), + None => { + "No validated token. Press t to save one or continue anonymously.".to_string() + } }, focus: Focus::Repos, input_buffer: String::new(), + oauth_client_id_input: std::env::var("GITNAPSE_GITHUB_OAUTH_CLIENT_ID") + .or_else(|_| std::env::var("GITHUB_CLIENT_ID")) + .unwrap_or_default() + .trim() + .to_string(), clone_path_input: account.preferred_clone_dir, should_quit: false, current_repo: None, @@ -168,7 +177,8 @@ impl App { return; } if self.selected_node + TREE_LOAD_THRESHOLD >= self.tree_visible_limit { - self.tree_visible_limit = (self.tree_visible_limit + TREE_PAGE_SIZE).min(self.tree_all.len()); + self.tree_visible_limit = + (self.tree_visible_limit + TREE_PAGE_SIZE).min(self.tree_all.len()); self.status = format!( "Loaded more tree entries ({}/{}).", self.tree_visible_limit, @@ -186,10 +196,11 @@ impl App { } fn search(&mut self) { - match self - .github - .search_repositories_page(&self.search_query, self.search_page, self.per_page) - { + match self.github.search_repositories_page( + &self.search_query, + self.search_page, + self.per_page, + ) { Ok(items) => { if items.is_empty() && self.search_page > 1 { self.search_page = self.search_page.saturating_sub(1); @@ -316,7 +327,8 @@ impl App { match self.github.fetch_file_content(&full_name, &node_path) { Ok(content) => { - self.preview_cache.put(&full_name, &branch, &node_path, &content); + self.preview_cache + .put(&full_name, &branch, &node_path, &content); self.preview_title = format!("{}/{}", full_name, node_path); self.preview_lines = highlight_content(&content, &node_path, 300); self.preview_scroll = 0; @@ -343,14 +355,14 @@ impl App { } let destination_path = PathBuf::from(destination); - if !destination_path.exists() { - if let Err(error) = std::fs::create_dir_all(&destination_path) { - self.status = format!( - "Cannot create destination path {}: {error}", - destination_path.display() - ); - return; - } + if !destination_path.exists() + && let Err(error) = std::fs::create_dir_all(&destination_path) + { + self.status = format!( + "Cannot create destination path {}: {error}", + destination_path.display() + ); + return; } let output = Command::new("git") @@ -383,9 +395,9 @@ impl App { return; } - match auth::save_token(&token_owned) - .and_then(|_| GitHubClient::new(Some(&token_owned)).context("Cannot rebuild HTTP client")) - { + match auth::save_token(&token_owned).and_then(|_| { + GitHubClient::new(Some(&token_owned)).context("Cannot rebuild HTTP client") + }) { Ok(client) => { self.github = client; self.auth_user = self.github.fetch_authenticated_user().ok().flatten(); @@ -409,13 +421,16 @@ impl App { Focus::DownloadPath => self.handle_download_path_input(code), Focus::ClonePath => self.handle_clone_path_input(code), Focus::TokenInput => self.handle_token_input(code), + Focus::OAuthClientIdInput => self.handle_oauth_client_id_input(code), Focus::BranchPicker => self.handle_branch_picker_input(code), Focus::Repos | Focus::Tree | Focus::Preview => self.handle_navigation(code), } } fn max_preview_scroll(&self, viewport_rows: usize) -> usize { - self.preview_lines.len().saturating_sub(viewport_rows.max(1)) + self.preview_lines + .len() + .saturating_sub(viewport_rows.max(1)) } fn scroll_preview_down(&mut self, step: usize, viewport_rows: usize) { @@ -431,7 +446,10 @@ impl App { let visible = self.visible_tree(); let viewport_rows = usize::from(area_height.saturating_sub(2)).max(1); let max_start = visible.len().saturating_sub(viewport_rows); - let start = self.selected_node.saturating_sub(viewport_rows / 2).min(max_start); + let start = self + .selected_node + .saturating_sub(viewport_rows / 2) + .min(max_start); let end = (start + viewport_rows).min(visible.len()); (start, end) } @@ -488,7 +506,10 @@ impl App { { self.selected_node = idx; self.ensure_lazy_tree_progress(); - self.status = format!("Found file match for \"{}\".", self.tree_search_input.trim()); + self.status = format!( + "Found file match for \"{}\".", + self.tree_search_input.trim() + ); } else { self.status = format!("No file matches \"{}\".", self.tree_search_input.trim()); } @@ -610,6 +631,67 @@ impl App { } } + fn handle_oauth_client_id_input(&mut self, code: KeyCode) { + match code { + KeyCode::Esc => { + self.focus = if self.current_repo.is_some() { + Focus::Tree + } else { + Focus::Repos + }; + } + KeyCode::Enter => { + let client_id = if self.oauth_client_id_input.trim().is_empty() { + None + } else { + Some(self.oauth_client_id_input.trim().to_string()) + }; + self.run_oauth_login_flow(client_id); + } + KeyCode::Delete => self.oauth_client_id_input.clear(), + KeyCode::Backspace => { + self.oauth_client_id_input.pop(); + } + KeyCode::Char(ch) => self.oauth_client_id_input.push(ch), + _ => {} + } + } + + fn run_oauth_login_flow(&mut self, client_id: Option) { + self.status = "Starting OAuth device flow...".to_string(); + + // Temporarily leave TUI mode to let user interact with OAuth instructions in terminal. + let _ = disable_raw_mode(); + let _ = execute!(stdout(), LeaveAlternateScreen, DisableMouseCapture); + + let oauth_result = + oauth::oauth_device_login_cli(client_id, vec!["read:user".to_string()], 900); + + let _ = enable_raw_mode(); + let _ = execute!(stdout(), EnterAlternateScreen, EnableMouseCapture); + + match oauth_result { + Ok(()) => { + if let Ok(token) = auth::load_token() + && let Ok(client) = GitHubClient::new(token.as_deref()) + { + self.github = client; + self.auth_user = self.github.fetch_authenticated_user().ok().flatten(); + } + self.status = "OAuth login completed and session saved.".to_string(); + } + Err(error) => { + self.status = format!("OAuth login failed: {error}"); + } + } + + self.focus = if self.current_repo.is_some() { + Focus::Tree + } else { + Focus::Repos + }; + } + fn handle_branch_picker_input(&mut self, code: KeyCode) { match code { KeyCode::Esc => self.focus = Focus::Tree, @@ -640,6 +722,9 @@ impl App { self.focus = Focus::TokenInput; self.input_buffer.clear(); } + KeyCode::Char('o') => { + self.focus = Focus::OAuthClientIdInput; + } KeyCode::Char('c') => { if self.current_repo.is_some() { self.clone_path_input = self.account.preferred_clone_dir.clone(); @@ -739,12 +824,14 @@ impl App { } KeyCode::Down => { if self.focus == Focus::Tree && !self.tree_all.is_empty() { - self.selected_node = (self.selected_node + 1).min(self.tree_all.len().saturating_sub(1)); + self.selected_node = + (self.selected_node + 1).min(self.tree_all.len().saturating_sub(1)); self.ensure_lazy_tree_progress(); } else if self.focus == Focus::Preview { self.scroll_preview_down(1, 30); } else if !self.repos.is_empty() { - self.selected_repo = (self.selected_repo + 1).min(self.repos.len().saturating_sub(1)); + self.selected_repo = + (self.selected_repo + 1).min(self.repos.len().saturating_sub(1)); } } KeyCode::Up => { @@ -801,11 +888,7 @@ impl App { if idx < end && idx < self.tree_all.len() { self.selected_node = idx; self.ensure_lazy_tree_progress(); - if self - .tree_all - .get(idx) - .map(|n| !n.is_dir) - .unwrap_or(false) + if self.tree_all.get(idx).map(|n| !n.is_dir).unwrap_or(false) && self.is_double_click_tree(idx) { self.preview_selected_file(); @@ -826,7 +909,9 @@ impl App { } return; } - if let Some(preview_area) = panes.preview && contains(preview_area, col, row) { + if let Some(preview_area) = panes.preview + && contains(preview_area, col, row) + { self.focus = Focus::Preview; } } @@ -847,7 +932,8 @@ impl App { if up { self.selected_node = self.selected_node.saturating_sub(1); } else { - self.selected_node = (self.selected_node + 1).min(self.tree_all.len().saturating_sub(1)); + self.selected_node = + (self.selected_node + 1).min(self.tree_all.len().saturating_sub(1)); self.ensure_lazy_tree_progress(); } } else if !self.repos.is_empty() { @@ -855,17 +941,23 @@ impl App { if up { self.selected_repo = self.selected_repo.saturating_sub(1); } else { - self.selected_repo = (self.selected_repo + 1).min(self.repos.len().saturating_sub(1)); + self.selected_repo = + (self.selected_repo + 1).min(self.repos.len().saturating_sub(1)); } } return; } - if let Some(preview_area) = panes.preview && contains(preview_area, col, row) { + if let Some(preview_area) = panes.preview + && contains(preview_area, col, row) + { self.focus = Focus::Preview; if up { self.scroll_preview_up(3); } else { - self.scroll_preview_down(3, usize::from(preview_area.height.saturating_sub(2)).max(1)); + self.scroll_preview_down( + 3, + usize::from(preview_area.height.saturating_sub(2)).max(1), + ); } } } @@ -874,7 +966,9 @@ impl App { let now = Instant::now(); let is_double = self .last_tree_click - .map(|(last_idx, last_at)| last_idx == idx && now.duration_since(last_at) <= Duration::from_millis(450)) + .map(|(last_idx, last_at)| { + last_idx == idx && now.duration_since(last_at) <= Duration::from_millis(450) + }) .unwrap_or(false); self.last_tree_click = Some((idx, now)); is_double @@ -884,7 +978,9 @@ impl App { let now = Instant::now(); let is_double = self .last_repo_click - .map(|(last_idx, last_at)| last_idx == idx && now.duration_since(last_at) <= Duration::from_millis(450)) + .map(|(last_idx, last_at)| { + last_idx == idx && now.duration_since(last_at) <= Duration::from_millis(450) + }) .unwrap_or(false); self.last_repo_click = Some((idx, now)); is_double @@ -952,7 +1048,7 @@ fn contains(rect: ratatui::layout::Rect, col: u16, row: u16) -> bool { #[cfg(test)] mod tests { - use super::{TREE_LOAD_THRESHOLD, App}; + use super::{App, TREE_LOAD_THRESHOLD}; #[test] fn lazy_tree_progress_advances_limit() { @@ -987,6 +1083,7 @@ mod tests { status: String::new(), focus: super::Focus::Tree, input_buffer: String::new(), + oauth_client_id_input: String::new(), clone_path_input: ".".to_string(), should_quit: false, current_repo: None, diff --git a/src/app/render.rs b/src/app/render.rs index 606e564..2fa83a2 100644 --- a/src/app/render.rs +++ b/src/app/render.rs @@ -1,9 +1,9 @@ use super::{App, Focus, theme}; +use ratatui::Frame; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Color, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap}; -use ratatui::Frame; #[derive(Debug, Clone, Copy)] pub struct PaneAreas { @@ -90,11 +90,8 @@ pub fn render(frame: &mut Frame<'_>, app: &App) { render_repo_list(frame, app, chunks[1]); } - let status = Paragraph::new(app.status.clone()).block( - Block::default() - .borders(Borders::ALL) - .title("Status"), - ); + let status = Paragraph::new(app.status.clone()) + .block(Block::default().borders(Borders::ALL).title("Status")); frame.render_widget(status, chunks[2]); let nav = Paragraph::new(nav_lines) @@ -108,7 +105,9 @@ pub fn render(frame: &mut Frame<'_>, app: &App) { let modal = Paragraph::new(app.clone_path_input.clone()) .block( Block::default() - .title("Clone Destination Path (Type path, Del clear, Enter confirm, Esc cancel)") + .title( + "Clone Destination Path (Type path, Del clear, Enter confirm, Esc cancel)", + ) .borders(Borders::ALL), ) .wrap(Wrap { trim: false }); @@ -127,6 +126,17 @@ pub fn render(frame: &mut Frame<'_>, app: &App) { frame.render_widget(modal, area); } + if app.focus == Focus::OAuthClientIdInput { + let area = centered_rect(frame.area(), 75, 20); + frame.render_widget(Clear, area); + let modal = Paragraph::new(app.oauth_client_id_input.clone()).block( + Block::default() + .title("OAuth Client ID (optional; Enter start, Del clear, Esc cancel)") + .borders(Borders::ALL), + ); + frame.render_widget(modal, area); + } + if app.focus == Focus::BranchPicker { let area = centered_rect(frame.area(), 60, 45); frame.render_widget(Clear, area); @@ -177,7 +187,10 @@ pub fn render(frame: &mut Frame<'_>, app: &App) { fn render_repo_list(frame: &mut Frame<'_>, app: &App, area: Rect) { let viewport_rows = usize::from(area.height.saturating_sub(2)).max(1); let max_start = app.repos.len().saturating_sub(viewport_rows); - let start = app.selected_repo.saturating_sub(viewport_rows / 2).min(max_start); + let start = app + .selected_repo + .saturating_sub(viewport_rows / 2) + .min(max_start); let end = (start + viewport_rows).min(app.repos.len()); let items = app.repos[start..end] @@ -185,7 +198,11 @@ fn render_repo_list(frame: &mut Frame<'_>, app: &App, area: Rect) { .enumerate() .map(|(index, repo)| { let absolute = start + index; - let marker = if absolute == app.selected_repo { ">" } else { " " }; + let marker = if absolute == app.selected_repo { + ">" + } else { + " " + }; let desc = repo.description.as_deref().unwrap_or("No description"); let lang = repo.language.as_deref().unwrap_or("unknown"); let line = format!( @@ -243,7 +260,10 @@ fn render_tree(frame: &mut Frame<'_>, app: &App, area: Rect) { let visible = app.visible_tree(); let viewport_rows = usize::from(area.height.saturating_sub(2)).max(1); let max_start = visible.len().saturating_sub(viewport_rows); - let start = app.selected_node.saturating_sub(viewport_rows / 2).min(max_start); + let start = app + .selected_node + .saturating_sub(viewport_rows / 2) + .min(max_start); let end = (start + viewport_rows).min(visible.len()); let items = visible[start..end] @@ -251,7 +271,11 @@ fn render_tree(frame: &mut Frame<'_>, app: &App, area: Rect) { .enumerate() .map(|(index, entry)| { let absolute = start + index; - let marker = if absolute == app.selected_node { ">" } else { " " }; + let marker = if absolute == app.selected_node { + ">" + } else { + " " + }; let indent = " ".repeat(entry.depth.min(8)); let icon = if entry.is_dir { "[D]" } else { "[F]" }; let text = format!("{marker} {indent}{icon} {}", entry.name); @@ -283,7 +307,9 @@ fn render_tree(frame: &mut Frame<'_>, app: &App, area: Rect) { fn render_preview(frame: &mut Frame<'_>, app: &App, area: Rect) { let viewport_rows = usize::from(area.height.saturating_sub(2)).max(1); - let start = app.preview_scroll.min(app.preview_lines.len().saturating_sub(1)); + let start = app + .preview_scroll + .min(app.preview_lines.len().saturating_sub(1)); let end = (start + viewport_rows).min(app.preview_lines.len()); let preview_slice = if app.preview_lines.is_empty() { vec![Line::from("")] @@ -293,7 +319,11 @@ fn render_preview(frame: &mut Frame<'_>, app: &App, area: Rect) { let title = format!( "{} ({}-{} / {})", app.preview_title, - if app.preview_lines.is_empty() { 0 } else { start + 1 }, + if app.preview_lines.is_empty() { + 0 + } else { + start + 1 + }, end, app.preview_lines.len() ); diff --git a/src/app/theme.rs b/src/app/theme.rs index 9f8e51e..b7c7639 100644 --- a/src/app/theme.rs +++ b/src/app/theme.rs @@ -47,12 +47,25 @@ pub fn selection_style(index: usize) -> Style { .add_modifier(Modifier::BOLD) } -fn nav_labels() -> [&'static str; 16] { +fn nav_labels() -> [&'static str; 17] { [ - " / Search ", " Enter Open/Preview ", " ↑/↓ Move ", " ← Prev Page ", " → Next Page ", - " Tab Repos/Tree/Preview ", " PgUp/PgDn Preview ", " Home/End Preview ", - " b Branch ", " f Find File ", " v Tree View ", " d Download File ", " c Clone ", - " t Token ", " Mouse Click/Scroll ", " Esc Back to Repo List ", + " / Search ", + " Enter Open/Preview ", + " ↑/↓ Move ", + " ← Prev Page ", + " → Next Page ", + " Tab Repos/Tree/Preview ", + " PgUp/PgDn Preview ", + " Home/End Preview ", + " b Branch ", + " f Find File ", + " v Tree View ", + " d Download File ", + " c Clone ", + " t Token ", + " o OAuth Login ", + " Esc Back ", + " Mouse Click/Scroll ", ] } diff --git a/src/auth.rs b/src/auth.rs index 2a180af..f89609d 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,3 +1,5 @@ +use crate::oauth_session; +use crate::secure_store; use anyhow::{Context, Result, anyhow}; use directories::ProjectDirs; use std::fs; @@ -5,12 +7,17 @@ use std::io::{self, Write}; use std::path::PathBuf; const ENV_TOKEN: &str = "GITHUB_TOKEN"; +const ENV_OAUTH_CLIENT_ID: &str = "GITNAPSE_GITHUB_OAUTH_CLIENT_ID"; +const ENV_GITHUB_CLIENT_ID: &str = "GITHUB_CLIENT_ID"; +const DEFAULT_OAUTH_CLIENT_ID: &str = "Iv23liX3yGiGUEYkSlFW"; +const TOKEN_SECRET_KEY: &str = "github_token"; fn token_file() -> Result { let project_dirs = ProjectDirs::from("com", "GitNapse", "GitNapse") .ok_or_else(|| anyhow!("Unable to resolve project config directory"))?; let dir = project_dirs.config_dir(); - fs::create_dir_all(dir).with_context(|| format!("Cannot create config dir: {}", dir.display()))?; + fs::create_dir_all(dir) + .with_context(|| format!("Cannot create config dir: {}", dir.display()))?; Ok(dir.join("token")) } @@ -22,18 +29,15 @@ pub fn load_token() -> Result> { } } - let file = token_file()?; - if !file.exists() { - return Ok(None); + if let Some(session_token) = oauth_session::resolve_access_token()? { + let trimmed = session_token.trim().to_string(); + if !trimmed.is_empty() { + return Ok(Some(trimmed)); + } } - let token = fs::read_to_string(&file) - .with_context(|| format!("Cannot read token file: {}", file.display()))?; - let token = token.trim().to_owned(); - if token.is_empty() { - return Ok(None); - } - Ok(Some(token)) + let file = token_file()?; + secure_store::load_secret(TOKEN_SECRET_KEY, &file) } pub fn save_token(token: &str) -> Result<()> { @@ -43,24 +47,15 @@ pub fn save_token(token: &str) -> Result<()> { } let file = token_file()?; - fs::write(&file, format!("{token}\n")) - .with_context(|| format!("Cannot write token file: {}", file.display()))?; - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(&file, fs::Permissions::from_mode(0o600)) - .with_context(|| format!("Cannot set secure permissions on {}", file.display()))?; - } + let _ = secure_store::save_secret(TOKEN_SECRET_KEY, &file, token)?; Ok(()) } pub fn clear_token() -> Result<()> { let file = token_file()?; - if file.exists() { - fs::remove_file(&file).with_context(|| format!("Cannot remove token file: {}", file.display()))?; - } + secure_store::clear_secret(TOKEN_SECRET_KEY, &file)?; + let _ = oauth_session::clear_session(); Ok(()) } @@ -86,16 +81,60 @@ pub fn clear_token_cli() -> Result<()> { } pub fn status_cli() -> Result<()> { - let env_ok = std::env::var(ENV_TOKEN).ok().filter(|t| !t.trim().is_empty()).is_some(); + let env_ok = std::env::var(ENV_TOKEN) + .ok() + .filter(|t| !t.trim().is_empty()) + .is_some(); + let oauth_client_id_ok = std::env::var(ENV_OAUTH_CLIENT_ID) + .ok() + .filter(|t| !t.trim().is_empty()) + .is_some(); + let github_client_id_ok = std::env::var(ENV_GITHUB_CLIENT_ID) + .ok() + .filter(|t| !t.trim().is_empty()) + .is_some(); let file = token_file()?; let file_ok = file.exists(); + let oauth_session_ok = oauth_session::load_session()?.is_some(); println!("Authentication status:"); - println!("- ENV {ENV_TOKEN}: {}", if env_ok { "available" } else { "missing" }); + println!( + "- ENV {ENV_TOKEN}: {}", + if env_ok { "available" } else { "missing" } + ); println!( "- Stored token file: {} ({})", file.display(), if file_ok { "present" } else { "missing" } ); + println!( + "- ENV {ENV_OAUTH_CLIENT_ID}: {}", + if oauth_client_id_ok { + "available" + } else { + "missing" + } + ); + println!( + "- ENV {ENV_GITHUB_CLIENT_ID}: {}", + if github_client_id_ok { + "available" + } else { + "missing" + } + ); + println!("- Built-in OAuth Client ID: {}", DEFAULT_OAUTH_CLIENT_ID); + println!( + "- OAuth session file: {}", + if oauth_session_ok { + "present" + } else { + "missing" + } + ); + println!( + "- Secret storage mode (preferred): {}", + secure_store::preferred_backend_name() + ); Ok(()) } diff --git a/src/cache.rs b/src/cache.rs index 1115845..88a7bce 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -47,8 +47,7 @@ impl PreviewCache { } let content = fs::read_to_string(&file).ok()?; - self.memory - .insert(key, (Instant::now(), content.clone())); + self.memory.insert(key, (Instant::now(), content.clone())); Some(content) } diff --git a/src/config.rs b/src/config.rs index 494f675..4f7c78c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -27,14 +27,17 @@ impl AccountConfig { let raw = fs::read_to_string(&file) .with_context(|| format!("Cannot read config file: {}", file.display()))?; - let cfg: AccountConfig = serde_json::from_str(&raw).context("Invalid account config format")?; + let cfg: AccountConfig = + serde_json::from_str(&raw).context("Invalid account config format")?; Ok(cfg) } pub fn save(&self) -> Result<()> { let file = config_file()?; - let content = serde_json::to_string_pretty(self).context("Cannot serialize account config")?; - fs::write(&file, content).with_context(|| format!("Cannot write config file: {}", file.display()))?; + let content = + serde_json::to_string_pretty(self).context("Cannot serialize account config")?; + fs::write(&file, content) + .with_context(|| format!("Cannot write config file: {}", file.display()))?; Ok(()) } } @@ -42,7 +45,11 @@ impl AccountConfig { pub fn config_file() -> Result { let dirs = ProjectDirs::from("com", "GitNapse", "GitNapse") .ok_or_else(|| anyhow!("Unable to resolve project config directory"))?; - fs::create_dir_all(dirs.config_dir()) - .with_context(|| format!("Cannot create config directory: {}", dirs.config_dir().display()))?; + fs::create_dir_all(dirs.config_dir()).with_context(|| { + format!( + "Cannot create config directory: {}", + dirs.config_dir().display() + ) + })?; Ok(Path::new(dirs.config_dir()).join("account.json")) } diff --git a/src/github.rs b/src/github.rs index dce02fb..de4f662 100644 --- a/src/github.rs +++ b/src/github.rs @@ -1,5 +1,6 @@ use crate::models::{ - AuthenticatedUser, BranchInfo, ContentResponse, RepoNode, RepoSummary, SearchResponse, TreeResponse, + AuthenticatedUser, BranchInfo, ContentResponse, RepoNode, RepoSummary, SearchResponse, + TreeResponse, }; use anyhow::{Context, Result, anyhow}; use base64::Engine; @@ -13,10 +14,85 @@ pub struct GitHubClient { } impl GitHubClient { + fn api_base() -> String { + std::env::var("GITNAPSE_GITHUB_API") + .ok() + .map(|v| v.trim().trim_end_matches('/').to_string()) + .filter(|v| !v.is_empty()) + .unwrap_or_else(|| GITHUB_API.to_string()) + } + + fn parse_me_query(query: &str) -> Option { + let trimmed = query.trim(); + if trimmed.eq_ignore_ascii_case("@me") { + return Some(String::new()); + } + if let Some(rest) = trimmed.strip_prefix("@me ") { + return Some(rest.trim().to_string()); + } + if let Some(rest) = trimmed.strip_prefix("me:") { + return Some(rest.trim().to_string()); + } + None + } + + fn list_authenticated_repositories( + &self, + page: u32, + per_page: u8, + filter: Option<&str>, + ) -> Result> { + let api_base = Self::api_base(); + let url = format!( + "{api_base}/user/repos?visibility=all&affiliation=owner,collaborator,organization_member&sort=updated&direction=desc&per_page={per_page}&page={page}" + ); + + let response = self + .client + .get(url) + .send() + .context("Network error while listing authenticated repositories")?; + + if response.status().as_u16() == 401 { + return Err(anyhow!( + "Authenticated repository listing requires a valid token/session." + )); + } + if !response.status().is_success() { + let status = response.status(); + let body = response.text().unwrap_or_default(); + return Err(anyhow!( + "GitHub authenticated repo listing failed ({status}): {body}" + )); + } + + let mut repos: Vec = response + .json() + .context("Invalid authenticated repositories response from GitHub")?; + + if let Some(text) = filter.map(|v| v.trim()).filter(|v| !v.is_empty()) { + let needle = text.to_lowercase(); + repos.retain(|repo| { + repo.full_name.to_lowercase().contains(&needle) + || repo.name.to_lowercase().contains(&needle) + || repo + .description + .as_ref() + .map(|desc| desc.to_lowercase().contains(&needle)) + .unwrap_or(false) + }); + } + + Ok(repos) + } + pub fn new(token: Option<&str>) -> Result { let mut headers = HeaderMap::new(); headers.insert(USER_AGENT, HeaderValue::from_static("gitnapse/0.1")); - headers.insert(ACCEPT, HeaderValue::from_static("application/vnd.github+json")); + headers.insert( + ACCEPT, + HeaderValue::from_static("application/vnd.github+json"), + ); if let Some(token) = token.filter(|t| !t.trim().is_empty()) { let value = HeaderValue::from_str(&format!("Bearer {}", token.trim())) @@ -28,7 +104,12 @@ impl GitHubClient { Ok(Self { client }) } - pub fn search_repositories_page(&self, query: &str, page: u32, per_page: u8) -> Result> { + pub fn search_repositories_page( + &self, + query: &str, + page: u32, + per_page: u8, + ) -> Result> { let query = query.trim(); if query.is_empty() { return Ok(Vec::new()); @@ -36,8 +117,13 @@ impl GitHubClient { let page = page.max(1); let per_page = per_page.clamp(1, 100); + if let Some(filter) = Self::parse_me_query(query) { + return self.list_authenticated_repositories(page, per_page, Some(filter.as_str())); + } + + let api_base = Self::api_base(); let url = format!( - "{GITHUB_API}/search/repositories?q={}&sort=stars&order=desc&per_page={per_page}&page={page}", + "{api_base}/search/repositories?q={}&sort=stars&order=desc&per_page={per_page}&page={page}", query.replace(' ', "+"), ); @@ -53,12 +139,15 @@ impl GitHubClient { return Err(anyhow!("GitHub search failed ({status}): {body}")); } - let data: SearchResponse = response.json().context("Invalid search response from GitHub")?; + let data: SearchResponse = response + .json() + .context("Invalid search response from GitHub")?; Ok(data.items) } pub fn fetch_branches(&self, full_name: &str) -> Result> { - let url = format!("{GITHUB_API}/repos/{full_name}/branches?per_page=100"); + let api_base = Self::api_base(); + let url = format!("{api_base}/repos/{full_name}/branches?per_page=100"); let response = self .client .get(url) @@ -71,13 +160,20 @@ impl GitHubClient { return Err(anyhow!("GitHub branch fetch failed ({status}): {body}")); } - let branches: Vec = response.json().context("Invalid branch response from GitHub")?; + let branches: Vec = response + .json() + .context("Invalid branch response from GitHub")?; Ok(branches.into_iter().map(|b| b.name).collect()) } pub fn fetch_repo_tree(&self, full_name: &str, branch: &str) -> Result> { - let branch = if branch.trim().is_empty() { "HEAD" } else { branch }; - let url = format!("{GITHUB_API}/repos/{full_name}/git/trees/{branch}?recursive=1"); + let branch = if branch.trim().is_empty() { + "HEAD" + } else { + branch + }; + let api_base = Self::api_base(); + let url = format!("{api_base}/repos/{full_name}/git/trees/{branch}?recursive=1"); let response = self .client @@ -91,7 +187,9 @@ impl GitHubClient { return Err(anyhow!("GitHub tree fetch failed ({status}): {body}")); } - let data: TreeResponse = response.json().context("Invalid tree response from GitHub")?; + let data: TreeResponse = response + .json() + .context("Invalid tree response from GitHub")?; let mut nodes = data .tree .into_iter() @@ -125,12 +223,18 @@ impl GitHubClient { self.fetch_file_content_by_ref(full_name, path, "") } - pub fn fetch_file_content_by_ref(&self, full_name: &str, path: &str, git_ref: &str) -> Result { + pub fn fetch_file_content_by_ref( + &self, + full_name: &str, + path: &str, + git_ref: &str, + ) -> Result { + let api_base = Self::api_base(); let url = if git_ref.trim().is_empty() { - format!("{GITHUB_API}/repos/{full_name}/contents/{path}") + format!("{api_base}/repos/{full_name}/contents/{path}") } else { format!( - "{GITHUB_API}/repos/{full_name}/contents/{path}?ref={}", + "{api_base}/repos/{full_name}/contents/{path}?ref={}", git_ref.trim() ) }; @@ -146,7 +250,9 @@ impl GitHubClient { return Err(anyhow!("GitHub content fetch failed ({status}): {body}")); } - let data: ContentResponse = response.json().context("Invalid content response from GitHub")?; + let data: ContentResponse = response + .json() + .context("Invalid content response from GitHub")?; if data.encoding != "base64" { return Err(anyhow!("Unsupported file encoding: {}", data.encoding)); } @@ -160,9 +266,10 @@ impl GitHubClient { } pub fn fetch_authenticated_user(&self) -> Result> { + let api_base = Self::api_base(); let response = self .client - .get(format!("{GITHUB_API}/user")) + .get(format!("{api_base}/user")) .send() .context("Network error while validating token")?; @@ -174,7 +281,9 @@ impl GitHubClient { let body = response.text().unwrap_or_default(); return Err(anyhow!("GitHub user lookup failed ({status}): {body}")); } - let user: AuthenticatedUser = response.json().context("Invalid user response from GitHub")?; + let user: AuthenticatedUser = response + .json() + .context("Invalid user response from GitHub")?; Ok(Some(user.login)) } } diff --git a/src/main.rs b/src/main.rs index 39c096d..dfa1d23 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,9 @@ mod cache; mod config; mod github; mod models; +mod oauth; +mod oauth_session; +mod secure_store; mod syntax; use anyhow::Result; @@ -12,7 +15,11 @@ use std::fs; use std::path::PathBuf; #[derive(Debug, Parser)] -#[command(name = "gitnapse", version, about = "Terminal GitHub repository explorer")] +#[command( + name = "gitnapse", + version, + about = "Terminal GitHub repository explorer" +)] struct Cli { #[command(subcommand)] command: Option, @@ -86,9 +93,31 @@ enum AuthAction { Clear, /// Show token source availability Status, + /// OAuth login using GitHub device flow (octocrab) + Oauth { + #[command(subcommand)] + action: OauthAction, + }, +} + +#[derive(Debug, Subcommand)] +enum OauthAction { + /// Login using OAuth device flow and persist the resulting token + Login { + /// GitHub OAuth app Client ID. If omitted, uses GITNAPSE_GITHUB_OAUTH_CLIENT_ID. + #[arg(long)] + client_id: Option, + /// OAuth scopes. Repeat or use comma-separated values. + #[arg(long = "scope", value_delimiter = ',')] + scope: Vec, + /// Poll timeout in seconds while waiting for browser authorization + #[arg(long, default_value_t = 900)] + timeout_secs: u64, + }, } fn main() -> Result<()> { + let _ = dotenvy::dotenv(); let cli = Cli::parse(); match cli.command { @@ -98,6 +127,13 @@ fn main() -> Result<()> { AuthAction::Set { token } => auth::set_token_cli(token), AuthAction::Clear => auth::clear_token_cli(), AuthAction::Status => auth::status_cli(), + AuthAction::Oauth { action } => match action { + OauthAction::Login { + client_id, + scope, + timeout_secs, + } => oauth::oauth_device_login_cli(client_id, scope, timeout_secs), + }, }, None => app::run(), } @@ -115,10 +151,17 @@ fn download_file_cli(args: DownloadFileArgs) -> Result<()> { _ => client.fetch_file_content(&args.repo, &args.path)?, }; - if let Some(parent) = args.out.parent() && !parent.as_os_str().is_empty() { + if let Some(parent) = args.out.parent() + && !parent.as_os_str().is_empty() + { fs::create_dir_all(parent)?; } fs::write(&args.out, content)?; - println!("Downloaded {}:{} -> {}", args.repo, args.path, args.out.display()); + println!( + "Downloaded {}:{} -> {}", + args.repo, + args.path, + args.out.display() + ); Ok(()) } diff --git a/src/oauth.rs b/src/oauth.rs new file mode 100644 index 0000000..f04a214 --- /dev/null +++ b/src/oauth.rs @@ -0,0 +1,165 @@ +use crate::auth; +use crate::github::GitHubClient; +use crate::oauth_session; +use anyhow::{Context, Result, anyhow}; +use http::header::ACCEPT; +use secrecy::{ExposeSecret, SecretString}; +use std::process::Command; +use std::time::Duration; + +const ENV_OAUTH_CLIENT_ID: &str = "GITNAPSE_GITHUB_OAUTH_CLIENT_ID"; +const ENV_GITHUB_CLIENT_ID: &str = "GITHUB_CLIENT_ID"; +const DEFAULT_OAUTH_CLIENT_ID: &str = "Iv23liX3yGiGUEYkSlFW"; + +fn resolve_client_id(client_id: Option) -> Result { + if let Some(cli_id) = client_id { + let trimmed = cli_id.trim().to_string(); + if !trimmed.is_empty() { + return Ok(trimmed); + } + } + + if let Ok(env_id) = std::env::var(ENV_OAUTH_CLIENT_ID) { + let trimmed = env_id.trim().to_string(); + if !trimmed.is_empty() { + return Ok(trimmed); + } + } + + if let Ok(env_id) = std::env::var(ENV_GITHUB_CLIENT_ID) { + let trimmed = env_id.trim().to_string(); + if !trimmed.is_empty() { + return Ok(trimmed); + } + } + + Ok(DEFAULT_OAUTH_CLIENT_ID.to_string()) +} + +fn terminal_hyperlink(url: &str) -> String { + format!("\x1b]8;;{url}\x1b\\{url}\x1b]8;;\x1b\\") +} + +fn ensure_rustls_crypto_provider() { + // Some environments cannot auto-select rustls provider at runtime. + let _ = + rustls::crypto::CryptoProvider::install_default(rustls::crypto::ring::default_provider()); +} + +fn try_open_browser(url: &str) -> bool { + if webbrowser::open(url).is_ok() { + return true; + } + // Fallbacks for terminals/environments where webbrowser backend is unavailable. + if cfg!(target_os = "linux") { + if Command::new("xdg-open").arg(url).status().is_ok() { + return true; + } + if Command::new("wslview").arg(url).status().is_ok() { + return true; + } + } else if cfg!(target_os = "macos") { + if Command::new("open").arg(url).status().is_ok() { + return true; + } + } else if cfg!(target_os = "windows") + && Command::new("cmd") + .args(["/C", "start", "", url]) + .status() + .is_ok() + { + return true; + } + false +} + +pub fn oauth_device_login_cli( + client_id: Option, + scopes: Vec, + timeout_secs: u64, +) -> Result<()> { + ensure_rustls_crypto_provider(); + let client_id = resolve_client_id(client_id)?; + let scopes = if scopes.is_empty() { + vec!["read:user".to_string()] + } else { + scopes + .into_iter() + .map(|scope| scope.trim().to_string()) + .filter(|scope| !scope.is_empty()) + .collect::>() + }; + + let client_secret = SecretString::new(client_id.clone().into()); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("Cannot initialize async runtime for OAuth flow")?; + + let (crab, device_codes) = runtime + .block_on(async { + let crab = octocrab::Octocrab::builder() + .base_uri("https://github.com") + .context("Cannot set OAuth base URI")? + .add_header(ACCEPT, "application/json".to_string()) + .build() + .context("Cannot create OAuth client")?; + + let device_codes = crab + .authenticate_as_device(&client_secret, scopes.iter().map(String::as_str)) + .await + .context("Unable to request OAuth device codes from GitHub")?; + Ok::<_, anyhow::Error>((crab, device_codes)) + }) + .context("Unable to request OAuth device codes from GitHub")?; + + println!("OAuth device login started."); + let opened = try_open_browser(&device_codes.verification_uri); + if opened { + println!("1. Browser launch requested automatically."); + println!(" If no browser appears, open this URL manually."); + } + println!( + "1. Open this URL in your browser: {}", + device_codes.verification_uri + ); + println!( + " Clickable link (if your terminal supports OSC8): {}", + terminal_hyperlink(&device_codes.verification_uri) + ); + println!("2. Enter code: {}", device_codes.user_code); + println!("3. After authorization, keep this terminal open while token exchange completes."); + println!("Scopes requested: {}", scopes.join(",")); + + let timeout = Duration::from_secs(timeout_secs.max(60)); + let oauth = runtime + .block_on(async { + tokio::time::timeout( + timeout, + device_codes.poll_until_available(&crab, &client_secret), + ) + .await + }) + .map_err(|_| { + anyhow!( + "OAuth device flow timed out after {} seconds.", + timeout.as_secs() + ) + })? + .context("OAuth token exchange failed")?; + + let access_token = oauth.access_token.expose_secret().to_string(); + auth::save_token(&access_token).context("Cannot store OAuth access token")?; + oauth_session::save_from_oauth(&oauth, &client_id) + .context("Cannot store OAuth session metadata")?; + + let login = GitHubClient::new(Some(&access_token)) + .context("Cannot validate OAuth token with API client")? + .fetch_authenticated_user() + .ok() + .flatten() + .unwrap_or_else(|| "unknown user".to_string()); + + println!("OAuth login completed. Token saved securely for user: {login}"); + Ok(()) +} diff --git a/src/oauth_session.rs b/src/oauth_session.rs new file mode 100644 index 0000000..253af8f --- /dev/null +++ b/src/oauth_session.rs @@ -0,0 +1,232 @@ +use crate::secure_store; +use anyhow::{Context, Result, anyhow}; +use directories::ProjectDirs; +use reqwest::blocking::Client; +use reqwest::header::{ACCEPT, CONTENT_TYPE, HeaderMap, HeaderValue, USER_AGENT}; +use secrecy::ExposeSecret; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; +use url::form_urlencoded::Serializer; + +const SESSION_FILE: &str = "oauth_session.json"; +const SESSION_SECRET_KEY: &str = "oauth_session_json"; +const ENV_OAUTH_CLIENT_SECRET: &str = "GITNAPSE_GITHUB_OAUTH_CLIENT_SECRET"; +const ENV_GITHUB_CLIENT_SECRET: &str = "GITHUB_CLIENT_SECRET"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OAuthSession { + pub access_token: String, + pub token_type: String, + pub scope: Vec, + pub expires_at_unix: Option, + pub refresh_token: Option, + pub refresh_expires_at_unix: Option, + pub client_id: String, +} + +#[derive(Debug, Deserialize)] +struct RefreshWire { + access_token: Option, + token_type: Option, + scope: Option, + expires_in: Option, + refresh_token: Option, + refresh_token_expires_in: Option, + error: Option, + _error_description: Option, +} + +fn now_unix() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +fn session_file() -> Result { + let dirs = ProjectDirs::from("com", "GitNapse", "GitNapse") + .ok_or_else(|| anyhow!("Unable to resolve project config directory"))?; + fs::create_dir_all(dirs.config_dir()).with_context(|| { + format!( + "Cannot create config directory: {}", + dirs.config_dir().display() + ) + })?; + Ok(Path::new(dirs.config_dir()).join(SESSION_FILE)) +} + +pub fn save_from_oauth(oauth: &octocrab::auth::OAuth, client_id: &str) -> Result<()> { + let now = now_unix(); + let session = OAuthSession { + access_token: oauth.access_token.expose_secret().to_string(), + token_type: oauth.token_type.clone(), + scope: oauth.scope.clone(), + expires_at_unix: oauth.expires_in.map(|s| now.saturating_add(s as u64)), + refresh_token: oauth + .refresh_token + .as_ref() + .map(|value| value.expose_secret().to_string()), + refresh_expires_at_unix: oauth + .refresh_token_expires_in + .map(|s| now.saturating_add(s as u64)), + client_id: client_id.to_string(), + }; + save_session(&session) +} + +pub fn save_session(session: &OAuthSession) -> Result<()> { + let file = session_file()?; + let content = + serde_json::to_string_pretty(session).context("Cannot serialize OAuth session")?; + let _ = secure_store::save_secret(SESSION_SECRET_KEY, &file, &content)?; + Ok(()) +} + +pub fn clear_session() -> Result<()> { + let file = session_file()?; + secure_store::clear_secret(SESSION_SECRET_KEY, &file)?; + Ok(()) +} + +pub fn load_session() -> Result> { + let file = session_file()?; + let Some(raw) = secure_store::load_secret(SESSION_SECRET_KEY, &file)? else { + return Ok(None); + }; + let session: OAuthSession = + serde_json::from_str(&raw).context("Invalid OAuth session format")?; + Ok(Some(session)) +} + +pub fn resolve_access_token() -> Result> { + let Some(mut session) = load_session()? else { + return Ok(None); + }; + + // If still valid (or no expiry metadata), use it directly. + let now = now_unix(); + let about_to_expire = session + .expires_at_unix + .map(|exp| exp <= now.saturating_add(60)) + .unwrap_or(false); + if !about_to_expire { + return Ok(Some(session.access_token)); + } + + // If expiring/expired, attempt refresh when possible. + if let Some(refreshed) = try_refresh(&session)? { + session = refreshed; + save_session(&session)?; + return Ok(Some(session.access_token)); + } + + // No refresh available; caller can fallback to legacy token file. + Ok(Some(session.access_token)) +} + +fn try_refresh(session: &OAuthSession) -> Result> { + let Some(refresh_token) = session + .refresh_token + .as_ref() + .filter(|t| !t.trim().is_empty()) + else { + return Ok(None); + }; + + let now = now_unix(); + if session + .refresh_expires_at_unix + .map(|exp| exp <= now.saturating_add(60)) + .unwrap_or(false) + { + return Ok(None); + } + + let client_secret = std::env::var(ENV_OAUTH_CLIENT_SECRET) + .ok() + .or_else(|| std::env::var(ENV_GITHUB_CLIENT_SECRET).ok()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + let Some(client_secret) = client_secret else { + return Ok(None); + }; + + let mut headers = HeaderMap::new(); + headers.insert(USER_AGENT, HeaderValue::from_static("gitnapse/0.1")); + headers.insert(ACCEPT, HeaderValue::from_static("application/json")); + + let client = Client::builder() + .default_headers(headers) + .build() + .context("Cannot build OAuth refresh HTTP client")?; + + let body = Serializer::new(String::new()) + .append_pair("client_id", session.client_id.as_str()) + .append_pair("client_secret", client_secret.as_str()) + .append_pair("grant_type", "refresh_token") + .append_pair("refresh_token", refresh_token.as_str()) + .finish(); + + let response = client + .post("https://github.com/login/oauth/access_token") + .header(CONTENT_TYPE, "application/x-www-form-urlencoded") + .body(body) + .send() + .context("OAuth refresh request failed")?; + + if !response.status().is_success() { + return Ok(None); + } + let wire: RefreshWire = response.json().context("Invalid OAuth refresh response")?; + if wire.error.is_some() { + return Ok(None); + } + let Some(access_token) = wire.access_token.filter(|s| !s.trim().is_empty()) else { + return Ok(None); + }; + + let scope = wire + .scope + .unwrap_or_default() + .split(',') + .filter(|s| !s.trim().is_empty()) + .map(|s| s.trim().to_string()) + .collect::>(); + + let now = now_unix(); + Ok(Some(OAuthSession { + access_token, + token_type: wire.token_type.unwrap_or_else(|| "bearer".to_string()), + scope, + expires_at_unix: wire.expires_in.map(|s| now.saturating_add(s)), + refresh_token: wire + .refresh_token + .or_else(|| Some(refresh_token.to_string())), + refresh_expires_at_unix: wire.refresh_token_expires_in.map(|s| now.saturating_add(s)), + client_id: session.client_id.clone(), + })) +} + +#[cfg(test)] +mod tests { + use super::OAuthSession; + + #[test] + fn session_serialization_roundtrip() { + let session = OAuthSession { + access_token: "a".to_string(), + token_type: "bearer".to_string(), + scope: vec!["read:user".to_string()], + expires_at_unix: Some(123), + refresh_token: Some("r".to_string()), + refresh_expires_at_unix: Some(456), + client_id: "cid".to_string(), + }; + let text = serde_json::to_string(&session).expect("serialize"); + let parsed: OAuthSession = serde_json::from_str(&text).expect("deserialize"); + assert_eq!(parsed.client_id, "cid"); + assert_eq!(parsed.scope.len(), 1); + } +} diff --git a/src/secure_store.rs b/src/secure_store.rs new file mode 100644 index 0000000..fcbabb8 --- /dev/null +++ b/src/secure_store.rs @@ -0,0 +1,158 @@ +use anyhow::{Context, Result}; +use std::fs; +use std::path::Path; + +const KEYRING_SERVICE: &str = "com.GitNapse.GitNapse"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SecretBackend { + Keyring, + File, +} + +fn is_wsl() -> bool { + if std::env::var("WSL_DISTRO_NAME") + .ok() + .filter(|v| !v.trim().is_empty()) + .is_some() + { + return true; + } + #[cfg(target_os = "linux")] + { + if let Ok(version) = fs::read_to_string("/proc/version") + && version.to_ascii_lowercase().contains("microsoft") + { + return true; + } + } + false +} + +fn should_try_keyring() -> bool { + !is_wsl() +} + +fn keyring_get(secret_key: &str) -> Option>> { + if !should_try_keyring() { + return None; + } + let entry = keyring::Entry::new(KEYRING_SERVICE, secret_key) + .map_err(anyhow::Error::from) + .context("Cannot initialize keyring entry"); + match entry { + Ok(entry) => match entry.get_password() { + Ok(value) => Some(Ok(Some(value))), + Err(_) => Some(Ok(None)), + }, + Err(error) => Some(Err(error)), + } +} + +fn keyring_set(secret_key: &str, value: &str) -> Option> { + if !should_try_keyring() { + return None; + } + let entry = keyring::Entry::new(KEYRING_SERVICE, secret_key) + .map_err(anyhow::Error::from) + .context("Cannot initialize keyring entry"); + match entry { + Ok(entry) => Some( + entry + .set_password(value) + .map_err(anyhow::Error::from) + .context("Cannot write secret to keyring"), + ), + Err(error) => Some(Err(error)), + } +} + +fn keyring_delete(secret_key: &str) -> Option> { + if !should_try_keyring() { + return None; + } + let entry = keyring::Entry::new(KEYRING_SERVICE, secret_key) + .map_err(anyhow::Error::from) + .context("Cannot initialize keyring entry"); + match entry { + Ok(entry) => Some( + entry + .delete_credential() + .map_err(anyhow::Error::from) + .context("Cannot delete keyring secret"), + ), + Err(error) => Some(Err(error)), + } +} + +fn file_read(path: &Path) -> Result> { + if !path.exists() { + return Ok(None); + } + let value = fs::read_to_string(path) + .with_context(|| format!("Cannot read secret file: {}", path.display()))?; + let trimmed = value.trim().to_string(); + if trimmed.is_empty() { + return Ok(None); + } + Ok(Some(trimmed)) +} + +fn file_write(path: &Path, value: &str) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("Cannot create secret directory: {}", parent.display()))?; + } + fs::write(path, format!("{value}\n")) + .with_context(|| format!("Cannot write secret file: {}", path.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o600)) + .with_context(|| format!("Cannot set secure permissions on {}", path.display()))?; + } + Ok(()) +} + +fn file_delete(path: &Path) -> Result<()> { + if path.exists() { + fs::remove_file(path) + .with_context(|| format!("Cannot remove secret file: {}", path.display()))?; + } + Ok(()) +} + +pub fn save_secret(secret_key: &str, fallback_file: &Path, value: &str) -> Result { + if let Some(result) = keyring_set(secret_key, value) + && result.is_ok() + { + let _ = file_delete(fallback_file); + return Ok(SecretBackend::Keyring); + } + file_write(fallback_file, value)?; + Ok(SecretBackend::File) +} + +pub fn load_secret(secret_key: &str, fallback_file: &Path) -> Result> { + if let Some(result) = keyring_get(secret_key) + && let Ok(Some(value)) = result + { + return Ok(Some(value)); + } + file_read(fallback_file) +} + +pub fn clear_secret(secret_key: &str, fallback_file: &Path) -> Result<()> { + if let Some(result) = keyring_delete(secret_key) { + let _ = result; + } + file_delete(fallback_file) +} + +pub fn preferred_backend_name() -> &'static str { + if should_try_keyring() { + "keyring" + } else { + "file-fallback" + } +} diff --git a/src/syntax.rs b/src/syntax.rs index 04d69ac..266d146 100644 --- a/src/syntax.rs +++ b/src/syntax.rs @@ -2,13 +2,45 @@ use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; const KEYWORDS: &[&str] = &[ - "fn", "let", "mut", "pub", "impl", "struct", "enum", "trait", "if", "else", "match", "for", - "while", "loop", "return", "use", "mod", "async", "await", "const", "static", "class", "def", - "import", "from", "export", "interface", "type", "package", "func", "var", + "fn", + "let", + "mut", + "pub", + "impl", + "struct", + "enum", + "trait", + "if", + "else", + "match", + "for", + "while", + "loop", + "return", + "use", + "mod", + "async", + "await", + "const", + "static", + "class", + "def", + "import", + "from", + "export", + "interface", + "type", + "package", + "func", + "var", ]; pub fn highlight_content(content: &str, path: &str, max_lines: usize) -> Vec> { - let ext = path.rsplit('.').next().unwrap_or_default().to_ascii_lowercase(); + let ext = path + .rsplit('.') + .next() + .unwrap_or_default() + .to_ascii_lowercase(); let comment_prefix = match ext.as_str() { "py" | "sh" | "toml" | "yaml" | "yml" | "rb" => "#", "rs" | "js" | "ts" | "tsx" | "java" | "c" | "cpp" | "go" | "swift" | "kt" => "//", diff --git a/tests/auth_precedence_tests.rs b/tests/auth_precedence_tests.rs new file mode 100644 index 0000000..c481c3f --- /dev/null +++ b/tests/auth_precedence_tests.rs @@ -0,0 +1,26 @@ +#![allow(dead_code)] + +#[path = "../src/auth.rs"] +mod auth; +#[path = "../src/oauth_session.rs"] +mod oauth_session; +#[path = "../src/secure_store.rs"] +mod secure_store; + +use serial_test::serial; + +#[test] +#[serial] +fn env_token_has_precedence_over_stored_sources() { + let prev = std::env::var("GITHUB_TOKEN").ok(); + unsafe { std::env::set_var("GITHUB_TOKEN", "env-priority-token") }; + + let loaded = auth::load_token().expect("load token"); + assert_eq!(loaded.as_deref(), Some("env-priority-token")); + + if let Some(value) = prev { + unsafe { std::env::set_var("GITHUB_TOKEN", value) }; + } else { + unsafe { std::env::remove_var("GITHUB_TOKEN") }; + } +} diff --git a/tests/github_search_tests.rs b/tests/github_search_tests.rs new file mode 100644 index 0000000..24bfea3 --- /dev/null +++ b/tests/github_search_tests.rs @@ -0,0 +1,138 @@ +#![allow(dead_code)] + +#[path = "../src/github.rs"] +mod github; +#[path = "../src/models.rs"] +mod models; + +use github::GitHubClient; +use mockito::{Matcher, Server}; +use serial_test::serial; + +fn with_api_base(base: &str, test: impl FnOnce() -> T) -> T { + let prev = std::env::var("GITNAPSE_GITHUB_API").ok(); + unsafe { std::env::set_var("GITNAPSE_GITHUB_API", base) }; + let out = test(); + if let Some(value) = prev { + unsafe { std::env::set_var("GITNAPSE_GITHUB_API", value) }; + } else { + unsafe { std::env::remove_var("GITNAPSE_GITHUB_API") }; + } + out +} + +#[test] +#[serial] +fn search_general_uses_search_endpoint() { + let mut server = Server::new(); + let _m = server + .mock("GET", "/search/repositories") + .match_query(Matcher::Regex( + r"q=rust\+language:rust.*per_page=30.*page=1".to_string(), + )) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{ + "items": [ + { + "name": "repo-one", + "full_name": "x/repo-one", + "description": "General search result", + "stargazers_count": 10, + "language": "Rust", + "clone_url": "https://github.com/x/repo-one.git", + "owner": { "login": "x" }, + "default_branch": "main" + } + ] + }"#, + ) + .create(); + + with_api_base(&server.url(), || { + let client = GitHubClient::new(None).expect("client"); + let repos = client + .search_repositories_page("rust language:rust", 1, 30) + .expect("search"); + assert_eq!(repos.len(), 1); + assert_eq!(repos[0].full_name, "x/repo-one"); + }); +} + +#[test] +#[serial] +fn me_query_lists_and_filters_authenticated_repos() { + let mut server = Server::new(); + let _m = server + .mock("GET", "/user/repos") + .match_query(Matcher::AllOf(vec![ + Matcher::UrlEncoded("visibility".into(), "all".into()), + Matcher::UrlEncoded( + "affiliation".into(), + "owner,collaborator,organization_member".into(), + ), + Matcher::UrlEncoded("per_page".into(), "30".into()), + Matcher::UrlEncoded("page".into(), "1".into()), + ])) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"[ + { + "name": "alpha-rust", + "full_name": "me/alpha-rust", + "description": "Rust private project", + "stargazers_count": 1, + "language": "Rust", + "clone_url": "https://github.com/me/alpha-rust.git", + "owner": { "login": "me" }, + "default_branch": "main" + }, + { + "name": "beta-js", + "full_name": "me/beta-js", + "description": "JavaScript project", + "stargazers_count": 2, + "language": "JavaScript", + "clone_url": "https://github.com/me/beta-js.git", + "owner": { "login": "me" }, + "default_branch": "main" + } + ]"#, + ) + .create(); + + with_api_base(&server.url(), || { + let client = GitHubClient::new(Some("token")).expect("client"); + let repos = client + .search_repositories_page("@me rust", 1, 30) + .expect("search"); + assert_eq!(repos.len(), 1); + assert_eq!(repos[0].full_name, "me/alpha-rust"); + }); +} + +#[test] +#[serial] +fn me_query_returns_error_on_unauthorized() { + let mut server = Server::new(); + let _m = server + .mock("GET", "/user/repos") + .match_query(Matcher::Any) + .with_status(401) + .with_header("content-type", "application/json") + .with_body(r#"{"message":"Bad credentials"}"#) + .create(); + + with_api_base(&server.url(), || { + let client = GitHubClient::new(None).expect("client"); + let err = client + .search_repositories_page("@me", 1, 30) + .expect_err("must fail"); + assert!( + err.to_string().contains("requires a valid token/session"), + "unexpected error: {err}" + ); + }); +} diff --git a/tests/secure_store_tests.rs b/tests/secure_store_tests.rs new file mode 100644 index 0000000..1a066a2 --- /dev/null +++ b/tests/secure_store_tests.rs @@ -0,0 +1,61 @@ +#![allow(dead_code)] + +#[path = "../src/secure_store.rs"] +mod secure_store; + +use serial_test::serial; +use tempfile::tempdir; + +#[test] +#[serial] +fn file_fallback_save_load_clear_roundtrip() { + let dir = tempdir().expect("tempdir"); + let file = dir.path().join("secret-token"); + let key = "test_secret_roundtrip"; + + let prev = std::env::var("WSL_DISTRO_NAME").ok(); + unsafe { std::env::set_var("WSL_DISTRO_NAME", "Ubuntu") }; + + let backend = secure_store::save_secret(key, &file, "abc123").expect("save"); + assert_eq!(backend, secure_store::SecretBackend::File); + + let loaded = secure_store::load_secret(key, &file).expect("load"); + assert_eq!(loaded.as_deref(), Some("abc123")); + + secure_store::clear_secret(key, &file).expect("clear"); + let loaded_after_clear = secure_store::load_secret(key, &file).expect("load after clear"); + assert_eq!(loaded_after_clear, None); + + if let Some(value) = prev { + unsafe { std::env::set_var("WSL_DISTRO_NAME", value) }; + } else { + unsafe { std::env::remove_var("WSL_DISTRO_NAME") }; + } +} + +#[test] +#[serial] +#[cfg(unix)] +fn file_fallback_sets_secure_permissions_on_unix() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempdir().expect("tempdir"); + let file = dir.path().join("secret-permissions"); + let key = "test_secret_permissions"; + + let prev = std::env::var("WSL_DISTRO_NAME").ok(); + unsafe { std::env::set_var("WSL_DISTRO_NAME", "Ubuntu") }; + + let backend = secure_store::save_secret(key, &file, "perm-check").expect("save"); + assert_eq!(backend, secure_store::SecretBackend::File); + + let metadata = std::fs::metadata(&file).expect("metadata"); + let mode = metadata.permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + + if let Some(value) = prev { + unsafe { std::env::set_var("WSL_DISTRO_NAME", value) }; + } else { + unsafe { std::env::remove_var("WSL_DISTRO_NAME") }; + } +} From d1912074f860c4654d97ef05528e7045aeb2629f Mon Sep 17 00:00:00 2001 From: xscriptor Date: Sat, 25 Apr 2026 23:41:48 +0200 Subject: [PATCH 3/3] update docs oauth release usage modify o shortcut update ui set workflow for releases --- .github/workflows/release.yml | 19 ++++++- docs/OAUTH_AUTHENTICATION.md | 14 ++++- docs/RELEASE_WORKFLOW.md | 16 +++++- docs/USAGE.md | 14 +++-- src/app/mod.rs | 15 +++++- src/app/theme.rs | 2 +- src/github.rs | 98 ++++++++++++++++++++++++++--------- src/main.rs | 3 ++ src/oauth.rs | 24 +++++++++ tests/github_search_tests.rs | 2 +- 10 files changed, 173 insertions(+), 34 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3734ac6..1bdeece 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -153,6 +153,7 @@ jobs: - build-macos permissions: contents: write + id-token: write steps: - name: Download build artifacts uses: actions/download-artifact@v5 @@ -160,11 +161,27 @@ jobs: pattern: asset-* path: dist merge-multiple: true + - name: Generate GitHub App token + id: app_token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.RELEASE_GH_APP_ID }} + private-key: ${{ secrets.RELEASE_GH_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} - name: Show assets run: ls -lah dist + - name: Install cosign + uses: sigstore/cosign-installer@v3 + - name: Sign assets (keyless) + run: | + for file in dist/*; do + cosign sign-blob --yes "$file" \ + --output-signature "${file}.sig" \ + --output-certificate "${file}.pem" + done - name: Create or update release env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ steps.app_token.outputs.token }} run: | gh release view "${RELEASE_TAG}" >/dev/null 2>&1 || \ gh release create "${RELEASE_TAG}" --title "${RELEASE_TAG}" --generate-notes diff --git a/docs/OAUTH_AUTHENTICATION.md b/docs/OAUTH_AUTHENTICATION.md index 66f0390..09ebff8 100644 --- a/docs/OAUTH_AUTHENTICATION.md +++ b/docs/OAUTH_AUTHENTICATION.md @@ -8,6 +8,7 @@
    • OAuth Device Flow with octocrab
    • GitHub Configuration
    • Commands
    • +
    • TUI Behavior
    • Security Notes
    @@ -98,13 +99,24 @@ gitnapse auth oauth login --scope read:user o

    - The app opens an OAuth Client ID modal and then starts the device login flow. + The app performs a quick OAuth/authentication status check and prints guidance to use CLI login.

    Short timeout tuning:

    gitnapse auth oauth login --client-id YOUR_OAUTH_CLIENT_ID --timeout-secs 1200
     
    +

    Check OAuth state:

    +
    gitnapse auth oauth status
    +
    + +

    TUI Behavior

    +
      +
    • OAuth interactive login is CLI-first for reliability across terminal multiplexers and alternate screen modes.
    • +
    • Use gitnapse auth oauth login in normal terminal mode for device flow URL/code interaction.
    • +
    • Inside TUI, key o is intentionally reduced to quick status/help behavior.
    • +
    +

    Security Notes

    @@ -52,6 +53,19 @@ git push origin v1.0.0
  • Run the workflow to rebuild and upload assets with --clobber behavior.
  • +

    Secrets And Signing

    +
      +
    • Release publishing is authenticated with a GitHub App token generated from repository secrets.
    • +
    • Required repository secrets:
    • +
    • RELEASE_GH_APP_ID - GitHub App ID.
    • +
    • RELEASE_GH_APP_PRIVATE_KEY - GitHub App private key PEM content (multi-line).
    • +
    • Assets are signed in workflow using keyless cosign with GitHub OIDC (id-token: write).
    • +
    • Signature files (.sig) and certificates (.pem) are uploaded alongside each asset.
    • +
    +

    + Keep both GitHub App values in Secrets (not Variables). The App private key must never be stored in plain Variables. +

    +

    Collaboration Policy

    For protected-branch collaboration flow (main via Pull Requests only), see @@ -60,7 +74,7 @@ git push origin v1.0.0

    Official Notes

      -
    • The workflow uses GITHUB_TOKEN with least privilege, elevating contents: write only in the publish job.
    • +
    • The workflow uses a GitHub App installation token for release API operations and keeps contents: write limited to the publish job.
    • Release notes are generated by GitHub using gh release create --generate-notes.
    • Artifact passing between jobs uses actions/upload-artifact and actions/download-artifact.
    diff --git a/docs/USAGE.md b/docs/USAGE.md index c2494a9..786c318 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -45,7 +45,7 @@ gitnapse run --query "@me" List authenticated repositories (including private) gitnapse run --query "@me" - Requires valid login/token; supports optional filter: @me keyword + Requires valid login/token; supports optional filters: text terms and language: gitnapse auth set @@ -77,6 +77,12 @@ gitnapse auth oauth login --client-id YOUR_OAUTH_CLIENT_ID --scope read:user --scope repo Starts browser-based device authorization and stores access token securely + + gitnapse auth oauth status + Show OAuth/authentication state + gitnapse auth oauth status + Prints oauth_logged_in=true|false, authenticated=true|false, and current user when available + gitnapse download-file ... Download one file (curl/wget-like) @@ -113,7 +119,7 @@ dPreviewDownload modalSave current previewed file to local path DelPath modalsClear path inputWorks in clone/download path inputs tGlobalToken modalSave token from inside the TUI - oGlobalOAuth modalClient ID is optional; press Enter empty to use default and start device-flow login + oGlobalOAuth quick checkDoes not start login; runs status check and tells you to use CLI login command qGlobalQuitExit application Mouse left clickTree / Preview / ReposFocus & selectSingle click selects, double click opens (repo/file) Mouse wheelTree / PreviewScrollScroll behavior depends on pointer position @@ -127,7 +133,9 @@

    • Inside TUI search input (/): @me
    • -
    • Optional filter: @me rust or me:rust
    • +
    • Optional text filter: @me rust or me:rust
    • +
    • Language filter: @me language:rust or @me lang:javascript
    • +
    • Combined filters: @me language:rust private or @me language:rust,javascript api
    • CLI start: gitnapse run --query "@me"

    diff --git a/src/app/mod.rs b/src/app/mod.rs index d05227f..f7f72a5 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -657,6 +657,19 @@ impl App { } } + fn run_oauth_quick_check(&mut self) { + match oauth::oauth_status_cli() { + Ok(()) => { + self.status = + "OAuth status printed in terminal. For login use: gitnapse auth oauth login" + .to_string(); + } + Err(error) => { + self.status = format!("OAuth status check failed: {error}"); + } + } + } + fn run_oauth_login_flow(&mut self, client_id: Option) { self.status = "Starting OAuth device flow...".to_string(); @@ -723,7 +736,7 @@ impl App { self.input_buffer.clear(); } KeyCode::Char('o') => { - self.focus = Focus::OAuthClientIdInput; + self.run_oauth_quick_check(); } KeyCode::Char('c') => { if self.current_repo.is_some() { diff --git a/src/app/theme.rs b/src/app/theme.rs index b7c7639..b8bb956 100644 --- a/src/app/theme.rs +++ b/src/app/theme.rs @@ -63,7 +63,7 @@ fn nav_labels() -> [&'static str; 17] { " d Download File ", " c Clone ", " t Token ", - " o OAuth Login ", + " o OAuth State ", " Esc Back ", " Mouse Click/Scroll ", ] diff --git a/src/github.rs b/src/github.rs index de4f662..1214b4a 100644 --- a/src/github.rs +++ b/src/github.rs @@ -13,6 +13,12 @@ pub struct GitHubClient { client: Client, } +#[derive(Debug, Clone)] +struct MeQuery { + text_terms: Vec, + languages: Vec, +} + impl GitHubClient { fn api_base() -> String { std::env::var("GITNAPSE_GITHUB_API") @@ -22,25 +28,50 @@ impl GitHubClient { .unwrap_or_else(|| GITHUB_API.to_string()) } - fn parse_me_query(query: &str) -> Option { + fn parse_me_query(query: &str) -> Option { let trimmed = query.trim(); - if trimmed.eq_ignore_ascii_case("@me") { - return Some(String::new()); - } - if let Some(rest) = trimmed.strip_prefix("@me ") { - return Some(rest.trim().to_string()); - } - if let Some(rest) = trimmed.strip_prefix("me:") { - return Some(rest.trim().to_string()); + let rest = if trimmed.eq_ignore_ascii_case("@me") { + "" + } else if let Some(rest) = trimmed.strip_prefix("@me ") { + rest.trim() + } else if let Some(rest) = trimmed.strip_prefix("me:") { + rest.trim() + } else { + return None; + }; + + let mut text_terms = Vec::new(); + let mut languages = Vec::new(); + for raw in rest.split_whitespace() { + if let Some(lang_expr) = raw + .strip_prefix("language:") + .or_else(|| raw.strip_prefix("lang:")) + { + for lang in lang_expr.split(',') { + let lang = lang.trim().to_lowercase(); + if !lang.is_empty() { + languages.push(lang); + } + } + } else { + let term = raw.trim().to_lowercase(); + if !term.is_empty() { + text_terms.push(term); + } + } } - None + + Some(MeQuery { + text_terms, + languages, + }) } fn list_authenticated_repositories( &self, page: u32, per_page: u8, - filter: Option<&str>, + query: &MeQuery, ) -> Result> { let api_base = Self::api_base(); let url = format!( @@ -70,18 +101,35 @@ impl GitHubClient { .json() .context("Invalid authenticated repositories response from GitHub")?; - if let Some(text) = filter.map(|v| v.trim()).filter(|v| !v.is_empty()) { - let needle = text.to_lowercase(); - repos.retain(|repo| { - repo.full_name.to_lowercase().contains(&needle) - || repo.name.to_lowercase().contains(&needle) - || repo - .description - .as_ref() - .map(|desc| desc.to_lowercase().contains(&needle)) - .unwrap_or(false) - }); - } + repos.retain(|repo| { + let language_match = if query.languages.is_empty() { + true + } else { + repo.language + .as_deref() + .map(|lang| lang.to_lowercase()) + .map(|lang| query.languages.iter().any(|candidate| candidate == &lang)) + .unwrap_or(false) + }; + if !language_match { + return false; + } + + if query.text_terms.is_empty() { + return true; + } + + let haystack = format!( + "{} {} {}", + repo.full_name.to_lowercase(), + repo.name.to_lowercase(), + repo.description + .as_ref() + .map(|desc| desc.to_lowercase()) + .unwrap_or_default() + ); + query.text_terms.iter().all(|term| haystack.contains(term)) + }); Ok(repos) } @@ -117,8 +165,8 @@ impl GitHubClient { let page = page.max(1); let per_page = per_page.clamp(1, 100); - if let Some(filter) = Self::parse_me_query(query) { - return self.list_authenticated_repositories(page, per_page, Some(filter.as_str())); + if let Some(me_query) = Self::parse_me_query(query) { + return self.list_authenticated_repositories(page, per_page, &me_query); } let api_base = Self::api_base(); diff --git a/src/main.rs b/src/main.rs index dfa1d23..75c8635 100644 --- a/src/main.rs +++ b/src/main.rs @@ -114,6 +114,8 @@ enum OauthAction { #[arg(long, default_value_t = 900)] timeout_secs: u64, }, + /// Show OAuth login/authentication state + Status, } fn main() -> Result<()> { @@ -133,6 +135,7 @@ fn main() -> Result<()> { scope, timeout_secs, } => oauth::oauth_device_login_cli(client_id, scope, timeout_secs), + OauthAction::Status => oauth::oauth_status_cli(), }, }, None => app::run(), diff --git a/src/oauth.rs b/src/oauth.rs index f04a214..bdbed02 100644 --- a/src/oauth.rs +++ b/src/oauth.rs @@ -163,3 +163,27 @@ pub fn oauth_device_login_cli( println!("OAuth login completed. Token saved securely for user: {login}"); Ok(()) } + +pub fn oauth_status_cli() -> Result<()> { + let token = auth::load_token()?; + let oauth_session_present = oauth_session::load_session()?.is_some(); + + if token.is_none() { + println!("oauth_logged_in=false"); + println!("authenticated=false"); + println!("oauth_session_present={oauth_session_present}"); + return Ok(()); + } + + let client = GitHubClient::new(token.as_deref())?; + let user = client.fetch_authenticated_user()?; + let authenticated = user.is_some(); + + println!("oauth_logged_in={}", oauth_session_present && authenticated); + println!("authenticated={authenticated}"); + println!("oauth_session_present={oauth_session_present}"); + if let Some(login) = user { + println!("user={login}"); + } + Ok(()) +} diff --git a/tests/github_search_tests.rs b/tests/github_search_tests.rs index 24bfea3..b250e75 100644 --- a/tests/github_search_tests.rs +++ b/tests/github_search_tests.rs @@ -106,7 +106,7 @@ fn me_query_lists_and_filters_authenticated_repos() { with_api_base(&server.url(), || { let client = GitHubClient::new(Some("token")).expect("client"); let repos = client - .search_repositories_page("@me rust", 1, 30) + .search_repositories_page("@me language:rust private", 1, 30) .expect("search"); assert_eq!(repos.len(), 1); assert_eq!(repos[0].full_name, "me/alpha-rust");