diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..1bdeece
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,188 @@
+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
+ id-token: write
+ steps:
+ - name: Download build artifacts
+ uses: actions/download-artifact@v5
+ with:
+ 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: ${{ 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
+ 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/.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/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 ee5d4e9..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,19 +93,35 @@ 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
X
+
+
+
@@ -116,4 +134,4 @@ wget -qO- https://raw.githubusercontent.com/xscriptor/gitnapse/main/scripts/inst
-
\ 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/assets/gitnapse-icon.png b/assets/gitnapse-icon.png
new file mode 100644
index 0000000..9d9b849
Binary files /dev/null and b/assets/gitnapse-icon.png differ
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:
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.
+
+
+ Ensure the target commit is already merged into main through PR.
+ Create an annotated semantic version tag (example: v1.2.0).
+ Push the tag to origin to trigger the release workflow.
+ Wait for Actions jobs to complete and verify uploaded assets in the Release page.
+
+
+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..09ebff8
--- /dev/null
+++ b/docs/OAUTH_AUTHENTICATION.md
@@ -0,0 +1,144 @@
+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
+
+
+
+ Mode
+ How it Works
+ Best For
+ Storage
+
+
+
+
+ Environment token
+ Reads GITHUB_TOKEN at runtime
+ CI/CD and ephemeral sessions
+ Not persisted by app
+
+
+ Manual token
+ gitnapse auth set stores a token
+ Local personal workflow
+ User config directory, secure file permissions on UNIX
+
+
+ OAuth device flow
+ gitnapse auth oauth login ... uses browser authorization and exchanges token via octocrab
+ Safer interactive sign-in without pasting long tokens
+ Stored in OS keyring when available; secure file fallback otherwise
+
+
+
+
+OAuth Device Flow with octocrab
+
+ GitNapse requests a device code from GitHub using octocrab against https://github.com.
+ GitNapse tries to open verification_uri in your default browser automatically.
+ If auto-open is unavailable, the terminal shows a clickable OSC8 hyperlink (when terminal supports it) plus the plain URL.
+ User authorizes in browser with GitHub account.
+ GitNapse polls token endpoint using octocrab's recommended flow logic, respecting interval and slow_down responses.
+ The resulting OAuth access token is stored securely and then validated against /user.
+
+
+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 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
+
+ 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..dedf5ac
--- /dev/null
+++ b/docs/RELEASE_WORKFLOW.md
@@ -0,0 +1,80 @@
+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
+
+ Open GitHub -> Actions -> Release .
+ Click Run workflow .
+ Set release_tag to an existing tag (example: 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
+ COLLABORATIVE_SECTION.md .
+
+
+Official Notes
+
+ 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 cf6a081..786c318 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 filters: text terms and language:
+
gitnapse auth set
Store GitHub token interactively
@@ -64,6 +71,18 @@
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 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)
@@ -100,12 +119,29 @@
dPreview Download modal Save current previewed file to local path
DelPath modals Clear path input Works in clone/download path inputs
tGlobal Token modal Save token from inside the TUI
+ oGlobal OAuth quick check Does not start login; runs status check and tells you to use CLI login command
qGlobal Quit Exit application
Mouse left click Tree / Preview / Repos Focus & select Single click selects, double click opens (repo/file)
Mouse wheel Tree / Preview Scroll Scroll 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 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"
+
+
+ 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 +177,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..f7f72a5 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,80 @@ 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_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();
+
+ // 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 +735,9 @@ impl App {
self.focus = Focus::TokenInput;
self.input_buffer.clear();
}
+ KeyCode::Char('o') => {
+ self.run_oauth_quick_check();
+ }
KeyCode::Char('c') => {
if self.current_repo.is_some() {
self.clone_path_input = self.account.preferred_clone_dir.clone();
@@ -739,12 +837,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 +901,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 +922,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 +945,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 +954,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 +979,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 +991,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 +1061,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 +1096,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..b8bb956 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 State ",
+ " 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..1214b4a 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;
@@ -12,11 +13,134 @@ 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")
+ .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();
+ 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);
+ }
+ }
+ }
+
+ Some(MeQuery {
+ text_terms,
+ languages,
+ })
+ }
+
+ fn list_authenticated_repositories(
+ &self,
+ page: u32,
+ per_page: u8,
+ query: &MeQuery,
+ ) -> 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")?;
+
+ 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)
+ }
+
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 +152,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 +165,13 @@ impl GitHubClient {
let page = page.max(1);
let per_page = per_page.clamp(1, 100);
+ 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();
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 +187,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 +208,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 +235,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 +271,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 +298,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 +314,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 +329,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..75c8635 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,33 @@ 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,
+ },
+ /// Show OAuth login/authentication state
+ Status,
}
fn main() -> Result<()> {
+ let _ = dotenvy::dotenv();
let cli = Cli::parse();
match cli.command {
@@ -98,6 +129,14 @@ 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),
+ OauthAction::Status => oauth::oauth_status_cli(),
+ },
},
None => app::run(),
}
@@ -115,10 +154,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..bdbed02
--- /dev/null
+++ b/src/oauth.rs
@@ -0,0 +1,189 @@
+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(())
+}
+
+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/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..b250e75
--- /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 language:rust private", 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") };
+ }
+}