diff options
Diffstat
75 files changed, 404 insertions, 10210 deletions
diff --git a/.dockerignore b/.dockerignore index 351a45c..4f50e86 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,4 +5,3 @@ .github .gilti-state dist -target diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 --- a/.editorconfig +++ /dev/null @@ -1,26 +0,0 @@ -# SPDX-FileCopyrightText: 2026 Nikolay Govorov -# SPDX-License-Identifier: AGPL-3.0-or-later - -root = true - -[*] -charset = utf-8 -end_of_line = lf -insert_final_newline = true -trim_trailing_whitespace = true -indent_style = space -indent_size = 4 - -[*.md] -# double whitespace at end of line -# denotes a line break in Markdown -trim_trailing_whitespace = false - -[*.rs] -max_line_length = 100 - -[*.yml] -indent_size = 2 - -[{Makefile,*.mk}] -indent_style = tab diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 09866d3..921d397 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -17,92 +17,25 @@ concurrency: group: gilti-build-${{ github.ref }} cancel-in-progress: true -env: - CARGO_TERM_COLOR: always - RELEASE_TAG: nightly - jobs: - lint: + check: name: Static checks runs-on: ubuntu-latest steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - uses: jdx/mise-action@5228313ee0372e111a38da051671ca30fc5a96db # v3 with: version: 2026.7.5 experimental: true - install: false - - name: Install dependencies - run: mise bootstrap --locked --yes --update - - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 - - - name: formatting - run: cargo fmt --all --check - - - name: shellcheck - run: shellcheck scripts/*.sh tests/*.sh - - build: - name: Build (${{ matrix.arch }}) - runs-on: ${{ matrix.runner }} - needs: [lint] - strategy: - fail-fast: false - matrix: - include: - - runner: ubuntu-24.04 - arch: amd64 - - runner: ubuntu-24.04-arm - arch: arm64 - steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - - uses: jdx/mise-action@5228313ee0372e111a38da051671ca30fc5a96db # v3 - with: - version: 2026.7.5 - experimental: true - install: false - - name: Install dependencies - run: mise bootstrap --locked --yes --update - - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 - - - run: cargo clippy --workspace --locked --all-targets --all-features --release -- -D warnings - - run: cargo test --all-features --release --locked + install: true + cache: true + - run: shellcheck scripts/*.sh tests/*.sh - run: tests/chart.sh - - name: Build container artifacts - env: - RUST_IMAGE: docker.io/library/rust:1.97.1-alpine3.22@sha256:df4efa4e0cdfb5245fa06e3f431387b2bcc96782ce5681b7fb6b0297d745bc29 - run: | - docker run --rm \ - --volume "$PWD:/src" \ - --workdir /src \ - --env "HOST_UID=$(id -u)" \ - --env "HOST_GID=$(id -g)" \ - "$RUST_IMAGE" sh -euxc ' - rustc --version | grep -qx "rustc 1.97.1 (8bab26f4f 2026-07-14)" - cargo --version | grep -q "^cargo 1.97.1 " - apk add --no-cache \ - build-base=0.5-r3 \ - cmake \ - python3 \ - zlib-dev=1.3.2-r0 \ - zlib-static=1.3.2-r0 - scripts/build-artifacts.sh "${{ matrix.arch }}" - chown -R "$HOST_UID:$HOST_GID" .container - ' - - - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 - with: - name: binary-${{ matrix.arch }} - path: .container/binary-${{ matrix.arch }}/ - if-no-files-found: error - image: name: Image and smoke test runs-on: ubuntu-latest - needs: [build] + needs: [check] steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - uses: jdx/mise-action@5228313ee0372e111a38da051671ca30fc5a96db # v3 @@ -110,10 +43,6 @@ jobs: version: 2026.7.5 experimental: true install: false - - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 - with: - name: binary-amd64 - path: .container/binary-amd64 - name: Build local image run: | mise run container -- \ @@ -141,10 +70,6 @@ jobs: version: 2026.7.5 experimental: true install: false - - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 - with: - pattern: binary-* - path: .container - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 - uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.6.0 with: diff --git a/.gitignore b/.gitignore index bc1d763..7222147 100644 --- a/.gitignore +++ b/.gitignore @@ -2,9 +2,6 @@ # SPDX-License-Identifier: AGPL-3.0-or-later /dist -/target +/.gilti-state mise.local.toml mise.*.local.toml - -/.container -/.gilti-state diff --git a/CODEOWNERS b/CODEOWNERS index faf8e2e..7aeb094 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1,4 +1,4 @@ # SPDX-FileCopyrightText: 2026 Nikolay Govorov # SPDX-License-Identifier: AGPL-3.0-or-later -* @mrdimidium +* @mrdimidium diff --git a/Cargo.lock b/Cargo.lock deleted file mode 100644 --- a/Cargo.lock +++ /dev/null @@ -1,690 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "axum" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" -dependencies = [ - "axum-core", - "bytes", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "itoa", - "matchit", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "serde_core", - "sync_wrapper", - "tokio", - "tower", - "tower-layer", - "tower-service", -] - -[[package]] -name = "axum-core" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "sync_wrapper", - "tower-layer", - "tower-service", -] - -[[package]] -name = "bitflags" -version = "2.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "bytes" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" - -[[package]] -name = "cc" -version = "1.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" -dependencies = [ - "find-msvc-tools", - "jobserver", - "libc", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", -] - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys", -] - -[[package]] -name = "find-msvc-tools" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" - -[[package]] -name = "futures-channel" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" -dependencies = [ - "futures-core", -] - -[[package]] -name = "futures-core" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" - -[[package]] -name = "futures-task" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" - -[[package]] -name = "futures-util" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" -dependencies = [ - "futures-core", - "futures-task", - "pin-project-lite", - "slab", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "getrandom" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" -dependencies = [ - "cfg-if", - "libc", - "r-efi", -] - -[[package]] -name = "gilti" -version = "0.1.0" -dependencies = [ - "axum", - "git2", - "libc", - "maud", - "percent-encoding", - "serde", - "serde_json", - "sha2", - "tokio", - "tower", -] - -[[package]] -name = "gilti-ssh" -version = "0.1.0" -dependencies = [ - "libc", -] - -[[package]] -name = "git2" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddddbf932745a6be37109b6112d3ee09696106f848449069d3a57bba937ab82e" -dependencies = [ - "bitflags", - "libc", - "libgit2-sys", - "log", -] - -[[package]] -name = "http" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "hyper" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "http", - "http-body", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "smallvec", - "tokio", -] - -[[package]] -name = "hyper-util" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" -dependencies = [ - "bytes", - "http", - "http-body", - "hyper", - "pin-project-lite", - "tokio", - "tower-service", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "jobserver" -version = "0.1.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" -dependencies = [ - "getrandom", - "libc", -] - -[[package]] -name = "libc" -version = "0.2.189" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" - -[[package]] -name = "libgit2-sys" -version = "0.18.8+1.9.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f7c568b25d7489bc3fb2988ed69ab111d2944d2f5fec3d5c987fe545ea97b50" -dependencies = [ - "cc", - "libc", - "libz-sys", - "pkg-config", -] - -[[package]] -name = "libz-sys" -version = "1.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "log" -version = "0.4.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" - -[[package]] -name = "matchit" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" - -[[package]] -name = "maud" -version = "0.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8156733e27020ea5c684db5beac5d1d611e1272ab17901a49466294b84fc217e" -dependencies = [ - "itoa", - "maud_macros", -] - -[[package]] -name = "maud_macros" -version = "0.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7261b00f3952f617899bc012e3dbd56e4f0110a038175929fa5d18e5a19913ca" -dependencies = [ - "proc-macro2", - "proc-macro2-diagnostics", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "memchr" -version = "2.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" - -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "mio" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" -dependencies = [ - "libc", - "wasi", - "windows-sys", -] - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "pkg-config" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" - -[[package]] -name = "proc-macro2" -version = "1.0.107" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "proc-macro2-diagnostics" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", - "version_check", -] - -[[package]] -name = "quote" -version = "1.0.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "serde" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.4", -] - -[[package]] -name = "serde_json" -version = "1.0.151" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "shlex" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" - -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "smallvec" -version = "1.15.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" - -[[package]] -name = "socket2" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" -dependencies = [ - "libc", - "windows-sys", -] - -[[package]] -name = "syn" -version = "2.0.119" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "3.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" - -[[package]] -name = "tokio" -version = "1.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" -dependencies = [ - "bytes", - "libc", - "mio", - "pin-project-lite", - "signal-hook-registry", - "socket2", - "tokio-macros", - "windows-sys", -] - -[[package]] -name = "tokio-macros" -version = "2.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.4", -] - -[[package]] -name = "tower" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tokio", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "zmij" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml deleted file mode 100644 --- a/Cargo.toml +++ /dev/null @@ -1,34 +0,0 @@ -# SPDX-FileCopyrightText: 2026 Nikolay Govorov -# SPDX-License-Identifier: AGPL-3.0-or-later - -[workspace] -members = ["crates/*"] -resolver = "3" - -[workspace.package] -publish = false -edition = "2024" -rust-version = "1.97" -version = "0.1.0" -license = "AGPL-3.0-or-later" -authors = ["Nikolay Govorov <me@govorov.online>"] -homepage = "https://git.dimidiumlabs.io/gilti" -repository = "https://git.dimidiumlabs.io/gilti" - -[workspace.dependencies] -axum = { version = "0.8.4", default-features = false, features = ["http1", "tokio"] } -git2 = { version = "0.21.0", default-features = false, features = ["unstable-sha256", "vendored-libgit2"] } -libc = "0.2.172" -percent-encoding = "2.3.2" -tokio = { version = "1.44.2", features = ["fs", "io-util", "macros", "net", "process", "rt-multi-thread", "signal"] } -tower = { version = "0.5.2", features = ["util"] } -maud = "0.27.0" -serde = { version = "1.0.219", features = ["derive"] } -serde_json = "1.0.140" -sha2 = "0.10.9" - -[profile.release] -codegen-units = 1 -lto = "thin" -panic = "abort" -strip = true diff --git a/Dockerfile b/Dockerfile index a625eb1..829cdf0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,57 +7,47 @@ ARG ALPINE_DIGEST=sha256:4bcff63911fcb4448bd4fdacec207030997caf25e9bea4045fa6c8c FROM docker.io/library/alpine:${ALPINE_VERSION}@${ALPINE_DIGEST} -ARG TARGETARCH ARG VERSION=dev ARG REVISION=unknown LABEL org.opencontainers.image.title="Gilti" \ - org.opencontainers.image.description="Tiny Git server in a box" \ - org.opencontainers.image.source="https://git.dimidiumlabs.io/gilti" \ + org.opencontainers.image.description="Boxed tiny Git server powered by cgit and Gitolite" \ + org.opencontainers.image.source="https://github.com/dimidiumlabs/gilti" \ org.opencontainers.image.version="$VERSION" \ org.opencontainers.image.revision="$REVISION" \ org.opencontainers.image.licenses="AGPL-3.0-or-later" RUN apk add --no-cache \ - bzip2=1.0.8-r6 \ + cgit=1.2.3-r5 \ + fcgiwrap=1.1.0-r8 \ git=2.49.1-r0 \ - git-daemon=2.49.1-r0 \ - libgcc=14.2.0-r6 \ - lzip=1.25-r0 \ + gitolite=3.6.13-r1 \ + nginx=1.28.3-r7 \ openssh-keygen=10.0_p1-r10 \ openssh-server=10.0_p1-r10 \ + perl=5.40.4-r0 \ + spawn-fcgi=1.6.5-r4 \ su-exec=0.2-r3 \ - tini=0.19.0-r3 \ - xz=5.8.3-r0 \ - zstd=1.5.7-r0 && \ + tini=0.19.0-r3 && \ + deluser git && \ addgroup -S -g 10000 git && \ install -d -m 0755 -o root -g root /var/lib/gilti && \ adduser -S -D -u 10000 -G git -h /var/lib/gilti/git -s /bin/sh git && \ passwd -d git && \ - install -d -m 0750 -o git -g git \ - /var/lib/gilti/git /var/lib/gilti/git/repositories && \ + git config --system init.defaultBranch main && \ + install -d -m 0750 -o git -g git /var/lib/gilti/git /var/cache/cgit && \ install -d -m 0700 -o root -g root /var/lib/gilti/ssh && \ - install -d -m 0755 -o root -g root /run/gilti && \ - install -d -m 0750 -o root -g git /run/gilti/ssh && \ - install -d -m 0755 /etc/gilti && \ - rm -rf /var/cache/apk/* + install -d -m 0755 /run/gilti /run/gilti-bootstrap && \ + rm -rf /var/cache/apk/* /etc/nginx/http.d/default.conf -COPY --chown=root:root --chmod=0755 \ - .container/binary-${TARGETARCH}/gilti \ - .container/binary-${TARGETARCH}/gilti-ssh \ - /usr/local/bin/ -COPY --chown=root:root \ - .container/binary-${TARGETARCH}/gilti.css \ - .container/binary-${TARGETARCH}/gilti.js \ - .container/binary-${TARGETARCH}/gilti.png \ - .container/binary-${TARGETARCH}/favicon.ico \ - /usr/share/gilti/ +COPY --chown=root:root config/cgitrc /etc/cgitrc +COPY --chown=root:root config/nginx.conf /etc/nginx/nginx.conf COPY --chown=root:root config/sshd_config /etc/ssh/sshd_config -COPY --chown=root:root --chmod=0755 scripts/entrypoint.sh /usr/local/bin/gilti-entrypoint -COPY --chown=root:root LICENSE README.md LICENSES/ /usr/share/doc/gilti/ +COPY --chown=root:root scripts/entrypoint.sh /usr/local/bin/gilti-entrypoint +COPY --chown=root:root LICENSE README.md /usr/share/doc/gilti/ -RUN /usr/local/bin/gilti --check && \ - /usr/local/bin/gilti-ssh --check +RUN chmod 0755 /usr/local/bin/gilti-entrypoint && \ + nginx -t -e /dev/stderr -c /etc/nginx/nginx.conf EXPOSE 8080 2222 VOLUME ["/var/lib/gilti"] diff --git a/LICENSES/GPL-2.0-only.txt b/LICENSES/GPL-2.0-only.txt deleted file mode 100644 --- a/LICENSES/GPL-2.0-only.txt +++ /dev/null @@ -1,117 +0,0 @@ -GNU GENERAL PUBLIC LICENSE -Version 2, June 1991 - -Copyright (C) 1989, 1991 Free Software Foundation, Inc. -51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA - -Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. - -Preamble - -The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. This General Public License applies to most of the Free Software Foundation's software and to any other program whose authors commit to using it. (Some other Free Software Foundation software is covered by the GNU Lesser General Public License instead.) You can apply it to your programs, too. - -When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs; and that you know you can do these things. - -To protect your rights, we need to make restrictions that forbid anyone to deny you these rights or to ask you to surrender the rights. These restrictions translate to certain responsibilities for you if you distribute copies of the software, or if you modify it. - -For example, if you distribute copies of such a program, whether gratis or for a fee, you must give the recipients all the rights that you have. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. - -We protect your rights with two steps: (1) copyright the software, and (2) offer you this license which gives you legal permission to copy, distribute and/or modify the software. - -Also, for each author's protection and ours, we want to make certain that everyone understands that there is no warranty for this free software. If the software is modified by someone else and passed on, we want its recipients to know that what they have is not the original, so that any problems introduced by others will not reflect on the original authors' reputations. - -Finally, any free program is threatened constantly by software patents. We wish to avoid the danger that redistributors of a free program will individually obtain patent licenses, in effect making the program proprietary. To prevent this, we have made it clear that any patent must be licensed for everyone's free use or not licensed at all. - -The precise terms and conditions for copying, distribution and modification follow. - -TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION - -0. This License applies to any program or other work which contains a notice placed by the copyright holder saying it may be distributed under the terms of this General Public License. The "Program", below, refers to any such program or work, and a "work based on the Program" means either the Program or any derivative work under copyright law: that is to say, a work containing the Program or a portion of it, either verbatim or with modifications and/or translated into another language. (Hereinafter, translation is included without limitation in the term "modification".) Each licensee is addressed as "you". - -Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running the Program is not restricted, and the output from the Program is covered only if its contents constitute a work based on the Program (independent of having been made by running the Program). Whether that is true depends on what the Program does. - -1. You may copy and distribute verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and give any other recipients of the Program a copy of this License along with the Program. - -You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. - -2. You may modify your copy or copies of the Program or any portion of it, thus forming a work based on the Program, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: - - a) You must cause the modified files to carry prominent notices stating that you changed the files and the date of any change. - - b) You must cause any work that you distribute or publish, that in whole or in part contains or is derived from the Program or any part thereof, to be licensed as a whole at no charge to all third parties under the terms of this License. - - c) If the modified program normally reads commands interactively when run, you must cause it, when started running for such interactive use in the most ordinary way, to print or display an announcement including an appropriate copyright notice and a notice that there is no warranty (or else, saying that you provide a warranty) and that users may redistribute the program under these conditions, and telling the user how to view a copy of this License. (Exception: if the Program itself is interactive but does not normally print such an announcement, your work based on the Program is not required to print an announcement.) - -These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Program, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Program, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. - -Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Program. - -In addition, mere aggregation of another work not based on the Program with the Program (or with a work based on the Program) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. - -3. You may copy and distribute the Program (or a work based on it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you also do one of the following: - - a) Accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or, - - b) Accompany it with a written offer, valid for at least three years, to give any third party, for a charge no more than your cost of physically performing source distribution, a complete machine-readable copy of the corresponding source code, to be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or, - - c) Accompany it with the information you received as to the offer to distribute corresponding source code. (This alternative is allowed only for noncommercial distribution and only if you received the program in object code or executable form with such an offer, in accord with Subsection b above.) - -The source code for a work means the preferred form of the work for making modifications to it. For an executable work, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the executable. However, as a special exception, the source code distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. - -If distribution of executable or object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place counts as distribution of the source code, even though third parties are not compelled to copy the source along with the object code. - -4. You may not copy, modify, sublicense, or distribute the Program except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense or distribute the Program is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. - -5. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Program or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Program (or any work based on the Program), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Program or works based on it. - -6. Each time you redistribute the Program (or any work based on the Program), the recipient automatically receives a license from the original licensor to copy, distribute or modify the Program subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties to this License. - -7. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Program at all. For example, if a patent license would not permit royalty-free redistribution of the Program by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Program. - -If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply and the section as a whole is intended to apply in other circumstances. - -It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system, which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. - -This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. - -8. If the distribution and/or use of the Program is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Program under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. - -9. The Free Software Foundation may publish revised and/or new versions of the General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. - -Each version is given a distinguishing version number. If the Program specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of this License, you may choose any version ever published by the Free Software Foundation. - -10. If you wish to incorporate parts of the Program into other free programs whose distribution conditions are different, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. - -NO WARRANTY - -11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - -12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. - -END OF TERMS AND CONDITIONS - -How to Apply These Terms to Your New Programs - -If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. - -To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. - - one line to give the program's name and an idea of what it does. Copyright (C) yyyy name of author - - This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. - - This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with this program; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. Also add information on how to contact you by electronic and paper mail. - -If the program is interactive, make it output a short notice like this when it starts in an interactive mode: - - Gnomovision version 69, Copyright (C) year name of author Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, the commands you use may be called something other than `show w' and `show c'; they could even be mouse-clicks or menu items--whatever suits your program. - -You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the program, if necessary. Here is a sample; alter the names: - - Yoyodyne, Inc., hereby disclaims all copyright interest in the program `Gnomovision' (which makes passes at compilers) written by James Hacker. - -signature of Ty Coon, 1 April 1989 Ty Coon, President of Vice diff --git a/README.md b/README.md index 2db136f..6c3ff10 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,35 @@ # Gilti — a tiny Git server in a box -Gilti is a tiny web UI for Git that can function as either a read-only showcase -or a Git SSH server by integrating with the system's `sshd`. It is designed for -open-source projects and small teams that want to break free from major hosting -platforms but aren't ready to host complex services like Forgejo. +Gilti packages [cgit](https://git.zx2c4.com/cgit/), +[Gitolite](https://gitolite.com/gitolite/), nginx, OpenSSH, and fcgiwrap into +one OCI service with a Helm chart. It is intended for small authoritative Git +installations where SSH is the only Git transport and selected repositories are +published through a read-only web interface. -> Gilti is a young project. Mirror your repositories and create backups. +The first Gilti installation is `vcs.dimidiumlabs.io`, the authoritative Git +service for Dimidium Labs. + +> Gilti is in its infancy. Back up the persistent volume and test restoration +> before storing irreplaceable repositories. ## Security boundary -- Git fetch is available anonymously over smart HTTP; authenticated fetch and - push use SSH public-key authentication through `gilti-ssh`. -- Every configured key has read/write access to every repository and may create - a repository by pushing to its name for the first time. -- Repository browsing, archives, LFS downloads, and smart HTTP fetches are - anonymous and read-only; every repository is publicly visible. -- Password authentication, shells, forwarding, and tunnels are disabled; content - filters are not supported. Optional unauthenticated HTTP writes must be - enabled explicitly. +- Git fetch and push use SSH public-key authentication through Gitolite. +- cgit is anonymous and read-only. It does not inherit Gitolite ACLs. +- Only repositories exported to Gitolite's `gitweb` pseudo-user are listed by + cgit; unrestricted repository scanning is deliberately disabled. +- Smart HTTP, password authentication, shells, forwarding, tunnels, and cgit + filters are disabled. - Gilti is a single-replica service backed by one POSIX persistent volume. It is not an HA system. ## Container -Every start requires a static `authorized_keys` file: +A fresh state directory requires an administrator public key: ```console -docker pull ghcr.io/dimidiumlabs/gilti:nightly +docker build -t gilti:dev . ssh-keygen -q -t ed25519 -N '' -f ./admin -cp ./admin.pub ./authorized_keys docker run --rm \ --read-only --cap-drop ALL \ @@ -36,38 +37,31 @@ docker run --rm \ --cap-add SETGID --cap-add SETUID --cap-add SYS_CHROOT \ --tmpfs /run:rw,nosuid,nodev,noexec,size=32m \ --tmpfs /tmp:rw,nosuid,nodev,noexec,size=256m \ + --tmpfs /var/cache/cgit:rw,nosuid,nodev,noexec,size=1g \ -p 8080:8080 -p 2222:2222 \ -v gilti-state:/var/lib/gilti \ - -v "$PWD/authorized_keys:/etc/gilti/authorized_keys:ro" \ - ghcr.io/dimidiumlabs/gilti:nightly + -v "$PWD/admin.pub:/run/gilti-bootstrap/admin.pub:ro" \ + gilti:dev ``` -The HTTP configuration is read from the environment at startup: - -- `GILTI_ROOT_TITLE` (default: `Gilti`); -- `GILTI_ROOT_DESCRIPTION` (default: `A tiny Git server`); -- `GILTI_CLONE_PREFIX` (empty by default); -- `GILTI_HTTP_WRITE` (`0` by default; `1` enables unauthenticated HTTP pushes and LFS uploads). - -Gilti snapshots the authorized keys file at process startup; changing it takes -effect after a restart. Repositories and the persistent SSH host key live on -the state volume. +The bootstrap key is used only when the volume is fresh. Subsequent starts do +not require it. Partial Gitolite state fails closed instead of being +reinitialized. SSH host keys live on the same volume and remain stable across +pod replacement. ## Helm -Configure the allowed public keys in values: - -```yaml -ssh: - authorizedKeys: - - ssh-ed25519 AAAA... operator@example -``` +Create the bootstrap Secret before the first install: ```console +kubectl create namespace gilti +kubectl -n gilti create secret generic gilti-bootstrap \ + --from-file=admin.pub="$HOME/.ssh/id_ed25519.pub" + helm upgrade --install gilti ./charts/gilti \ - --namespace gilti --create-namespace \ - --values values.yaml \ - --set web.clonePrefix='ssh://git@git.dimidiumlabs.io/' + --namespace gilti \ + --set bootstrap.existingSecret=gilti-bootstrap \ + --set cgit.clonePrefix='ssh://git@vcs.dimidiumlabs.io/' ``` See [`charts/gilti/README.md`](charts/gilti/README.md) for persistence, routing, @@ -79,17 +73,12 @@ Provision tools and run static checks: ```console mise bootstrap -cargo fmt -- --check -cargo test --locked -cargo clippy --locked --all-targets -- -D warnings shellcheck scripts/*.sh tests/*.sh mise run chart -- --chart charts/gilti --lint-only ``` -The CI matrix builds architecture-specific Alpine artifacts before assembling -the image; the Dockerfile does not compile source code. Linux packages are -intentionally not produced. Gilti's release artifacts are a multi-platform OCI -image and an OCI Helm chart. +Linux packages are intentionally not produced. Gilti's release artifacts are a +multi-platform OCI image and an OCI Helm chart. ## Contributing diff --git a/REUSE.toml b/REUSE.toml index 901485f..17c1c78 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -7,7 +7,6 @@ version = 1 path = [ "charts/gilti/templates/*", "charts/gilti/values.schema.json", - "Cargo.lock", "mise.lock", ] SPDX-FileCopyrightText = "2026 Nikolay Govorov" @@ -27,11 +26,3 @@ path = [ ] SPDX-FileCopyrightText = "2026 Nikolay Govorov" SPDX-License-Identifier = "CC-BY-3.0" - -[[annotations]] -path = [ - "crates/gilti/assets/gilti.png", - "crates/gilti/assets/favicon.ico", -] -SPDX-FileCopyrightText = "cgit Development Team <cgit@lists.zx2c4.com>" -SPDX-License-Identifier = "GPL-2.0-only" diff --git a/charts/gilti/Chart.yaml b/charts/gilti/Chart.yaml index 9a4b918..2c050c8 100644 --- a/charts/gilti/Chart.yaml +++ b/charts/gilti/Chart.yaml @@ -7,5 +7,5 @@ version: 0.1.0 appVersion: 0.1.0 name: gilti -home: https://git.dimidiumlabs.io/gilti -description: Boxed tiny Git server powered by Rust and OpenSSH +home: https://github.com/dimidiumlabs/gilti +description: Boxed tiny Git server powered by cgit and Gitolite diff --git a/charts/gilti/README.md b/charts/gilti/README.md index 71f45ea..46eea9f 100644 --- a/charts/gilti/README.md +++ b/charts/gilti/README.md @@ -1,41 +1,41 @@ # Gilti Helm chart The chart installs one Gilti replica backed by one persistent volume. Upgrades -use `Recreate`: two pods must never write the repository filesystem -concurrently. The generated PVC carries `helm.sh/resource-policy: keep` by -default, so uninstalling the release does not delete authoritative Git data. +use `Recreate`: two pods must never write the Gitolite filesystem concurrently. +The generated PVC carries `helm.sh/resource-policy: keep` by default, so +uninstalling the release does not delete authoritative Git data. -## SSH access +## Bootstrap -Public keys are static chart configuration: +A fresh volume requires an existing Secret containing the administrator's SSH +public key as `admin.pub`: -```yaml -ssh: - authorizedKeys: - - ssh-ed25519 AAAA... operator@example - - ssh-ed25519 AAAA... automation@example +```console +kubectl create secret generic gilti-bootstrap --from-file=admin.pub +helm upgrade --install gilti . \ + --set bootstrap.existingSecret=gilti-bootstrap ``` -Every configured key has the same permissions: it may fetch, push, and create -any repository. There are no users, per-repository ACLs, or live key updates. -Gilti snapshots the configured keys when the pod starts, so a key change takes -effect after the Deployment rolls out the updated ConfigMap. +Additional `*.pub` entries in the same Secret are committed to `gitolite-admin` +as additional keys for the same `admin` identity. Bootstrap keys are ignored +after successful initialization and the Secret may then be removed from values. +A partially initialized volume is never overwritten automatically. -Shells, forwarding, tunnels, and arbitrary SSH commands are disabled. A push to -a missing repository initializes it as a bare repository: +## Publishing repositories -```console -git remote add origin ssh://git@git.example.test/example -git push -u origin main -``` +cgit does not implement Gitolite authorization. Gilti therefore reads Gitolite's +generated `projects.list`; a repository becomes public only when the `gitweb` +pseudo-user can read it: -## Repository visibility +```text +repo example + RW+ = alice + R = gitweb +``` -Repository browsing, smart HTTP cloning, archives, and LFS downloads are -anonymous and read-only. Consequently every repository available over SSH is -also publicly visible over HTTP. HTTP pushes and LFS uploads remain disabled by -default; setting `http.write: true` enables them without authentication and is -appropriate only behind a trusted access-control proxy. +After pushing this configuration to `gitolite-admin`, `example` appears in cgit. +Repositories not present in `projects.list` are unavailable even through a +direct cgit URL. Web cloning and snapshots are disabled in the default policy. ## Networking @@ -50,14 +50,11 @@ listener. Example for the first Dimidium Labs installation: ```yaml -ssh: - authorizedKeys: - - ssh-ed25519 AAAA... operator@example -web: - clonePrefix: ssh://git@git.dimidiumlabs.io/ +cgit: + clonePrefix: ssh://git@vcs.dimidiumlabs.io/ httpRoute: enabled: true - hostnames: [git.dimidiumlabs.io] + hostnames: [vcs.dimidiumlabs.io] parentRefs: - name: public namespace: network @@ -71,14 +68,14 @@ sshRoute: ``` TLS terminates at the shared Gateway; the chart does not create certificates. -Repositories and the SSH host key reside on the persistent volume. Public keys -are stored in the generated ConfigMap and must be supplied on every deployment. +SSH host keys, Gitolite configuration, repositories, generated authorization, +and audit logs reside on the persistent volume. Back up and restore that volume +as a unit. ## Security context -The Rust HTTP gateway and its native views run without privileges. OpenSSH -intentionally keeps a root master so it can enter the `git` account (UID/GID -10000). The chart drops all capabilities and restores only `CHOWN`, -`DAC_OVERRIDE`, `FOWNER`, `SETGID`, `SETUID`, and `SYS_CHROOT`. The root -filesystem is read-only; state, `/run`, and `/tmp` are explicit writable -mounts. +The nginx and cgit workers run without privileges. OpenSSH intentionally keeps a +root master so it can enter the `git` account (UID/GID 10000). The chart drops +all capabilities and restores only `CHOWN`, `DAC_OVERRIDE`, `FOWNER`, `SETGID`, +`SETUID`, and `SYS_CHROOT`. The root filesystem is read-only; state, cache, +`/run`, and `/tmp` are explicit writable mounts. diff --git a/charts/gilti/templates/configmap.yaml b/charts/gilti/templates/configmap.yaml index aa6ab25..a35361a 100644 --- a/charts/gilti/templates/configmap.yaml +++ b/charts/gilti/templates/configmap.yaml @@ -1,16 +1,11 @@ # SPDX-FileCopyrightText: 2026 Nikolay Govorov # SPDX-License-Identifier: AGPL-3.0-or-later -{{- range $name, $value := dict "web.rootTitle" .Values.web.rootTitle "web.rootDescription" .Values.web.rootDescription "web.clonePrefix" .Values.web.clonePrefix }} +{{- range $name, $value := dict "cgit.rootTitle" .Values.cgit.rootTitle "cgit.rootDescription" .Values.cgit.rootDescription "cgit.clonePrefix" .Values.cgit.clonePrefix }} {{- if regexMatch "[\\r\\n]" $value }} {{- fail (printf "%s must be a single line" $name) }} {{- end }} {{- end }} -{{- range $key := .Values.ssh.authorizedKeys }} -{{- if or (eq $key "") (regexMatch "[\\r\\n]" $key) }} -{{- fail "ssh.authorizedKeys entries must be non-empty single lines" }} -{{- end }} -{{- end }} apiVersion: v1 kind: ConfigMap metadata: @@ -18,7 +13,32 @@ metadata: labels: {{- include "gilti.labels" . | nindent 4 }} data: - authorized_keys: | - {{- range .Values.ssh.authorizedKeys }} - {{ . }} + cgitrc: | + root-title={{ .Values.cgit.rootTitle }} + root-desc={{ .Values.cgit.rootDescription }} + virtual-root=/ + css=/cgit.css + logo=/cgit.png + favicon=/favicon.ico + cache-root=/var/cache/cgit + cache-size=1000 + cache-dynamic-ttl=5 + cache-repo-ttl=5 + cache-root-ttl=5 + cache-scanrc-ttl=1 + enable-http-clone=0 + enable-index-owner=1 + enable-index-links=1 + enable-commit-graph=1 + enable-log-filecount=1 + enable-log-linecount=1 + enable-tree-linenumbers=1 + snapshots= + remove-suffix=1 + project-list=/var/lib/gilti/git/projects.list + readme=:README.md + readme=:README + {{- with .Values.cgit.clonePrefix }} + clone-prefix={{ . }} {{- end }} + scan-path=/var/lib/gilti/git/repositories diff --git a/charts/gilti/templates/deployment.yaml b/charts/gilti/templates/deployment.yaml index 08bd3c7..1193fca 100644 --- a/charts/gilti/templates/deployment.yaml +++ b/charts/gilti/templates/deployment.yaml @@ -47,15 +47,6 @@ spec: imagePullPolicy: {{ .Values.image.pullPolicy }} securityContext: {{- toYaml .Values.securityContext | nindent 12 }} - env: - - name: GILTI_ROOT_TITLE - value: {{ .Values.web.rootTitle | quote }} - - name: GILTI_ROOT_DESCRIPTION - value: {{ .Values.web.rootDescription | quote }} - - name: GILTI_CLONE_PREFIX - value: {{ .Values.web.clonePrefix | quote }} - - name: GILTI_HTTP_WRITE - value: {{ ternary "1" "0" .Values.http.write | quote }} ports: - name: http containerPort: 8080 @@ -65,18 +56,18 @@ spec: protocol: TCP startupProbe: httpGet: - path: /-/health + path: / port: http failureThreshold: 60 periodSeconds: 5 readinessProbe: httpGet: - path: /-/health + path: / port: http periodSeconds: 10 livenessProbe: httpGet: - path: /-/health + path: /healthz port: http periodSeconds: 20 resources: @@ -85,11 +76,16 @@ spec: - name: state mountPath: /var/lib/gilti - name: config - mountPath: /etc/gilti/authorized_keys - subPath: authorized_keys + mountPath: /etc/cgitrc + subPath: cgitrc + readOnly: true + - name: bootstrap + mountPath: /run/gilti-bootstrap readOnly: true - name: run - mountPath: /run/gilti + mountPath: /run + - name: cache + mountPath: /var/cache/cgit - name: tmp mountPath: /tmp volumes: @@ -103,9 +99,19 @@ spec: - name: config configMap: name: {{ include "gilti.fullname" . }} + - name: bootstrap + {{- if .Values.bootstrap.existingSecret }} + secret: + secretName: {{ .Values.bootstrap.existingSecret }} + {{- else }} + emptyDir: {} + {{- end }} - name: run emptyDir: sizeLimit: {{ .Values.runtime.runSizeLimit }} + - name: cache + emptyDir: + sizeLimit: {{ .Values.runtime.cacheSizeLimit }} - name: tmp emptyDir: sizeLimit: {{ .Values.runtime.tmpSizeLimit }} diff --git a/charts/gilti/values.schema.json b/charts/gilti/values.schema.json index 3fc6618..b7ace1b 100644 --- a/charts/gilti/values.schema.json +++ b/charts/gilti/values.schema.json @@ -13,16 +13,13 @@ "pullPolicy": { "enum": ["Always", "IfNotPresent", "Never"] } } }, - "ssh": { + "bootstrap": { "type": "object", "properties": { - "authorizedKeys": { - "type": "array", - "items": { "type": "string", "minLength": 1, "pattern": "^[^\\r\\n]+$" } - } + "existingSecret": { "type": "string" } } }, - "web": { + "cgit": { "type": "object", "properties": { "rootTitle": { "type": "string", "pattern": "^[^\\r\\n]*$" }, @@ -30,12 +27,6 @@ "clonePrefix": { "type": "string", "pattern": "^[^\\r\\n]*$" } } }, - "http": { - "type": "object", - "properties": { - "write": { "type": "boolean" } - } - }, "persistence": { "type": "object", "properties": { @@ -84,6 +75,7 @@ "type": "object", "properties": { "runSizeLimit": { "type": "string", "minLength": 1 }, + "cacheSizeLimit": { "type": "string", "minLength": 1 }, "tmpSizeLimit": { "type": "string", "minLength": 1 } } }, diff --git a/charts/gilti/values.yaml b/charts/gilti/values.yaml index 2924782..04518b5 100644 --- a/charts/gilti/values.yaml +++ b/charts/gilti/values.yaml @@ -13,19 +13,15 @@ imagePullSecrets: [] nameOverride: "" fullnameOverride: "" -ssh: - # Every listed key has read/write access to every repository. - authorizedKeys: [] +bootstrap: + # Secret key is needed only while initializing a fresh persistent volume. + existingSecret: "" -web: +cgit: rootTitle: Gilti rootDescription: A tiny Git server clonePrefix: "" -http: - # Allow unauthenticated Git receive-pack and LFS uploads. - write: false - persistence: enabled: true existingClaim: "" @@ -81,6 +77,7 @@ resources: runtime: runSizeLimit: 32Mi + cacheSizeLimit: 1Gi tmpSizeLimit: 256Mi nodeSelector: {} diff --git a/config/cgitrc b/config/cgitrc new file mode 100644 --- /dev/null +++ b/config/cgitrc @@ -0,0 +1,32 @@ +# SPDX-FileCopyrightText: 2026 Nikolay Govorov +# SPDX-License-Identifier: AGPL-3.0-or-later + +root-title=Gilti +root-desc=A tiny Git server +virtual-root=/ + +css=/cgit.css +logo=/cgit.png +favicon=/favicon.ico + +cache-root=/var/cache/cgit +cache-size=1000 +cache-dynamic-ttl=5 +cache-repo-ttl=5 +cache-root-ttl=5 +cache-scanrc-ttl=1 + +enable-http-clone=0 +enable-index-owner=1 +enable-index-links=1 +enable-commit-graph=1 +enable-log-filecount=1 +enable-log-linecount=1 +enable-tree-linenumbers=1 +snapshots= + +remove-suffix=1 +project-list=/var/lib/gilti/git/projects.list +readme=:README.md +readme=:README +scan-path=/var/lib/gilti/git/repositories diff --git a/config/nginx.conf b/config/nginx.conf new file mode 100644 --- /dev/null +++ b/config/nginx.conf @@ -0,0 +1,57 @@ +# SPDX-FileCopyrightText: 2026 Nikolay Govorov +# SPDX-License-Identifier: AGPL-3.0-or-later + +user git git; +worker_processes auto; +error_log /dev/stderr info; +pid /run/gilti/nginx.pid; + +events { + worker_connections 256; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + access_log /dev/stdout; + sendfile on; + server_tokens off; + client_max_body_size 1m; + client_body_timeout 10s; + keepalive_timeout 30s; + + server { + listen 8080; + server_name _; + + location = /healthz { + access_log off; + default_type text/plain; + return 200 "ok\n"; + } + + location = /cgit.css { + alias /usr/share/webapps/cgit/cgit.css; + } + + location = /cgit.png { + alias /usr/share/webapps/cgit/cgit.png; + } + + location = /favicon.ico { + alias /usr/share/webapps/cgit/favicon.ico; + } + + location / { + limit_except GET HEAD { deny all; } + + include /etc/nginx/fastcgi_params; + fastcgi_param SCRIPT_FILENAME /usr/share/webapps/cgit/cgit.cgi; + fastcgi_param PATH_INFO $uri; + fastcgi_param QUERY_STRING $args; + fastcgi_param HTTP_HOST $host; + fastcgi_pass unix:/run/gilti/fcgiwrap.sock; + fastcgi_read_timeout 30s; + } + } +} diff --git a/config/sshd_config b/config/sshd_config index 80f749b..0760fe7 100644 --- a/config/sshd_config +++ b/config/sshd_config @@ -5,7 +5,7 @@ Port 2222 ListenAddress 0.0.0.0 AddressFamily any HostKey /var/lib/gilti/ssh/ssh_host_ed25519_key -PidFile /run/gilti/ssh/sshd.pid +PidFile /run/gilti/sshd.pid AllowUsers git AuthenticationMethods publickey @@ -15,9 +15,7 @@ KbdInteractiveAuthentication no PermitEmptyPasswords no PermitRootLogin no StrictModes yes -AuthorizedKeysFile /run/gilti/ssh/authorized_keys -ForceCommand /usr/local/bin/gilti-ssh -AcceptEnv GIT_PROTOCOL +AuthorizedKeysFile .ssh/authorized_keys DisableForwarding yes AllowAgentForwarding no @@ -26,7 +24,6 @@ GatewayPorts no PermitTunnel no PermitTTY no PermitUserEnvironment no -PermitUserRC no X11Forwarding no LogLevel INFO diff --git a/crates/gilti-ssh/Cargo.toml b/crates/gilti-ssh/Cargo.toml deleted file mode 100644 --- a/crates/gilti-ssh/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -# SPDX-FileCopyrightText: 2026 Nikolay Govorov -# SPDX-License-Identifier: AGPL-3.0-or-later - -[package] -name = "gilti-ssh" -description = "Restricted SSH command for Gilti Git access" -version.workspace = true - -repository.workspace = true -homepage.workspace = true -authors.workspace = true -edition.workspace = true -license.workspace = true -publish.workspace = true -rust-version.workspace = true - -[dependencies] -libc.workspace = true diff --git a/crates/gilti-ssh/src/main.rs b/crates/gilti-ssh/src/main.rs deleted file mode 100644 --- a/crates/gilti-ssh/src/main.rs +++ /dev/null @@ -1,288 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -//! Restricted OpenSSH forced command for Git services. -//! -//! Every authenticated key is fully trusted. Repository directories, their -//! configuration, and server-side hooks are trusted administrator state. - -const GIT_HOME: &str = "/var/lib/gilti/git"; -const REPOSITORIES: &str = "/var/lib/gilti/git/repositories"; - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum GitService { - ReceivePack, - UploadArchive, - UploadPack, -} - -impl GitService { - fn parse(value: &str) -> Option<Self> { - match value { - "git-receive-pack" => Some(Self::ReceivePack), - "git-upload-archive" => Some(Self::UploadArchive), - "git-upload-pack" => Some(Self::UploadPack), - _ => None, - } - } - - fn program(self) -> &'static str { - match self { - Self::ReceivePack => "/usr/bin/git-receive-pack", - Self::UploadArchive => "/usr/bin/git-upload-archive", - Self::UploadPack => "/usr/bin/git-upload-pack", - } - } -} - -fn main() -> std::process::ExitCode { - // SAFETY: setting the process umask has no memory-safety implications. - unsafe { - libc::umask(0o077); - } - - match run() { - Ok(()) => std::process::ExitCode::SUCCESS, - Err(error) => { - eprintln!("gilti-ssh: {error}"); - std::process::ExitCode::FAILURE - } - } -} - -fn run() -> Result<(), String> { - if std::env::args_os().nth(1).as_deref() == Some(std::ffi::OsStr::new("--check")) { - return check_installation(); - } - - let remote = std::env::var("SSH_CONNECTION") - .ok() - .and_then(|connection| connection.split_whitespace().next().map(str::to_owned)) - .ok_or_else(|| "SSH_CONNECTION is missing".to_owned())?; - let command = match std::env::var("SSH_ORIGINAL_COMMAND") { - Ok(command) if !command.is_empty() => command, - _ => { - eprintln!("gilti-ssh: authenticated connection from {remote}"); - println!("Gilti: authenticated. Shell access is disabled."); - return Ok(()); - } - }; - if command.contains(['\n', '\r']) { - return Err("newlines are not allowed in SSH_ORIGINAL_COMMAND".to_owned()); - } - - let (service, repository) = parse_command(&command)?; - eprintln!("gilti-ssh: {service:?} {repository} from {remote}"); - let root = std::path::Path::new(REPOSITORIES); - let root_metadata = std::fs::symlink_metadata(root) - .map_err(|error| format!("cannot inspect {REPOSITORIES}: {error}"))?; - if !root_metadata.file_type().is_dir() { - return Err(format!("{REPOSITORIES} is not a real directory")); - } - let path = root.join(format!("{repository}.git")); - - if service == GitService::ReceivePack && !path.exists() { - create_repository(root, &path)?; - } - let metadata = std::fs::symlink_metadata(&path) - .map_err(|_| format!("repository '{repository}' does not exist"))?; - if !metadata.file_type().is_dir() { - return Err(format!("repository '{repository}' is not a real directory")); - } - verify_repository_path(root, &path)?; - - let mut command = git_command(service.program()); - if let Some(protocol) = std::env::var_os("GIT_PROTOCOL") { - command.env("GIT_PROTOCOL", protocol); - } - let error = std::os::unix::process::CommandExt::exec(command.arg(path)); - Err(format!("cannot execute {}: {error}", service.program())) -} - -fn check_installation() -> Result<(), String> { - let metadata = std::fs::symlink_metadata(REPOSITORIES) - .map_err(|error| format!("cannot inspect {REPOSITORIES}: {error}"))?; - if !metadata.file_type().is_dir() { - return Err(format!("{REPOSITORIES} is not a real directory")); - } - for service in [ - GitService::ReceivePack, - GitService::UploadArchive, - GitService::UploadPack, - ] { - let metadata = std::fs::metadata(service.program()) - .map_err(|error| format!("cannot inspect {}: {error}", service.program()))?; - if !metadata.is_file() - || std::os::unix::fs::PermissionsExt::mode(&metadata.permissions()) & 0o111 == 0 - { - return Err(format!("{} is not executable", service.program())); - } - } - Ok(()) -} - -fn parse_command(command: &str) -> Result<(GitService, String), String> { - let (program, argument) = command - .split_once(' ') - .ok_or_else(|| "only Git protocol commands are allowed".to_owned())?; - let service = GitService::parse(program) - .ok_or_else(|| "only Git protocol commands are allowed".to_owned())?; - let repository = parse_repository(argument)?; - Ok((service, repository)) -} - -fn parse_repository(argument: &str) -> Result<String, String> { - let argument = argument - .strip_prefix('\'') - .and_then(|value| value.strip_suffix('\'')); - let argument = argument.ok_or_else(|| "repository must be single-quoted".to_owned())?; - let argument = argument.strip_prefix('/').unwrap_or(argument); - let argument = argument.strip_suffix(".git").unwrap_or(argument); - - if argument.is_empty() - || argument.len() > 1024 - || !argument.as_bytes()[0].is_ascii_alphanumeric() - || argument.contains("..") - || argument.contains(".git/") - { - return Err("invalid repository name".to_owned()); - } - for component in argument.split('/') { - if component.is_empty() - || component == "." - || component == ".." - || !component - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || b"-_.".contains(&byte)) - { - return Err("invalid repository name".to_owned()); - } - } - Ok(argument.to_owned()) -} - -fn verify_repository_path(root: &std::path::Path, path: &std::path::Path) -> Result<(), String> { - let root = std::fs::canonicalize(root) - .map_err(|error| format!("cannot resolve {}: {error}", root.display()))?; - let path = std::fs::canonicalize(path) - .map_err(|error| format!("cannot resolve {}: {error}", path.display()))?; - if !path.starts_with(root) { - return Err("repository escapes the repository directory".to_owned()); - } - Ok(()) -} - -fn verify_creation_parent(root: &std::path::Path, path: &std::path::Path) -> Result<(), String> { - let mut ancestor = path - .parent() - .ok_or_else(|| "repository has no parent directory".to_owned())?; - loop { - match std::fs::symlink_metadata(ancestor) { - Ok(metadata) => { - if !metadata.is_dir() { - return Err(format!("{} is not a directory", ancestor.display())); - } - return verify_repository_path(root, ancestor); - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound && ancestor != root => { - ancestor = ancestor - .parent() - .ok_or_else(|| "repository escapes the repository directory".to_owned())?; - } - Err(error) => { - return Err(format!("cannot inspect {}: {error}", ancestor.display())); - } - } - } -} - -fn git_command(program: &str) -> std::process::Command { - let mut command = std::process::Command::new(program); - command - .env_clear() - .env("HOME", GIT_HOME) - .env("USER", "git") - .env("LOGNAME", "git") - .env("PATH", "/usr/bin:/bin") - .env("GIT_CONFIG_GLOBAL", "/dev/null") - .env("GIT_CONFIG_NOSYSTEM", "1") - .env("GIT_TERMINAL_PROMPT", "0"); - command -} - -fn create_repository(root: &std::path::Path, path: &std::path::Path) -> Result<(), String> { - verify_creation_parent(root, path)?; - let parent = path - .parent() - .ok_or_else(|| "repository has no parent directory".to_owned())?; - std::fs::create_dir_all(parent) - .map_err(|error| format!("cannot create {}: {error}", parent.display()))?; - verify_repository_path(root, parent)?; - - let status = git_command("/usr/bin/git") - .args(["init", "--quiet", "--bare", "--initial-branch=main", "--"]) - .arg(path) - .status() - .map_err(|error| format!("cannot initialize {}: {error}", path.display()))?; - if !status.success() { - return Err(format!( - "cannot initialize {}: git exited with {status}", - path.display() - )); - } - Ok(()) -} - -#[cfg(test)] -mod tests { - #[test] - fn parses_git_commands() { - assert_eq!( - super::parse_command("git-upload-pack 'group/project.git'").unwrap(), - (super::GitService::UploadPack, "group/project".to_owned()) - ); - assert_eq!( - super::parse_command("git-receive-pack '/project'").unwrap(), - (super::GitService::ReceivePack, "project".to_owned()) - ); - } - - #[test] - fn rejects_other_commands_and_unsafe_names() { - for command in [ - "sh -c true", - "git-upload-pack '../../etc/passwd'", - "git-upload-pack 'repo..backup'", - "git-upload-pack 'outer.git/inner'", - "git-upload-pack '.hidden'", - "git-upload-pack 'repo name'", - "git-upload-pack 'repo' trailing", - "git-upload-pack repo", - "git-upload-pack 'repo'\nwhoami", - ] { - assert!(super::parse_command(command).is_err(), "accepted {command}"); - } - } - - #[test] - fn refuses_to_create_through_a_symlinked_parent() { - static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); - - let base = std::env::temp_dir().join(format!( - "gilti-ssh-test-{}-{}", - std::process::id(), - NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) - )); - let root = base.join("repositories"); - let outside = base.join("outside"); - std::fs::create_dir_all(&root).unwrap(); - std::fs::create_dir_all(&outside).unwrap(); - std::os::unix::fs::symlink(&outside, root.join("group")).unwrap(); - - let repository = root.join("group/project.git"); - assert!(super::create_repository(&root, &repository).is_err()); - assert!(!outside.join("project.git").exists()); - - std::fs::remove_dir_all(base).unwrap(); - } -} diff --git a/crates/gilti/Cargo.toml b/crates/gilti/Cargo.toml deleted file mode 100644 --- a/crates/gilti/Cargo.toml +++ /dev/null @@ -1,27 +0,0 @@ -# SPDX-FileCopyrightText: 2026 Nikolay Govorov -# SPDX-License-Identifier: AGPL-3.0-or-later - -[package] -name = "gilti" -description = "Gilti daemon" -version.workspace = true - -authors.workspace = true -edition.workspace = true -license.workspace = true -publish.workspace = true -homepage.workspace = true -repository.workspace = true -rust-version.workspace = true - -[dependencies] -axum.workspace = true -git2.workspace = true -libc.workspace = true -percent-encoding.workspace = true -tokio.workspace = true -tower.workspace = true -maud.workspace = true -serde.workspace = true -serde_json.workspace = true -sha2.workspace = true diff --git a/crates/gilti/assets/favicon.ico b/crates/gilti/assets/favicon.ico deleted file mode 100644 --- a/crates/gilti/assets/favicon.ico +++ /dev/null Binary files differ diff --git a/crates/gilti/assets/gilti.css b/crates/gilti/assets/gilti.css deleted file mode 100644 --- a/crates/gilti/assets/gilti.css +++ /dev/null @@ -1,918 +0,0 @@ -/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com> - * SPDX-License-Identifier: GPL-2.0-only - */ - -div#gilti { - padding: 0em; - margin: 0em; - font-family: sans-serif; - font-size: 10pt; - color: #333; - background: white; - padding: 4px; -} - -div#gilti a { - color: blue; - text-decoration: none; -} - -div#gilti a:hover { - text-decoration: underline; -} - -div#gilti table { - border-collapse: collapse; -} - -div#gilti table#header { - width: 100%; - margin-bottom: 1em; -} - -div#gilti table#header td.logo { - width: 96px; - vertical-align: top; -} - -div#gilti table#header td.main { - font-size: 250%; - padding-left: 10px; - white-space: nowrap; -} - -div#gilti table#header td.main a { - color: #000; -} - -div#gilti table#header td.form { - text-align: right; - vertical-align: bottom; - padding-right: 1em; - padding-bottom: 2px; - white-space: nowrap; -} - -div#gilti table#header td.form form, -div#gilti table#header td.form input, -div#gilti table#header td.form select { - font-size: 90%; -} - -div#gilti table#header td.sub { - color: #777; - border-top: solid 1px #ccc; - padding-left: 10px; -} - -div#gilti table.tabs { - border-bottom: solid 3px #ccc; - border-collapse: collapse; - margin-top: 2em; - margin-bottom: 0px; - width: 100%; -} - -div#gilti table.tabs td { - padding: 0px 1em; - vertical-align: bottom; -} - -div#gilti table.tabs td a { - padding: 2px 0.75em; - color: #777; - font-size: 110%; -} - -div#gilti table.tabs td a.active { - color: #000; - background-color: #ccc; -} - -div#gilti table.tabs a[href^="http://"]:after, div#gilti table.tabs a[href^="https://"]:after { - content: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAAmJLR0QA/4ePzL8AAAAJcEhZcwAACxMAAAsTAQCanBgAAAAHdElNRQfgAhcJDQY+gm2TAAAAHWlUWHRDb21tZW50AAAAAABDcmVhdGVkIHdpdGggR0lNUGQuZQcAAABbSURBVAhbY2BABs4MU4CwhYHBh2Erww4wrGFQZHjI8B8IgUIscJWyDHcggltQhI4zGDCcRwhChPggHIggP1QoAVmQkSETrGoHsiAEsACtBYN0oDAMbgU6EBcAAL2eHUt4XUU4AAAAAElFTkSuQmCC); - opacity: 0.5; - margin: 0 0 0 5px; -} - -div#gilti table.tabs td.form { - text-align: right; -} - -div#gilti table.tabs td.form form { - padding-bottom: 2px; - font-size: 90%; - white-space: nowrap; -} - -div#gilti table.tabs td.form input, -div#gilti table.tabs td.form select { - font-size: 90%; -} - -div#gilti div.path { - margin: 0px; - padding: 5px 2em 2px 2em; - color: #000; - background-color: #eee; -} - -div#gilti div.content { - margin: 0px; - padding: 2em; - border-bottom: solid 3px #ccc; -} - - -div#gilti table.list { - width: 100%; - border: none; - border-collapse: collapse; -} - -div#gilti table.list tr { - background: white; -} - -div#gilti table.list tr.logheader { - background: #eee; -} - -div#gilti table.list tr:nth-child(even) { - background: #f7f7f7; -} - -div#gilti table.list tr:nth-child(odd) { - background: white; -} - -div#gilti table.list tr:hover { - background: #eee; -} - -div#gilti table.list tr.nohover { - background: white; -} - -div#gilti table.list tr.nohover:hover { - background: white; -} - -div#gilti table.list tr.nohover-highlight:hover:nth-child(even) { - background: #f7f7f7; -} - -div#gilti table.list tr.nohover-highlight:hover:nth-child(odd) { - background: white; -} - -div#gilti table.list th { - font-weight: bold; - /* color: #888; - border-top: dashed 1px #888; - border-bottom: dashed 1px #888; - */ - padding: 0.1em 0.5em 0.05em 0.5em; - vertical-align: baseline; -} - -div#gilti table.list td { - border: none; - padding: 0.1em 0.5em 0.1em 0.5em; -} - -div#gilti table.list td.commitgraph { - font-family: monospace; - white-space: pre; -} - -div#gilti table.list td.commitgraph .column1 { - color: #a00; -} - -div#gilti table.list td.commitgraph .column2 { - color: #0a0; -} - -div#gilti table.list td.commitgraph .column3 { - color: #aa0; -} - -div#gilti table.list td.commitgraph .column4 { - color: #00a; -} - -div#gilti table.list td.commitgraph .column5 { - color: #a0a; -} - -div#gilti table.list td.commitgraph .column6 { - color: #0aa; -} - -div#gilti table.list td.logsubject { - font-family: monospace; - font-weight: bold; -} - -div#gilti table.list td.logmsg { - font-family: monospace; - white-space: pre; - padding: 0 0.5em; -} - -div#gilti table.list td a { - color: black; -} - -div#gilti table.list td a.ls-dir { - font-weight: bold; - color: #00f; -} - -div#gilti table.list td a:hover { - color: #00f; -} - -div#gilti img { - border: none; -} - -div#gilti input#switch-btn { - margin: 2px 0px 0px 0px; -} - -div#gilti td#sidebar input.txt { - width: 100%; - margin: 2px 0px 0px 0px; -} - -div#gilti table#grid { - margin: 0px; -} - -div#gilti td#content { - vertical-align: top; - padding: 1em 2em 1em 1em; - border: none; -} - -div#gilti div#summary { - vertical-align: top; - margin-bottom: 1em; -} - -div#gilti div#summary pre { - margin: 0; - white-space: pre-wrap; -} - -div#gilti table#downloads { - float: right; - border-collapse: collapse; - border: solid 1px #777; - margin-left: 0.5em; - margin-bottom: 0.5em; -} - -div#gilti table#downloads th { - background-color: #ccc; -} - -div#gilti div#blob { - border: solid 1px black; -} - -div#gilti div.error { - color: red; - font-weight: bold; - margin: 1em 2em; -} - -div#gilti a.ls-blob, div#gilti a.ls-dir, div#gilti .ls-mod { - font-family: monospace; -} - -div#gilti td.ls-size { - text-align: right; - font-family: monospace; - width: 10em; -} - -div#gilti td.ls-mode { - font-family: monospace; - width: 10em; -} - -div#gilti table.blob { - margin-top: 0.5em; - border-top: solid 1px black; -} - -div#gilti table.blob td.hashes, -div#gilti table.blob td.lines { - margin: 0; padding: 0 0 0 0.5em; - vertical-align: top; - color: black; -} - -div#gilti table.blob td.linenumbers { - margin: 0; padding: 0 0.5em 0 0.5em; - vertical-align: top; - text-align: right; - border-right: 1px solid gray; -} - -div#gilti table.blob pre { - padding: 0; margin: 0; -} - -div#gilti table.blob td.linenumbers a, -div#gilti table.ssdiff td.lineno a { - color: gray; - text-align: right; - text-decoration: none; -} - -div#gilti table.blob td.linenumbers a:hover, -div#gilti table.ssdiff td.lineno a:hover { - color: black; -} - -div#gilti table.blob td.linenumbers a:target:before { - color: red; - content: "\2BA9"; -} - -div#gilti table.blame td.hashes, -div#gilti table.blame td.lines, -div#gilti table.blame td.linenumbers { - padding: 0; -} - -div#gilti table.blame td.hashes div.alt, -div#gilti table.blame td.lines div.alt { - padding: 0 0.5em 0 0.5em; -} - -div#gilti table.blame td.linenumbers div.alt { - padding: 0 0.5em 0 0; -} - -div#gilti table.blame div.alt:nth-child(even) { - background: #eee; -} - -div#gilti table.blame div.alt:nth-child(odd) { - background: white; -} - -div#gilti table.blame td.lines > div { - position: relative; -} - -div#gilti table.blame td.lines > div > pre { - padding: 0 0 0 0.5em; - position: absolute; - top: 0; -} - -div#gilti table.blame .oid { - font-size: 100%; -} - -div#gilti table.bin-blob { - margin-top: 0.5em; - border: solid 1px black; -} - -div#gilti table.bin-blob th { - font-family: monospace; - white-space: pre; - border: solid 1px #777; - padding: 0.5em 1em; -} - -div#gilti table.bin-blob td { - font-family: monospace; - white-space: pre; - border-left: solid 1px #777; - padding: 0em 1em; -} - -div#gilti table.nowrap td { - white-space: nowrap; -} - -div#gilti table.commit-info { - border-collapse: collapse; - margin-top: 1.5em; -} - -div#gilti div.gilti-panel { - float: right; - margin-top: 1.5em; -} - -div#gilti div.gilti-panel table { - border-collapse: collapse; - border: solid 1px #aaa; - background-color: #eee; -} - -div#gilti div.gilti-panel th { - text-align: center; -} - -div#gilti div.gilti-panel td { - padding: 0.25em 0.5em; -} - -div#gilti div.gilti-panel td.label { - padding-right: 0.5em; -} - -div#gilti div.gilti-panel td.ctrl { - padding-left: 0.5em; -} - -div#gilti table.commit-info th { - text-align: left; - font-weight: normal; - padding: 0.1em 1em 0.1em 0.1em; - vertical-align: top; -} - -div#gilti table.commit-info td { - font-weight: normal; - padding: 0.1em 1em 0.1em 0.1em; -} - -div#gilti div.commit-subject { - font-weight: bold; - font-size: 125%; - margin: 1.5em 0em 0.5em 0em; - padding: 0em; -} - -div#gilti div.commit-msg { - white-space: pre; - font-family: monospace; -} - -div#gilti div.notes-header { - font-weight: bold; - padding-top: 1.5em; -} - -div#gilti div.notes { - white-space: pre; - font-family: monospace; - border: solid 1px #ee9; - background-color: #ffd; - padding: 0.3em 2em 0.3em 1em; - float: left; -} - -div#gilti div.notes-footer { - clear: left; -} - -div#gilti div.diffstat-header { - font-weight: bold; - padding-top: 1.5em; -} - -div#gilti table.diffstat { - border-collapse: collapse; - border: solid 1px #aaa; - background-color: #eee; -} - -div#gilti table.diffstat th { - font-weight: normal; - text-align: left; - text-decoration: underline; - padding: 0.1em 1em 0.1em 0.1em; - font-size: 100%; -} - -div#gilti table.diffstat td { - padding: 0.2em 0.2em 0.1em 0.1em; - font-size: 100%; - border: none; -} - -div#gilti table.diffstat td.mode { - white-space: nowrap; -} - -div#gilti table.diffstat td span.modechange { - padding-left: 1em; - color: red; -} - -div#gilti table.diffstat td.add a { - color: green; -} - -div#gilti table.diffstat td.del a { - color: red; -} - -div#gilti table.diffstat td.upd a { - color: blue; -} - -div#gilti table.diffstat td.graph { - width: 500px; - vertical-align: middle; -} - -div#gilti table.diffstat td.graph table { - border: none; -} - -div#gilti table.diffstat td.graph td { - padding: 0px; - border: 0px; - height: 7pt; -} - -div#gilti table.diffstat td.graph td.add { - background-color: #5c5; -} - -div#gilti table.diffstat td.graph td.rem { - background-color: #c55; -} - -div#gilti div.diffstat-summary { - color: #888; - padding-top: 0.5em; -} - -div#gilti table.diff { - width: 100%; -} - -div#gilti table.diff td { - font-family: monospace; - white-space: pre; -} - -div#gilti table.diff td div.head { - font-weight: bold; - margin-top: 1em; - color: black; -} - -div#gilti table.diff td div.hunk { - color: #009; -} - -div#gilti table.diff td div.add { - color: green; -} - -div#gilti table.diff td div.del { - color: red; -} - -div#gilti .oid { - font-family: monospace; - font-size: 90%; -} - -div#gilti .left { - text-align: left; -} - -div#gilti .right { - text-align: right; -} - -div#gilti table.list td.reposection { - font-style: italic; - color: #888; -} - -div#gilti a.button { - font-size: 80%; - padding: 0em 0.5em; -} - -div#gilti a.primary { - font-size: 100%; -} - -div#gilti a.secondary { - font-size: 90%; -} - -div#gilti td.toplevel-repo { - -} - -div#gilti table.list td.sublevel-repo { - padding-left: 1.5em; -} - -div#gilti ul.pager { - list-style-type: none; - text-align: center; - margin: 1em 0em 0em 0em; - padding: 0; -} - -div#gilti ul.pager li { - display: inline-block; - margin: 0.25em 0.5em; -} - -div#gilti ul.pager a { - color: #777; -} - -div#gilti ul.pager .current { - font-weight: bold; -} - -div#gilti span.age-mins { - font-weight: bold; - color: #080; -} - -div#gilti span.age-hours { - color: #080; -} - -div#gilti span.age-days { - color: #040; -} - -div#gilti span.age-weeks { - color: #444; -} - -div#gilti span.age-months { - color: #888; -} - -div#gilti span.age-years { - color: #bbb; -} - -div#gilti span.insertions { - color: #080; -} - -div#gilti span.deletions { - color: #800; -} - -div#gilti div.footer { - margin-top: 0.5em; - text-align: center; - font-size: 80%; - color: #ccc; -} - -div#gilti div.footer a { - color: #ccc; - text-decoration: none; -} - -div#gilti div.footer a:hover { - text-decoration: underline; -} - -div#gilti a.branch-deco { - color: #000; - margin: 0px 0.5em; - padding: 0px 0.25em; - background-color: #88ff88; - border: solid 1px #007700; - border-radius: 2px; -} - -div#gilti a.tag-deco { - color: #000; - margin: 0px 0.5em; - padding: 0px 0.25em; - background-color: #ffff88; - border: solid 1px #777700; - border-radius: 2px; -} - -div#gilti a.tag-annotated-deco { - color: #000; - margin: 0px 0.5em; - padding: 0px 0.25em; - background-color: #ffcc88; - border: solid 1px #777700; - border-radius: 2px; -} - -div#gilti a.remote-deco { - color: #000; - margin: 0px 0.5em; - padding: 0px 0.25em; - background-color: #ccccff; - border: solid 1px #000077; - border-radius: 2px; -} - -div#gilti a.deco { - color: #000; - margin: 0px 0.5em; - padding: 0px 0.25em; - background-color: #ff8888; - border: solid 1px #770000; - border-radius: 2px; -} - -div#gilti div.commit-subject a.branch-deco, -div#gilti div.commit-subject a.tag-deco, -div#gilti div.commit-subject a.tag-annotated-deco, -div#gilti div.commit-subject a.remote-deco, -div#gilti div.commit-subject a.deco { - margin-left: 1em; - font-size: 75%; -} - -div#gilti table.stats { - border: solid 1px black; - border-collapse: collapse; -} - -div#gilti table.stats th { - text-align: left; - padding: 1px 0.5em; - background-color: #eee; - border: solid 1px black; -} - -div#gilti table.stats td { - text-align: right; - padding: 1px 0.5em; - border: solid 1px black; -} - -div#gilti table.stats td.total { - font-weight: bold; - text-align: left; -} - -div#gilti table.stats td.sum { - color: #c00; - font-weight: bold; -/* background-color: #eee; */ -} - -div#gilti table.stats td.left { - text-align: left; -} - -div#gilti table.vgraph { - border-collapse: separate; - border: solid 1px black; - height: 200px; -} - -div#gilti table.vgraph th { - background-color: #eee; - font-weight: bold; - border: solid 1px white; - padding: 1px 0.5em; -} - -div#gilti table.vgraph td { - vertical-align: bottom; - padding: 0px 10px; -} - -div#gilti table.vgraph div.bar { - background-color: #eee; -} - -div#gilti table.hgraph { - border: solid 1px black; - width: 800px; -} - -div#gilti table.hgraph th { - background-color: #eee; - font-weight: bold; - border: solid 1px black; - padding: 1px 0.5em; -} - -div#gilti table.hgraph td { - vertical-align: middle; - padding: 2px 2px; -} - -div#gilti table.hgraph div.bar { - background-color: #eee; - height: 1em; -} - -div#gilti table.ssdiff { - width: 100%; -} - -div#gilti table.ssdiff td { - font-size: 75%; - font-family: monospace; - white-space: pre; - padding: 1px 4px 1px 4px; - border-left: solid 1px #aaa; - border-right: solid 1px #aaa; -} - -div#gilti table.ssdiff td.add { - color: black; - background: #cfc; - min-width: 50%; -} - -div#gilti table.ssdiff td.add_dark { - color: black; - background: #aca; - min-width: 50%; -} - -div#gilti table.ssdiff span.add { - background: #cfc; - font-weight: bold; -} - -div#gilti table.ssdiff td.del { - color: black; - background: #fcc; - min-width: 50%; -} - -div#gilti table.ssdiff td.del_dark { - color: black; - background: #caa; - min-width: 50%; -} - -div#gilti table.ssdiff span.del { - background: #fcc; - font-weight: bold; -} - -div#gilti table.ssdiff td.changed { - color: black; - background: #ffc; - min-width: 50%; -} - -div#gilti table.ssdiff td.changed_dark { - color: black; - background: #cca; - min-width: 50%; -} - -div#gilti table.ssdiff td.lineno { - color: black; - background: #eee; - text-align: right; - width: 3em; - min-width: 3em; -} - -div#gilti table.ssdiff td.hunk { - color: black; - background: #ccf; - border-top: solid 1px #aaa; - border-bottom: solid 1px #aaa; -} - -div#gilti table.ssdiff td.head { - border-top: solid 1px #aaa; - border-bottom: solid 1px #aaa; -} - -div#gilti table.ssdiff td.head div.head { - font-weight: bold; - color: black; -} - -div#gilti table.ssdiff td.foot { - border-top: solid 1px #aaa; - border-left: none; - border-right: none; - border-bottom: none; -} - -div#gilti table.ssdiff td.space { - border: none; -} - -div#gilti table.ssdiff td.space div { - min-height: 3em; -} diff --git a/crates/gilti/assets/gilti.js b/crates/gilti/assets/gilti.js deleted file mode 100644 --- a/crates/gilti/assets/gilti.js +++ /dev/null @@ -1,72 +0,0 @@ -/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com> - * SPDX-License-Identifier: GPL-2.0-only - */ - -/* gilti.js: JavaScript helpers for Gilti - * - * Copyright (C) 2006-2018 cgit Development Team <cgit@lists.zx2c4.com> - * - * Licensed under GNU General Public License v2 - * (see LICENSES/GPL-2.0-only.txt for full license text) - */ - -(function () { - -/* Relative-age rendering shared by Gilti browser views. */ - -var age_classes = [ "age-mins", "age-hours", "age-days", "age-weeks", "age-months", "age-years" ]; -var age_suffix = [ "min.", "hours", "days", "weeks", "months", "years", "years" ]; -var age_next = [ 60, 3600, 24 * 3600, 7 * 24 * 3600, 30 * 24 * 3600, 365 * 24 * 3600, 365 * 24 * 3600 ]; -var age_limit = [ 7200, 24 * 7200, 7 * 24 * 7200, 30 * 24 * 7200, 365 * 25 * 7200, 365 * 25 * 7200 ]; -var update_next = [ 10, 5 * 60, 1800, 24 * 3600, 24 * 3600, 24 * 3600, 24 * 3600 ]; - -function render_age(e, age) { - var t, n; - - for (n = 0; n < age_classes.length; n++) - if (age < age_limit[n]) - break; - - t = Math.round(age / age_next[n]) + " " + age_suffix[n]; - - if (e.textContent != t) { - e.textContent = t; - if (n == age_classes.length) - n--; - if (e.className != age_classes[n]) - e.className = age_classes[n]; - } -} - -function aging() { - var n, next = 24 * 3600, - now_ut = Math.round((new Date().getTime() / 1000)); - - for (n = 0; n < age_classes.length; n++) { - var m, elems = document.getElementsByClassName(age_classes[n]); - - if (elems.length && update_next[n] < next) - next = update_next[n]; - - for (m = 0; m < elems.length; m++) { - var age = now_ut - elems[m].getAttribute("data-ut"); - - render_age(elems[m], age); - } - } - - /* - * We only need to come back when the age might have changed. - * Eg, if everything is counted in hours already, once per - * 5 minutes is accurate enough. - */ - - window.setTimeout(aging, next * 1000); -} - -document.addEventListener("DOMContentLoaded", function() { - /* we can do the aging on DOM content load since no layout dependency */ - aging(); -}, false); - -})(); diff --git a/crates/gilti/assets/gilti.png b/crates/gilti/assets/gilti.png deleted file mode 100644 --- a/crates/gilti/assets/gilti.png +++ /dev/null Binary files differ diff --git a/crates/gilti/src/cgi.rs b/crates/gilti/src/cgi.rs deleted file mode 100644 --- a/crates/gilti/src/cgi.rs +++ /dev/null @@ -1,237 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -//! HTTP-to-CGI Tower service. - -#[derive(Clone, Default)] -pub struct Environment(pub Vec<(std::ffi::OsString, std::ffi::OsString)>); - -#[derive(Clone, Copy)] -pub struct RemoteAddr(pub std::net::SocketAddr); - -#[derive(Clone)] -pub struct Cgi { - program: std::path::PathBuf, - current_dir: std::path::PathBuf, - environment: Vec<(std::ffi::OsString, std::ffi::OsString)>, - server_addr: std::net::SocketAddr, -} - -impl Cgi { - pub fn new( - program: impl Into<std::path::PathBuf>, - current_dir: impl Into<std::path::PathBuf>, - server_addr: std::net::SocketAddr, - ) -> Self { - Self { - program: program.into(), - current_dir: current_dir.into(), - environment: Vec::new(), - server_addr, - } - } - - pub fn env( - mut self, - name: impl Into<std::ffi::OsString>, - value: impl Into<std::ffi::OsString>, - ) -> Self { - self.environment.push((name.into(), value.into())); - self - } - - async fn execute( - self, - request: axum::http::Request<axum::body::Body>, - ) -> std::io::Result<axum::http::Response<axum::body::Body>> { - let remote = request.extensions().get::<RemoteAddr>().map_or_else( - || std::net::SocketAddr::from(([0, 0, 0, 0], 0)), - |value| value.0, - ); - let (parts, body) = request.into_parts(); - let body = axum::body::to_bytes(body, 1024 * 1024 * 1024) - .await - .map_err(std::io::Error::other)?; - let path = percent_encoding::percent_decode_str(parts.uri.path()).collect::<Vec<_>>(); - if path.contains(&0) { - return Err(invalid("request path contains a null byte")); - } - let path = <std::ffi::OsString as std::os::unix::ffi::OsStringExt>::from_vec(path); - let host = parts - .headers - .get(axum::http::header::HOST) - .and_then(|value| value.to_str().ok()) - .unwrap_or("localhost"); - let content_type = parts - .headers - .get(axum::http::header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .unwrap_or(""); - let request_uri = parts - .uri - .path_and_query() - .map_or(parts.uri.path(), |value| value.as_str()); - - let mut command = tokio::process::Command::new(&self.program); - command - .env_clear() - .envs(self.environment) - .env("GATEWAY_INTERFACE", "CGI/1.1") - .env("SERVER_PROTOCOL", "HTTP/1.1") - .env("REQUEST_METHOD", parts.method.as_str()) - .env("SCRIPT_FILENAME", &self.program) - .env("SCRIPT_NAME", "") - .env("PATH_INFO", path) - .env("QUERY_STRING", parts.uri.query().unwrap_or("")) - .env("REQUEST_URI", request_uri) - .env("REMOTE_ADDR", remote.ip().to_string()) - .env("REMOTE_PORT", remote.port().to_string()) - .env("SERVER_ADDR", self.server_addr.ip().to_string()) - .env("SERVER_PORT", self.server_addr.port().to_string()) - .env("SERVER_NAME", host) - .env("CONTENT_LENGTH", body.len().to_string()) - .env("CONTENT_TYPE", content_type) - .env("HTTP_HOST", host) - .current_dir(self.current_dir) - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::inherit()); - - for (name, value) in &parts.headers { - if name == axum::http::header::HOST - || name == axum::http::header::CONTENT_LENGTH - || name == axum::http::header::CONTENT_TYPE - || name == "proxy" - { - continue; - } - if let Ok(value) = value.to_str() { - command.env( - format!( - "HTTP_{}", - name.as_str().to_ascii_uppercase().replace('-', "_") - ), - value, - ); - } - } - - if let Some(environment) = parts.extensions.get::<Environment>() { - command.envs(environment.0.iter().cloned()); - } - - let mut child = command.spawn()?; - let mut stdin = child - .stdin - .take() - .ok_or_else(|| std::io::Error::other("CGI stdin is unavailable"))?; - let write = async move { - tokio::io::AsyncWriteExt::write_all(&mut stdin, &body).await?; - tokio::io::AsyncWriteExt::shutdown(&mut stdin).await - }; - let (write, output) = tokio::join!(write, child.wait_with_output()); - write?; - let output = output?; - if !output.status.success() { - return Err(std::io::Error::other(format!( - "CGI program exited with {}", - output.status - ))); - } - - let (status, headers, body) = parse_response(&output.stdout)?; - let mut response = axum::http::Response::builder() - .status(status) - .body(axum::body::Body::from(body.to_vec())) - .map_err(std::io::Error::other)?; - *response.headers_mut() = headers; - Ok(response) - } -} - -impl tower::Service<axum::http::Request<axum::body::Body>> for Cgi { - type Response = axum::http::Response<axum::body::Body>; - type Error = std::io::Error; - type Future = std::pin::Pin< - Box<dyn std::future::Future<Output = std::io::Result<Self::Response>> + Send>, - >; - - fn poll_ready( - &mut self, - _context: &mut std::task::Context<'_>, - ) -> std::task::Poll<std::io::Result<()>> { - std::task::Poll::Ready(Ok(())) - } - - fn call(&mut self, request: axum::http::Request<axum::body::Body>) -> Self::Future { - let service = self.clone(); - Box::pin(async move { service.execute(request).await }) - } -} - -fn parse_response( - output: &[u8], -) -> std::io::Result<(axum::http::StatusCode, axum::http::HeaderMap, &[u8])> { - let (head, body) = if let Some(offset) = output.windows(4).position(|part| part == b"\r\n\r\n") - { - (&output[..offset], &output[offset + 4..]) - } else if let Some(offset) = output.windows(2).position(|part| part == b"\n\n") { - (&output[..offset], &output[offset + 2..]) - } else { - return Err(invalid("CGI response has no header separator")); - }; - - let mut status = axum::http::StatusCode::OK; - let mut headers = axum::http::HeaderMap::new(); - for line in head.split(|byte| *byte == b'\n') { - let line = line.strip_suffix(b"\r").unwrap_or(line); - let colon = line - .iter() - .position(|byte| *byte == b':') - .ok_or_else(|| invalid("malformed CGI header"))?; - let (name, mut value) = (&line[..colon], &line[colon + 1..]); - while value.first().is_some_and(u8::is_ascii_whitespace) { - value = &value[1..]; - } - if name.eq_ignore_ascii_case(b"Status") { - status = axum::http::StatusCode::from_bytes( - value.split(|byte| *byte == b' ').next().unwrap_or(value), - ) - .map_err(|_| invalid("invalid CGI status"))?; - } else { - headers.append( - axum::http::HeaderName::from_bytes(name) - .map_err(|_| invalid("invalid CGI header"))?, - axum::http::HeaderValue::from_bytes(value) - .map_err(|_| invalid("invalid CGI header"))?, - ); - } - } - if status == axum::http::StatusCode::OK && headers.contains_key("location") { - status = axum::http::StatusCode::FOUND; - } - Ok((status, headers, body)) -} - -fn invalid(message: &'static str) -> std::io::Error { - std::io::Error::new(std::io::ErrorKind::InvalidData, message) -} - -#[cfg(test)] -mod tests { - #[test] - fn parses_cgi_response() { - let (status, headers, body) = - super::parse_response(b"Status: 201 Created\r\nContent-Type: text/plain\r\n\r\nhello") - .unwrap(); - assert_eq!(status, axum::http::StatusCode::CREATED); - assert_eq!(headers[axum::http::header::CONTENT_TYPE], "text/plain"); - assert_eq!(body, b"hello"); - } - - #[test] - fn is_a_tower_service() { - fn assert_service<T: tower::Service<axum::http::Request<axum::body::Body>>>() {} - assert_service::<super::Cgi>(); - } -} diff --git a/crates/gilti/src/lfs.rs b/crates/gilti/src/lfs.rs deleted file mode 100644 --- a/crates/gilti/src/lfs.rs +++ /dev/null @@ -1,389 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -const MAX_OBJECT_SIZE: usize = 1024 * 1024 * 1024; - -#[derive(serde::Deserialize)] -struct BatchRequest { - operation: String, - objects: Vec<ObjectRequest>, -} - -#[derive(serde::Deserialize)] -struct ObjectRequest { - oid: String, - size: u64, -} - -#[derive(serde::Deserialize)] -struct VerifyRequest { - oid: String, - size: u64, -} - -#[derive(serde::Serialize)] -struct BatchResponse { - transfer: &'static str, - objects: Vec<ObjectResponse>, -} - -#[derive(serde::Serialize)] -struct ObjectResponse { - oid: String, - size: u64, - #[serde(skip_serializing_if = "Option::is_none")] - actions: Option<Actions>, - #[serde(skip_serializing_if = "Option::is_none")] - error: Option<ObjectError>, -} - -#[derive(serde::Serialize)] -struct Actions { - #[serde(skip_serializing_if = "Option::is_none")] - download: Option<Action>, - #[serde(skip_serializing_if = "Option::is_none")] - upload: Option<Action>, - #[serde(skip_serializing_if = "Option::is_none")] - verify: Option<Action>, -} - -#[derive(serde::Serialize)] -struct Action { - href: String, -} - -#[derive(serde::Serialize)] -struct ObjectError { - code: u16, - message: &'static str, -} - -pub async fn serve( - repositories: &std::path::Path, - repo: &str, - path: &str, - write_enabled: bool, - request: axum::extract::Request, -) -> axum::response::Response { - let Some(repository) = repository(repositories, repo) else { - return plain(axum::http::StatusCode::NOT_FOUND, "repository not found\n"); - }; - let objects = repository.join("lfs/objects"); - let method = request.method().clone(); - - if path == "objects/batch" && method == axum::http::Method::POST { - return batch(objects, write_enabled, request).await; - } - if method == axum::http::Method::POST - && let Some(oid) = path - .strip_prefix("objects/") - .and_then(|value| value.strip_suffix("/verify")) - { - return verify(&objects, oid, request).await; - } - if let Some(oid) = path.strip_prefix("objects/") { - if !valid_oid(oid) { - return plain(axum::http::StatusCode::BAD_REQUEST, "invalid object id\n"); - } - return match method { - axum::http::Method::GET | axum::http::Method::HEAD => { - download(&objects, oid, method == axum::http::Method::HEAD).await - } - axum::http::Method::PUT if write_enabled => upload(&objects, oid, request).await, - axum::http::Method::PUT => plain( - axum::http::StatusCode::FORBIDDEN, - "LFS uploads are disabled\n", - ), - _ => plain( - axum::http::StatusCode::METHOD_NOT_ALLOWED, - "method not allowed\n", - ), - }; - } - plain(axum::http::StatusCode::NOT_FOUND, "not found\n") -} - -async fn batch( - objects: std::path::PathBuf, - write_enabled: bool, - request: axum::extract::Request, -) -> axum::response::Response { - let base = request - .uri() - .path() - .strip_suffix("/objects/batch") - .unwrap_or(request.uri().path()); - let scheme = request - .headers() - .get("x-forwarded-proto") - .and_then(|value| value.to_str().ok()) - .filter(|value| matches!(*value, "http" | "https")) - .unwrap_or("http"); - let host = request - .headers() - .get(axum::http::header::HOST) - .and_then(|value| value.to_str().ok()) - .unwrap_or("localhost"); - let prefix = format!("{scheme}://{host}{base}/objects"); - let body = match axum::body::to_bytes(request.into_body(), 1024 * 1024).await { - Ok(body) => body, - Err(_) => { - return plain( - axum::http::StatusCode::PAYLOAD_TOO_LARGE, - "request too large\n", - ); - } - }; - let request: BatchRequest = match serde_json::from_slice(&body) { - Ok(request) => request, - Err(_) => return plain(axum::http::StatusCode::BAD_REQUEST, "invalid LFS request\n"), - }; - if !matches!(request.operation.as_str(), "download" | "upload") { - return plain( - axum::http::StatusCode::BAD_REQUEST, - "invalid LFS operation\n", - ); - } - - let objects = request - .objects - .into_iter() - .map(|object| { - if !valid_oid(&object.oid) { - return error(object, 422, "invalid object id"); - } - let stored = object_path(&objects, &object.oid); - let present = std::fs::metadata(&stored) - .is_ok_and(|metadata| metadata.is_file() && metadata.len() == object.size); - match request.operation.as_str() { - "download" if present => response( - object, - Actions { - download: Some(Action { - href: format!( - "{prefix}/{}", - stored.file_name().unwrap().to_string_lossy() - ), - }), - upload: None, - verify: None, - }, - ), - "download" => error(object, 404, "object not found"), - "upload" if present => response( - object, - Actions { - download: Some(Action { - href: format!( - "{prefix}/{}", - stored.file_name().unwrap().to_string_lossy() - ), - }), - upload: None, - verify: None, - }, - ), - "upload" if !write_enabled => error(object, 403, "LFS uploads are disabled"), - "upload" => response( - object, - Actions { - download: None, - upload: Some(Action { - href: format!( - "{prefix}/{}", - stored.file_name().unwrap().to_string_lossy() - ), - }), - verify: Some(Action { - href: format!( - "{prefix}/{}/verify", - stored.file_name().unwrap().to_string_lossy() - ), - }), - }, - ), - _ => unreachable!(), - } - }) - .collect(); - json( - axum::http::StatusCode::OK, - &BatchResponse { - transfer: "basic", - objects, - }, - ) -} - -async fn download(objects: &std::path::Path, oid: &str, head: bool) -> axum::response::Response { - let path = object_path(objects, oid); - let metadata = match tokio::fs::metadata(&path).await { - Ok(metadata) if metadata.is_file() => metadata, - _ => return plain(axum::http::StatusCode::NOT_FOUND, "object not found\n"), - }; - let body = if head { - axum::body::Body::empty() - } else { - match tokio::fs::read(path).await { - Ok(body) => axum::body::Body::from(body), - Err(_) => { - return plain( - axum::http::StatusCode::INTERNAL_SERVER_ERROR, - "read failed\n", - ); - } - } - }; - axum::http::Response::builder() - .status(axum::http::StatusCode::OK) - .header(axum::http::header::CONTENT_TYPE, "application/octet-stream") - .header(axum::http::header::CONTENT_LENGTH, metadata.len()) - .body(body) - .expect("valid LFS response") -} - -async fn upload( - objects: &std::path::Path, - oid: &str, - request: axum::extract::Request, -) -> axum::response::Response { - use sha2::Digest; - - let body = match axum::body::to_bytes(request.into_body(), MAX_OBJECT_SIZE).await { - Ok(body) => body, - Err(_) => { - return plain( - axum::http::StatusCode::PAYLOAD_TOO_LARGE, - "object too large\n", - ); - } - }; - let actual = format!("{:x}", sha2::Sha256::digest(&body)); - if actual != oid { - return plain( - axum::http::StatusCode::UNPROCESSABLE_ENTITY, - "object hash mismatch\n", - ); - } - let path = object_path(objects, oid); - let Some(parent) = path.parent() else { - return plain( - axum::http::StatusCode::INTERNAL_SERVER_ERROR, - "invalid storage path\n", - ); - }; - if tokio::fs::create_dir_all(parent).await.is_err() { - return plain( - axum::http::StatusCode::INTERNAL_SERVER_ERROR, - "storage failed\n", - ); - } - let temporary = parent.join(format!(".{oid}.{}.tmp", std::process::id())); - if tokio::fs::write(&temporary, &body).await.is_err() - || tokio::fs::rename(&temporary, &path).await.is_err() - { - let _ = tokio::fs::remove_file(temporary).await; - return plain( - axum::http::StatusCode::INTERNAL_SERVER_ERROR, - "storage failed\n", - ); - } - plain(axum::http::StatusCode::OK, "") -} - -async fn verify( - objects: &std::path::Path, - oid: &str, - request: axum::extract::Request, -) -> axum::response::Response { - if !valid_oid(oid) { - return plain(axum::http::StatusCode::BAD_REQUEST, "invalid object id\n"); - } - let body = match axum::body::to_bytes(request.into_body(), 64 * 1024).await { - Ok(body) => body, - Err(_) => { - return plain( - axum::http::StatusCode::PAYLOAD_TOO_LARGE, - "request too large\n", - ); - } - }; - let request: VerifyRequest = match serde_json::from_slice(&body) { - Ok(request) => request, - Err(_) => return plain(axum::http::StatusCode::BAD_REQUEST, "invalid LFS request\n"), - }; - let valid = request.oid == oid - && std::fs::metadata(object_path(objects, oid)) - .is_ok_and(|metadata| metadata.is_file() && metadata.len() == request.size); - if valid { - plain(axum::http::StatusCode::OK, "") - } else { - plain(axum::http::StatusCode::NOT_FOUND, "object not found\n") - } -} - -fn repository(root: &std::path::Path, repo: &str) -> Option<std::path::PathBuf> { - let root = std::fs::canonicalize(root).ok()?; - let repository = std::fs::canonicalize(root.join(format!("{repo}.git"))).ok()?; - repository - .is_dir() - .then_some(repository) - .filter(|repository| repository.starts_with(root)) -} - -fn object_path(root: &std::path::Path, oid: &str) -> std::path::PathBuf { - root.join(&oid[..2]).join(&oid[2..4]).join(oid) -} - -fn valid_oid(oid: &str) -> bool { - oid.len() == 64 - && oid - .bytes() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) -} - -fn response(object: ObjectRequest, actions: Actions) -> ObjectResponse { - ObjectResponse { - oid: object.oid, - size: object.size, - actions: Some(actions), - error: None, - } -} - -fn error(object: ObjectRequest, code: u16, message: &'static str) -> ObjectResponse { - ObjectResponse { - oid: object.oid, - size: object.size, - actions: None, - error: Some(ObjectError { code, message }), - } -} - -fn json<T: serde::Serialize>( - status: axum::http::StatusCode, - value: &T, -) -> axum::response::Response { - match serde_json::to_vec(value) { - Ok(body) => axum::http::Response::builder() - .status(status) - .header( - axum::http::header::CONTENT_TYPE, - "application/vnd.git-lfs+json", - ) - .body(axum::body::Body::from(body)) - .expect("valid LFS response"), - Err(_) => plain( - axum::http::StatusCode::INTERNAL_SERVER_ERROR, - "encoding failed\n", - ), - } -} - -fn plain(status: axum::http::StatusCode, message: &'static str) -> axum::response::Response { - axum::http::Response::builder() - .status(status) - .header(axum::http::header::CONTENT_TYPE, "text/plain") - .body(axum::body::Body::from(message)) - .expect("valid LFS response") -} diff --git a/crates/gilti/src/main.rs b/crates/gilti/src/main.rs deleted file mode 100644 --- a/crates/gilti/src/main.rs +++ /dev/null @@ -1,624 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -mod cgi; -mod lfs; -mod models; -pub mod router; -mod views; - -const DEFAULT_LISTEN_ADDR: &str = "0.0.0.0:8080"; -const DEFAULT_ROOT_TITLE: &str = "Gilti"; -const DEFAULT_ROOT_DESCRIPTION: &str = "A tiny Git server"; -const GIT_HTTP_BACKENDS: &[&str] = &[ - "/usr/libexec/git-core/git-http-backend", - "/usr/lib/git-core/git-http-backend", -]; -const GIT: &str = "/usr/bin/git"; -const GIT_HOME: &str = "/var/lib/gilti/git"; -const REPOSITORIES: &str = "/var/lib/gilti/git/repositories"; -const ARCHIVE_COMPRESSORS: &[&str] = &[ - "/usr/bin/bzip2", - "/usr/bin/lzip", - "/usr/bin/xz", - "/usr/bin/zstd", -]; - -const ASSET_CSS: &str = "/usr/share/gilti/gilti.css"; -const ASSET_JS: &str = "/usr/share/gilti/gilti.js"; -const ASSET_LOGO: &str = "/usr/share/gilti/gilti.png"; -const ASSET_FAVICON: &str = "/usr/share/gilti/favicon.ico"; - -#[derive(Clone)] -struct RepositoryService { - git: cgi::Cgi, - views: views::shared::Context, - write_enabled: bool, -} - -struct Config { - listen_addr: std::net::SocketAddr, - root_title: String, - root_description: String, - clone_prefix: String, - http_write: bool, -} - -impl Config { - fn from_environment() -> std::io::Result<Self> { - let listen_addr = environment("GILTI_HTTP_ADDR", DEFAULT_LISTEN_ADDR)? - .parse() - .map_err(|_| invalid_config("GILTI_HTTP_ADDR must be a socket address"))?; - Ok(Self { - listen_addr, - root_title: environment("GILTI_ROOT_TITLE", DEFAULT_ROOT_TITLE)?, - root_description: environment("GILTI_ROOT_DESCRIPTION", DEFAULT_ROOT_DESCRIPTION)?, - clone_prefix: environment("GILTI_CLONE_PREFIX", "")?, - http_write: parse_bool("GILTI_HTTP_WRITE", &environment("GILTI_HTTP_WRITE", "0")?)?, - }) - } -} - -fn environment(name: &str, default: &str) -> std::io::Result<String> { - match std::env::var(name) { - Ok(value) => Ok(value), - Err(std::env::VarError::NotPresent) => Ok(default.to_owned()), - Err(std::env::VarError::NotUnicode(_)) => { - Err(invalid_config(format!("{name} must be valid UTF-8"))) - } - } -} - -fn parse_bool(name: &str, value: &str) -> std::io::Result<bool> { - match value { - "0" => Ok(false), - "1" => Ok(true), - _ => Err(invalid_config(format!("{name} must be 0 or 1"))), - } -} - -fn invalid_config(message: impl Into<String>) -> std::io::Error { - std::io::Error::new(std::io::ErrorKind::InvalidData, message.into()) -} - -#[tokio::main] -async fn main() -> Result<(), Box<dyn std::error::Error>> { - let config = Config::from_environment()?; - let listen_addr = config.listen_addr; - let git_http_backend = git_http_backend()?; - check_files(&git_http_backend)?; - - if std::env::args().nth(1).as_deref() == Some("--check") { - return Ok(()); - } - - let views = views::shared::Context { - repositories: REPOSITORIES, - root_title: std::sync::Arc::from(config.root_title.clone()), - root_description: std::sync::Arc::from(config.root_description.clone()), - clone_prefix: std::sync::Arc::from(config.clone_prefix.clone()), - }; - let git = cgi::Cgi::new(git_http_backend, GIT_HOME, config.listen_addr) - .env("GIT_PROJECT_ROOT", REPOSITORIES) - .env("GIT_HTTP_EXPORT_ALL", "1") - .env("HOME", GIT_HOME) - .env("USER", "git") - .env("LOGNAME", "git") - .env("GIT_CONFIG_GLOBAL", "/dev/null") - .env("GIT_CONFIG_NOSYSTEM", "1") - .env("PATH", "/usr/bin:/bin"); - let repositories = RepositoryService { - git, - views, - write_enabled: config.http_write, - }; - let app = axum::Router::new() - .route( - "/-/health", - axum::routing::get(async || { - response( - axum::http::StatusCode::OK, - "application/json", - b"{\"status\":\"ok\"}\n".to_vec(), - ) - }), - ) - .route( - "/-/about", - axum::routing::get(async || { - plain_response(axum::http::StatusCode::OK, "Gilti Git server\n") - }), - ) - .route( - "/-/terms", - axum::routing::get(async || { - plain_response(axum::http::StatusCode::OK, "No additional terms of use.\n") - }), - ) - .route( - "/-/assets/gilti.css", - axum::routing::get(async || static_file(ASSET_CSS, "text/css")), - ) - .route( - "/-/assets/gilti.js", - axum::routing::get(async || static_file(ASSET_JS, "text/javascript")), - ) - .route( - "/-/assets/gilti.png", - axum::routing::get(async || static_file(ASSET_LOGO, "image/png")), - ) - .route( - "/-/assets/favicon.ico", - axum::routing::get(async || static_file(ASSET_FAVICON, "image/x-icon")), - ) - .fallback_service(repositories); - - let listener = tokio::net::TcpListener::bind(listen_addr).await?; - eprintln!("gilti: listening on {listen_addr}"); - - axum::serve( - listener, - app.into_make_service_with_connect_info::<std::net::SocketAddr>(), - ) - .with_graceful_shutdown(shutdown_signal()) - .await?; - - Ok(()) -} - -fn git_http_backend() -> std::io::Result<std::path::PathBuf> { - GIT_HTTP_BACKENDS - .iter() - .map(std::path::PathBuf::from) - .find(|path| path.is_file()) - .ok_or_else(|| { - std::io::Error::new(std::io::ErrorKind::NotFound, "git-http-backend not found") - }) -} - -fn check_files(git_http_backend: &std::path::Path) -> std::io::Result<()> { - for path in std::iter::once(git_http_backend) - .chain(std::iter::once(std::path::Path::new(GIT))) - .chain(ARCHIVE_COMPRESSORS.iter().map(std::path::Path::new)) - { - let metadata = std::fs::metadata(path)?; - if !metadata.is_file() - || std::os::unix::fs::PermissionsExt::mode(&metadata.permissions()) & 0o111 == 0 - { - return Err(std::io::Error::other(format!( - "{} is not executable", - path.display() - ))); - } - } - for path in [ASSET_CSS, ASSET_JS, ASSET_LOGO, ASSET_FAVICON] { - if !std::fs::metadata(path)?.is_file() { - return Err(std::io::Error::other(format!( - "{path} is not a regular file" - ))); - } - } - Ok(()) -} - -async fn shutdown_signal() { - let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) - .expect("install SIGTERM handler"); - let mut interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt()) - .expect("install SIGINT handler"); - tokio::select! { - _ = terminate.recv() => {} - _ = interrupt.recv() => {} - } -} - -fn static_file(path: &str, content_type: &'static str) -> axum::response::Response { - match std::fs::read(path) { - Ok(bytes) => response(axum::http::StatusCode::OK, content_type, bytes), - Err(error) => { - eprintln!("gilti: cannot read {path}: {error}"); - plain_response(axum::http::StatusCode::NOT_FOUND, "not found\n") - } - } -} - -impl tower::Service<axum::extract::Request> for RepositoryService { - type Response = axum::response::Response; - type Error = std::convert::Infallible; - type Future = std::pin::Pin< - Box<dyn std::future::Future<Output = Result<Self::Response, Self::Error>> + Send>, - >; - - fn poll_ready( - &mut self, - _context: &mut std::task::Context<'_>, - ) -> std::task::Poll<Result<(), Self::Error>> { - std::task::Poll::Ready(Ok(())) - } - - fn call(&mut self, request: axum::extract::Request) -> Self::Future { - let service = self.clone(); - Box::pin(async move { Ok(service.execute(request).await) }) - } -} - -impl RepositoryService { - async fn execute(&self, mut request: axum::extract::Request) -> axum::response::Response { - let route = match router::parse(request.uri().path()) { - Ok(route) => route, - Err(_) => return plain_response(axum::http::StatusCode::NOT_FOUND, "not found\n"), - }; - if let Some(axum::extract::ConnectInfo(remote)) = request - .extensions() - .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>() - .copied() - { - request.extensions_mut().insert(cgi::RemoteAddr(remote)); - } - let browser_view = matches!( - &route, - router::Route::Repositories - | router::Route::Overview(_) - | router::Route::About(_) - | router::Route::Stats(_) - | router::Route::Object(_) - | router::Route::Refs(_) - | router::Route::Revision(_) - | router::Route::Tree(_) - | router::Route::Blame(_) - | router::Route::Archive(_) - | router::Route::ArchiveSignature(_) - | router::Route::Diff(_) - | router::Route::Patch(_) - | router::Route::Log(_) - | router::Route::AtomFeed(_) - ); - let query = if browser_view { - let query = match request_query(request.uri().query()) { - Ok(query) => query, - Err(()) => { - return plain_response(axum::http::StatusCode::BAD_REQUEST, "bad query\n"); - } - }; - if !valid_format(&route, query.format.as_deref()) { - return plain_response(axum::http::StatusCode::NOT_FOUND, "not found\n"); - } - Some(query) - } else { - None - }; - let format = query.as_ref().and_then(|query| query.format.as_deref()); - - match route { - router::Route::Repositories => { - views::repositories::serve( - &self.views, - views::repositories::Query::from_request(query.as_ref().expect("query parsed")), - request.method().clone(), - ) - .await - } - router::Route::Summary(route) => redirect(&route.repo), - router::Route::GitClone(route) => redirect(&route.repo), - router::Route::Overview(route) => { - views::overview::serve( - &self.views, - route, - request.headers(), - request.method().clone(), - ) - .await - } - router::Route::About(route) => { - views::about::serve(&self.views, route, request.method().clone()).await - } - router::Route::Stats(route) => { - let query = match views::stats::Query::from_request( - query.as_ref().expect("query parsed"), - ) { - Ok(query) => query, - Err(views::stats::QueryError::BadRequest) => { - return views::bad_request("bad query\n"); - } - Err(views::stats::QueryError::NotFound) => { - return plain_response(axum::http::StatusCode::NOT_FOUND, "not found\n"); - } - }; - views::stats::serve(&self.views, route, query, request.method().clone()).await - } - router::Route::Object(route) => { - views::object::serve(REPOSITORIES, route, request.method().clone()).await - } - router::Route::Refs(route) => { - views::refs::serve(&self.views, route, request.method().clone()).await - } - router::Route::Tree(route) => { - views::tree::serve(&self.views, route, format, request.method().clone()).await - } - router::Route::Blame(route) => { - views::blame::serve(&self.views, route, request.method().clone()).await - } - router::Route::Archive(route) => { - let Some(format) = views::archive::Format::parse(format) else { - return plain_response(axum::http::StatusCode::NOT_FOUND, "not found\n"); - }; - views::archive::serve(&self.views, route, format, request.method().clone()).await - } - router::Route::ArchiveSignature(route) => { - views::archive_signature::serve( - &self.views, - route, - format, - request.method().clone(), - ) - .await - } - router::Route::Revision(route) - if matches!( - &route.params, - router::Revision::Ref(reference) if reference.starts_with("refs/tags/") - ) => - { - views::tag::serve(&self.views, route, request.method().clone()).await - } - router::Route::Revision(route) => { - let query = - match views::diff::Query::from_request(query.as_ref().expect("query parsed")) { - Ok(query) => query, - Err(()) => return views::bad_request("bad query\n"), - }; - views::revision::serve(&self.views, route, query, request.method().clone()).await - } - router::Route::Diff(route) => { - let query = - match views::diff::Query::from_request(query.as_ref().expect("query parsed")) { - Ok(query) => query, - Err(()) => return views::bad_request("bad query\n"), - }; - views::diff::serve( - &self.views, - route, - query, - format == Some("raw"), - request.method().clone(), - ) - .await - } - router::Route::Patch(route) => { - views::patch::serve(&self.views, route, request.method().clone()).await - } - router::Route::Log(route) => { - let query = - match views::log::Query::from_request(query.as_ref().expect("query parsed")) { - Ok(query) => query, - Err(()) => return views::bad_request("bad query\n"), - }; - views::log::serve(&self.views, route, query, request.method().clone()).await - } - router::Route::AtomFeed(route) => { - views::atom::serve( - &self.views, - route, - request.headers().get(axum::http::header::HOST), - request.method().clone(), - ) - .await - } - router::Route::GitLfs(route) => { - lfs::serve( - std::path::Path::new(REPOSITORIES), - &route.repo, - &route.params, - self.write_enabled, - request, - ) - .await - } - router::Route::GitInfoRefs(route) => { - self.git(request, route.repo, "info/refs", self.write_enabled) - .await - } - router::Route::GitUploadPack(route) => { - self.git(request, route.repo, "git-upload-pack", false) - .await - } - router::Route::GitReceivePack(route) if self.write_enabled => { - self.git(request, route.repo, "git-receive-pack", true) - .await - } - router::Route::GitReceivePack(_) => { - plain_response(axum::http::StatusCode::FORBIDDEN, "HTTP push is disabled\n") - } - router::Route::GitHead(route) => self.git(request, route.repo, "HEAD", false).await, - router::Route::GitObjects(route) => { - self.git( - request, - route.repo, - &format!("objects/{}", route.params), - false, - ) - .await - } - } - } - - async fn git( - &self, - mut request: axum::extract::Request, - repo: String, - endpoint: &str, - authenticated: bool, - ) -> axum::response::Response { - if !safe_repository(&repo) { - return plain_response(axum::http::StatusCode::NOT_FOUND, "not found\n"); - } - let mut environment = vec![ - ("PATH_INFO".into(), format!("/{repo}.git/{endpoint}").into()), - ("GIT_PROJECT_ROOT".into(), REPOSITORIES.into()), - ]; - if authenticated { - environment.push(("REMOTE_USER".into(), "gilti".into())); - } - request - .extensions_mut() - .insert(cgi::Environment(environment)); - match tower::ServiceExt::oneshot(self.git.clone(), request).await { - Ok(response) => response, - Err(error) => internal_error("git-http-backend", error), - } - } -} - -#[derive(Default)] -struct RequestQuery { - format: Option<String>, - environment: Vec<(std::ffi::OsString, std::ffi::OsString)>, -} - -impl RequestQuery { - fn value(&self, name: &str) -> Option<&str> { - self.environment - .iter() - .find(|(key, _)| key == std::ffi::OsStr::new(name)) - .and_then(|(_, value)| value.to_str()) - } -} - -fn request_query(query: Option<&str>) -> Result<RequestQuery, ()> { - let mut result = RequestQuery::default(); - for pair in query - .unwrap_or("") - .split('&') - .filter(|pair| !pair.is_empty()) - { - let (name, value) = pair.split_once('=').unwrap_or((pair, "")); - let name = decode_query(name)?; - let value = decode_query(value)?; - if name == "format" { - if result.format.replace(value).is_some() { - return Err(()); - } - continue; - } - let environment = match name.as_str() { - "q" => "GILTI_QUERY_SEARCH", - "qt" => "GILTI_QUERY_GREP", - "ofs" => "GILTI_QUERY_OFFSET", - "s" => "GILTI_QUERY_SORT", - "showmsg" => "GILTI_QUERY_SHOWMSG", - "period" => "GILTI_QUERY_PERIOD", - "dt" => "GILTI_QUERY_DIFFTYPE", - "context" => "GILTI_QUERY_CONTEXT", - "ignorews" => "GILTI_QUERY_IGNOREWS", - "follow" => "GILTI_QUERY_FOLLOW", - "view" => "GILTI_QUERY_VIEW", - _ => return Err(()), - }; - if result - .environment - .iter() - .any(|(existing, _)| existing == std::ffi::OsStr::new(environment)) - { - return Err(()); - } - result.environment.push((environment.into(), value.into())); - } - Ok(result) -} - -fn decode_query(value: &str) -> Result<String, ()> { - let value = value.replace('+', " "); - let value = percent_encoding::percent_decode_str(&value) - .decode_utf8() - .map_err(|_| ())? - .into_owned(); - (!value.contains('\0')).then_some(value).ok_or(()) -} - -fn valid_format(route: &router::Route, format: Option<&str>) -> bool { - let Some(format) = format else { - return true; - }; - match route { - router::Route::Tree(_) | router::Route::Diff(_) => matches!(format, "html" | "raw"), - router::Route::Object(_) => format == "raw", - router::Route::Patch(_) => matches!(format, "patch" | "raw"), - router::Route::Archive(_) | router::Route::ArchiveSignature(_) => matches!( - format, - "tar" | "tar.gz" | "tar.bz2" | "tar.lz" | "tar.xz" | "tar.zst" | "zip" - ), - router::Route::AtomFeed(_) => format == "atom", - _ => format == "html", - } -} - -fn safe_repository(repo: &str) -> bool { - !repo.is_empty() - && !repo.starts_with('/') - && !repo.chars().any(char::is_control) - && !repo.contains('\\') - && repo - .split('/') - .all(|component| !component.is_empty() && !matches!(component, "." | "..")) -} - -fn redirect(repo: &str) -> axum::response::Response { - axum::http::Response::builder() - .status(axum::http::StatusCode::PERMANENT_REDIRECT) - .header( - axum::http::header::LOCATION, - format!("/{}", encode_path(repo)), - ) - .body(axum::body::Body::empty()) - .expect("valid redirect") -} - -fn encode_path(value: &str) -> String { - let mut encoded = String::new(); - for byte in value.bytes() { - if byte.is_ascii_alphanumeric() || byte == b'/' || byte == b'_' { - encoded.push(char::from(byte)); - } else { - use std::fmt::Write; - write!(encoded, "%{byte:02X}").expect("writing to String cannot fail"); - } - } - encoded -} - -fn internal_error(context: &str, error: std::io::Error) -> axum::response::Response { - eprintln!("gilti: {context} request failed: {error}"); - plain_response( - axum::http::StatusCode::INTERNAL_SERVER_ERROR, - "internal server error\n", - ) -} - -fn response( - status: axum::http::StatusCode, - content_type: &'static str, - body: Vec<u8>, -) -> axum::response::Response { - axum::response::Response::builder() - .status(status) - .header(axum::http::header::CONTENT_TYPE, content_type) - .body(axum::body::Body::from(body)) - .expect("static response is valid") -} - -fn plain_response( - status: axum::http::StatusCode, - message: &'static str, -) -> axum::response::Response { - response(status, "text/plain", message.as_bytes().to_vec()) -} - -#[cfg(test)] -mod tests { - #[test] - fn structural_query_parameters_are_rejected() { - assert!(super::request_query(Some("id=HEAD")).is_err()); - assert!(super::request_query(Some("path=README.md")).is_err()); - assert!(super::request_query(Some("format=raw&format=html")).is_err()); - } -} diff --git a/crates/gilti/src/models/about.rs b/crates/gilti/src/models/about.rs deleted file mode 100644 --- a/crates/gilti/src/models/about.rs +++ /dev/null @@ -1,31 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -use std::path::Path; - -pub struct About { - pub repository: super::repository::Info, - pub bytes: Vec<u8>, -} - -impl About { - pub fn load(root: &Path, name: &str) -> Result<Self, super::Error> { - let repository = super::repository::open(root, name)?; - let info = super::repository::info(&repository, name); - let commit = super::revision::commit(&repository, &crate::router::Revision::Head)?; - let tree = commit.tree().map_err(super::Error::from_git)?; - let blob = ["README.md", "README"] - .iter() - .find_map(|path| { - tree.get_path(Path::new(path)) - .ok() - .filter(|entry| entry.kind() == Some(git2::ObjectType::Blob)) - .and_then(|entry| repository.find_blob(entry.id()).ok()) - }) - .ok_or(super::Error::NotFound)?; - Ok(Self { - repository: info, - bytes: blob.content().to_vec(), - }) - } -} diff --git a/crates/gilti/src/models/archive.rs b/crates/gilti/src/models/archive.rs deleted file mode 100644 --- a/crates/gilti/src/models/archive.rs +++ /dev/null @@ -1,41 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -use std::path::{Path, PathBuf}; - -pub struct Archive { - pub repository_path: PathBuf, - pub oid: String, - pub prefix: String, -} - -impl Archive { - pub fn load( - root: &Path, - name: &str, - revision: &crate::router::Revision, - path: Option<&str>, - ) -> Result<Self, super::Error> { - let repository = super::repository::open(root, name)?; - let commit = super::revision::commit(&repository, revision)?; - if let Some(path) = path { - commit - .tree() - .and_then(|tree| tree.get_path(Path::new(path))) - .map_err(|_| super::Error::NotFound)?; - } - let repository_path = repository.path().to_owned(); - let prefix = name - .rsplit('/') - .next() - .unwrap_or(name) - .strip_suffix(".git") - .unwrap_or_else(|| name.rsplit('/').next().unwrap_or(name)) - .to_owned(); - Ok(Self { - repository_path, - oid: commit.id().to_string(), - prefix, - }) - } -} diff --git a/crates/gilti/src/models/archive_signature.rs b/crates/gilti/src/models/archive_signature.rs deleted file mode 100644 --- a/crates/gilti/src/models/archive_signature.rs +++ /dev/null @@ -1,31 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -use std::path::Path; - -pub struct ArchiveSignature { - pub bytes: Vec<u8>, - pub oid: String, - pub filename: String, -} - -impl ArchiveSignature { - pub fn load( - root: &Path, - name: &str, - revision: crate::router::Revision, - format: &str, - ) -> Result<Self, super::Error> { - let repository = super::repository::open(root, name)?; - let commit = super::revision::commit(&repository, &revision)?; - let notes_ref = format!("refs/notes/signatures/{format}"); - let note = repository - .find_note(Some(¬es_ref), commit.id()) - .map_err(super::Error::from_git)?; - Ok(Self { - bytes: note.message_bytes().to_vec(), - oid: note.id().to_string(), - filename: format!("{}.{}.asc", name.rsplit('/').next().unwrap_or(name), format), - }) - } -} diff --git a/crates/gilti/src/models/blame.rs b/crates/gilti/src/models/blame.rs deleted file mode 100644 --- a/crates/gilti/src/models/blame.rs +++ /dev/null @@ -1,114 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -use std::path::Path; - -pub struct Blame { - pub repository: super::repository::Info, - pub revision: String, - pub path: String, - pub oid: String, - pub bytes: Vec<u8>, - pub binary: bool, - pub hunks: Vec<Hunk>, -} - -pub struct Hunk { - pub oid: String, - pub short_oid: String, - pub start: usize, - pub lines: usize, - pub original_path: String, - pub author: String, - pub author_email: String, - pub committer: String, - pub committer_email: String, - pub timestamp: i64, - pub summary: String, - pub parent: Option<String>, -} - -impl Blame { - pub fn load( - root: &Path, - name: &str, - revision: crate::router::Revision, - path: String, - ) -> Result<Self, super::Error> { - let repository = super::repository::open(root, name)?; - let info = super::repository::info(&repository, name); - let selector = super::revision::selector(&revision); - let commit = super::revision::commit(&repository, &revision)?; - let tree = commit.tree().map_err(super::Error::from_git)?; - let entry = tree - .get_path(Path::new(&path)) - .map_err(super::Error::from_git)?; - if !matches!(entry.filemode_raw(), 0o100644 | 0o100755) { - return Err(super::Error::NotFound); - } - let blob = repository - .find_blob(entry.id()) - .map_err(super::Error::from_git)?; - let bytes = blob.content().to_vec(); - let binary = bytes.iter().take(8000).any(|byte| *byte == 0); - let mut hunks = Vec::new(); - if !binary { - let mut options = git2::BlameOptions::new(); - options.newest_commit(commit.id()); - let blame = repository - .blame_file(Path::new(&path), Some(&mut options)) - .map_err(super::Error::from_git)?; - for hunk in blame.iter() { - let oid = hunk.final_commit_id(); - let commit = repository - .find_commit(oid) - .map_err(super::Error::from_git)?; - let author = hunk.final_signature().unwrap_or_else(|| commit.author()); - let committer = hunk.final_committer().unwrap_or_else(|| commit.committer()); - hunks.push(Hunk { - oid: oid.to_string(), - short_oid: oid.to_string().chars().take(7).collect(), - start: hunk.final_start_line(), - lines: hunk.lines_in_hunk(), - original_path: hunk - .path() - .map_or_else(|| path.clone(), |path| path.to_string_lossy().into_owned()), - author: signature_name(&author), - author_email: signature_email(&author), - committer: signature_name(&committer), - committer_email: signature_email(&committer), - timestamp: author.when().seconds(), - summary: String::from_utf8_lossy( - hunk.summary_bytes() - .unwrap_or(commit.summary_bytes().unwrap_or_default()), - ) - .into_owned(), - parent: commit.parent_id(0).ok().map(|oid| oid.to_string()), - }); - } - } - Ok(Self { - repository: info, - revision: selector, - path, - oid: entry.id().to_string(), - bytes, - binary, - hunks, - }) - } -} - -fn signature_name(signature: &git2::Signature<'_>) -> String { - signature - .name() - .map(str::to_owned) - .unwrap_or_else(|_| String::from_utf8_lossy(signature.name_bytes()).into_owned()) -} - -fn signature_email(signature: &git2::Signature<'_>) -> String { - signature - .email() - .map(str::to_owned) - .unwrap_or_else(|_| String::from_utf8_lossy(signature.email_bytes()).into_owned()) -} diff --git a/crates/gilti/src/models/commit.rs b/crates/gilti/src/models/commit.rs deleted file mode 100644 --- a/crates/gilti/src/models/commit.rs +++ /dev/null @@ -1,151 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -use std::path::Path; - -pub struct Commit { - pub repository: super::repository::Info, - pub revision: String, - pub oid: String, - pub tree: String, - pub author: Identity, - pub committer: Identity, - pub subject: String, - pub message: String, - pub parents: Vec<String>, - pub decorations: Vec<Decoration>, - pub notes: Option<String>, - pub diff: Option<super::diff::Diff>, -} - -pub struct Identity { - pub name: String, - pub email: String, - pub timestamp: i64, - pub offset_minutes: i32, -} - -pub struct Decoration { - pub label: String, - pub reference: Option<String>, -} - -impl Commit { - pub fn load( - root: &Path, - name: &str, - revision: crate::router::Revision, - diff_options: super::diff::Options, - ) -> Result<Self, super::Error> { - let repository = super::repository::open(root, name)?; - let info = super::repository::info(&repository, name); - let commit = super::revision::commit(&repository, &revision)?; - let oid = commit.id(); - let parents = commit - .parent_ids() - .map(|id| id.to_string()) - .collect::<Vec<_>>(); - let author = identity(commit.author()); - let committer = identity(commit.committer()); - let message = String::from_utf8_lossy(commit.message_bytes()); - let subject = - String::from_utf8_lossy(commit.summary_bytes().unwrap_or_default()).into_owned(); - let body = message - .split_once('\n') - .map_or("", |(_, body)| body.trim_start_matches('\n')) - .to_owned(); - let notes = repository - .find_note(None, oid) - .ok() - .map(|note| String::from_utf8_lossy(note.message_bytes()).into_owned()); - let refs = super::refs::Refs::load(root, name)?; - let mut decorations = Vec::new(); - if repository - .head() - .ok() - .and_then(|head| head.peel_to_commit().ok()) - .is_some_and(|head| head.id() == oid) - { - decorations.push(Decoration { - label: "HEAD".to_owned(), - reference: None, - }); - } - for branch in refs.branches { - if repository - .find_reference(&branch.reference) - .ok() - .and_then(|reference| reference.peel_to_commit().ok()) - .is_some_and(|target| target.id() == oid) - { - decorations.push(Decoration { - label: branch.name, - reference: Some(branch.reference), - }); - } - } - for tag in refs.tags { - if repository - .find_reference(&tag.reference) - .ok() - .and_then(|reference| reference.peel_to_commit().ok()) - .is_some_and(|target| target.id() == oid) - { - decorations.push(Decoration { - label: tag.name, - reference: Some(tag.reference), - }); - } - } - let old_revision = (parents.len() < 3) - .then(|| parents.first().cloned()) - .flatten() - .map(crate::router::Revision::Commit); - let show_diff = parents.len() < 3; - let revision_name = super::revision::selector(&revision); - let tree = commit.tree_id().to_string(); - drop(commit); - drop(repository); - let diff = show_diff - .then(|| { - super::diff::Diff::load( - root, - name, - old_revision, - revision.clone(), - None, - diff_options, - ) - }) - .transpose()?; - Ok(Self { - repository: info, - revision: revision_name, - oid: oid.to_string(), - tree, - author, - committer, - subject, - message: body, - parents, - decorations, - notes, - diff, - }) - } -} - -fn identity(signature: git2::Signature<'_>) -> Identity { - Identity { - name: signature - .name() - .map(str::to_owned) - .unwrap_or_else(|_| String::from_utf8_lossy(signature.name_bytes()).into_owned()), - email: signature - .email() - .map(str::to_owned) - .unwrap_or_else(|_| String::from_utf8_lossy(signature.email_bytes()).into_owned()), - timestamp: signature.when().seconds(), - offset_minutes: signature.when().offset_minutes(), - } -} diff --git a/crates/gilti/src/models/diff.rs b/crates/gilti/src/models/diff.rs deleted file mode 100644 --- a/crates/gilti/src/models/diff.rs +++ /dev/null @@ -1,159 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -use std::path::Path; - -#[derive(Clone, Copy)] -pub struct Options { - pub context: u32, - pub ignore_whitespace: bool, -} - -pub struct Diff { - pub repository: super::repository::Info, - pub old_revision: Option<String>, - pub new_revision: String, - pub old_oid: Option<String>, - pub new_oid: String, - pub files: Vec<File>, - pub additions: usize, - pub deletions: usize, -} - -pub struct File { - pub status: git2::Delta, - pub old_path: String, - pub new_path: String, - pub old_oid: Option<String>, - pub new_oid: Option<String>, - pub old_mode: u32, - pub new_mode: u32, - pub old_size: u64, - pub new_size: u64, - pub binary: bool, - pub additions: usize, - pub deletions: usize, - pub hunks: Vec<Hunk>, -} - -pub struct Hunk { - pub header: String, - pub lines: Vec<Line>, -} - -pub struct Line { - pub origin: char, - pub content: String, - pub old_line: Option<u32>, - pub new_line: Option<u32>, -} - -impl Diff { - pub fn load( - root: &Path, - name: &str, - old_revision: Option<crate::router::Revision>, - new_revision: crate::router::Revision, - path: Option<String>, - options: Options, - ) -> Result<Self, super::Error> { - let repository = super::repository::open(root, name)?; - let info = super::repository::info(&repository, name); - let new_commit = super::revision::commit(&repository, &new_revision)?; - let old_commit = old_revision - .as_ref() - .map(|revision| super::revision::commit(&repository, revision)) - .transpose()?; - let old_tree = old_commit - .as_ref() - .map(git2::Commit::tree) - .transpose() - .map_err(super::Error::from_git)?; - let new_tree = new_commit.tree().map_err(super::Error::from_git)?; - let mut diff_options = git2::DiffOptions::new(); - diff_options.context_lines(options.context); - if options.ignore_whitespace { - diff_options.ignore_whitespace(true); - } - if let Some(path) = &path { - diff_options.pathspec(path).disable_pathspec_match(true); - } - let mut diff = repository - .diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), Some(&mut diff_options)) - .map_err(super::Error::from_git)?; - diff.find_similar(None).map_err(super::Error::from_git)?; - let stats = diff.stats().map_err(super::Error::from_git)?; - let mut files = Vec::with_capacity(diff.deltas().len()); - for (index, delta) in diff.deltas().enumerate() { - let old_file = delta.old_file(); - let new_file = delta.new_file(); - let patch = git2::Patch::from_diff(&diff, index).map_err(super::Error::from_git)?; - let (additions, deletions, hunks) = if let Some(patch) = patch { - let (_, additions, deletions) = - patch.line_stats().map_err(super::Error::from_git)?; - let mut hunks = Vec::with_capacity(patch.num_hunks()); - for hunk_index in 0..patch.num_hunks() { - let (hunk, lines) = patch.hunk(hunk_index).map_err(super::Error::from_git)?; - let mut result = Hunk { - header: String::from_utf8_lossy(hunk.header()) - .trim_end_matches('\n') - .to_owned(), - lines: Vec::with_capacity(lines), - }; - for line_index in 0..lines { - let line = patch - .line_in_hunk(hunk_index, line_index) - .map_err(super::Error::from_git)?; - result.lines.push(Line { - origin: line.origin(), - content: String::from_utf8_lossy(line.content()) - .trim_end_matches('\n') - .to_owned(), - old_line: line.old_lineno(), - new_line: line.new_lineno(), - }); - } - hunks.push(result); - } - (additions, deletions, hunks) - } else { - (0, 0, Vec::new()) - }; - files.push(File { - status: delta.status(), - old_path: path_string(old_file.path_bytes()), - new_path: path_string(new_file.path_bytes()), - old_oid: oid(old_file.id()), - new_oid: oid(new_file.id()), - old_mode: old_file.mode().into(), - new_mode: new_file.mode().into(), - old_size: old_file.size(), - new_size: new_file.size(), - binary: old_file.is_binary() || new_file.is_binary(), - additions, - deletions, - hunks, - }); - } - Ok(Self { - repository: info, - old_revision: old_revision.as_ref().map(super::revision::selector), - new_revision: super::revision::selector(&new_revision), - old_oid: old_commit.map(|commit| commit.id().to_string()), - new_oid: new_commit.id().to_string(), - files, - additions: stats.insertions(), - deletions: stats.deletions(), - }) - } -} - -fn path_string(path: Option<&[u8]>) -> String { - path.map_or_else(String::new, |path| { - String::from_utf8_lossy(path).into_owned() - }) -} - -fn oid(oid: git2::Oid) -> Option<String> { - (!oid.is_zero()).then(|| oid.to_string()) -} diff --git a/crates/gilti/src/models/history.rs b/crates/gilti/src/models/history.rs deleted file mode 100644 --- a/crates/gilti/src/models/history.rs +++ /dev/null @@ -1,556 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -use std::{collections::BTreeMap, path::Path, process::Command}; - -pub const LOG_PAGE_SIZE: usize = 50; - -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum Search { - None, - Grep(String), - Author(String), - Committer(String), - Range(String), -} - -pub struct Options { - pub path: Option<String>, - pub follow: bool, - pub search: Search, - pub offset: usize, - pub limit: usize, - pub graph: bool, - pub ignore_whitespace: bool, - pub include_statistics: bool, -} - -pub struct History { - pub repository: super::repository::Info, - pub revision: String, - pub entries: Vec<Entry>, - pub has_next: bool, - pub graph: bool, -} - -pub struct Entry { - pub oid: String, - pub subject: String, - pub body: String, - pub author: super::commit::Identity, - pub committer: super::commit::Identity, - pub decorations: Vec<super::commit::Decoration>, - pub notes: Option<String>, - pub files: usize, - pub additions: usize, - pub deletions: usize, - pub graph: String, - pub graph_continuations: Vec<String>, - pub path: Option<String>, -} - -impl History { - pub fn load( - root: &Path, - name: &str, - revision: crate::router::Revision, - options: Options, - ) -> Result<Self, super::Error> { - let repository = super::repository::open(root, name)?; - let info = super::repository::info(&repository, name); - let resolved = super::revision::commit(&repository, &revision)?; - let revision_name = super::revision::selector(&revision); - if let Search::Range(range) = &options.search { - for selector in range - .split_ascii_whitespace() - .flat_map(|selector| selector.split("..")) - { - let selector = if selector == "HEAD" { - crate::router::Revision::Head - } else if selector.starts_with("refs/") { - crate::router::Revision::Ref(selector.to_owned()) - } else { - crate::router::Revision::Commit(selector.to_owned()) - }; - super::revision::commit(&repository, &selector)?; - } - } - let repository_path = super::repository::path(root, name)?; - let follow = options.follow && options.path.is_some(); - let oids = select( - &repository_path, - resolved.id().to_string(), - &options, - follow, - )?; - let has_next = oids.len() > options.limit; - let selected = &oids[..oids.len().min(options.limit)]; - let git_statistics = if !options.include_statistics { - Some(BTreeMap::new()) - } else if follow { - None - } else { - Some(statistics_from_git( - &repository_path, - selected, - options.path.as_deref(), - options.ignore_whitespace, - )?) - }; - let mut decorations = decorations(&repository)?; - let mut entries = Vec::with_capacity(selected.len()); - let mut path = options.path.clone(); - for selected in selected { - let oid = git2::Oid::from_str_ext(&selected.oid, repository.object_format()) - .map_err(|_| super::Error::NotFound)?; - let commit = repository - .find_commit(oid) - .map_err(super::Error::from_git)?; - let (files, additions, deletions) = match &git_statistics { - Some(statistics) => statistics.get(&selected.oid).copied().unwrap_or_default(), - None => statistics_from_git2( - &repository, - &commit, - path.as_deref(), - options.ignore_whitespace, - )?, - }; - let message = String::from_utf8_lossy(commit.message_bytes()); - let subject = - String::from_utf8_lossy(commit.summary_bytes().unwrap_or_default()).into_owned(); - let body = message - .split_once('\n') - .map_or("", |(_, body)| body.trim_start_matches('\n')) - .to_owned(); - let notes = repository - .find_note(None, oid) - .ok() - .map(|note| String::from_utf8_lossy(note.message_bytes()).into_owned()); - entries.push(Entry { - oid: selected.oid.clone(), - subject, - body, - author: identity(commit.author()), - committer: identity(commit.committer()), - decorations: decorations.remove(&oid).unwrap_or_default(), - notes, - files, - additions, - deletions, - graph: selected.graph.clone(), - graph_continuations: selected.graph_continuations.clone(), - path: path.clone(), - }); - if follow { - path = previous_path(&repository, &commit, path)?; - } - } - Ok(Self { - repository: info, - revision: revision_name, - entries, - has_next, - graph: options.graph && !follow, - }) - } -} - -struct Selected { - oid: String, - graph: String, - graph_continuations: Vec<String>, -} - -fn git(path: &Path) -> Command { - let mut command = Command::new(crate::GIT); - command - .env_clear() - .env("HOME", "/var/empty") - .env("PATH", "/usr/bin:/bin") - .env("LC_ALL", "C") - .env("GIT_CONFIG_GLOBAL", "/dev/null") - .env("GIT_CONFIG_NOSYSTEM", "1") - .env("GIT_ATTR_NOSYSTEM", "1") - .arg("--no-replace-objects") - .arg("--git-dir") - .arg(path) - .arg("-c") - .arg("core.hooksPath=/dev/null") - .arg("-c") - .arg("color.ui=false") - .arg("--no-pager"); - command -} - -fn select( - path: &Path, - revision: String, - options: &Options, - follow: bool, -) -> Result<Vec<Selected>, super::Error> { - let mut command = git(path); - command - .arg("log") - .arg("--no-ext-diff") - .arg("--no-textconv") - .arg("--no-color") - .arg("--format=format:%x1e%H%x1f") - .arg(format!("--max-count={}", options.limit.saturating_add(1))) - .arg(format!("--skip={}", options.offset)); - if options.graph && !follow { - command.arg("--graph"); - } - if options.ignore_whitespace { - command.arg("--ignore-all-space"); - } - if follow { - command.arg("--follow"); - } - match &options.search { - Search::None => { - command.arg(revision); - } - Search::Grep(value) => { - command - .arg("--regexp-ignore-case") - .arg(format!("--grep={value}")) - .arg(revision); - } - Search::Author(value) => { - command - .arg("--regexp-ignore-case") - .arg(format!("--author={value}")) - .arg(revision); - } - Search::Committer(value) => { - command - .arg("--regexp-ignore-case") - .arg(format!("--committer={value}")) - .arg(revision); - } - Search::Range(value) => { - for selector in value.split_ascii_whitespace() { - command.arg(selector); - } - } - } - if let Some(path) = &options.path { - command.arg("--").arg(path); - } - let output = command - .output() - .map_err(|error| super::Error::Internal(error.to_string()))?; - if !output.status.success() { - let error = String::from_utf8_lossy(&output.stderr); - return Err(super::Error::Internal(format!("git log failed: {error}"))); - } - parse(&output.stdout) -} - -fn parse(bytes: &[u8]) -> Result<Vec<Selected>, super::Error> { - let text = std::str::from_utf8(bytes) - .map_err(|_| super::Error::Internal("git log emitted invalid UTF-8".to_owned()))?; - let mut result = Vec::new(); - let mut prefix_start = 0; - while let Some(relative) = text[prefix_start..].find('\x1e') { - let marker = prefix_start + relative; - let graph_lines = text[prefix_start..marker] - .strip_prefix('\n') - .unwrap_or(&text[prefix_start..marker]) - .split_terminator('\n') - .collect::<Vec<_>>(); - let (graph, graph_continuations) = - graph_lines - .split_last() - .map_or(("", Vec::new()), |(graph, continuations)| { - ( - *graph, - continuations - .iter() - .map(|line| (*line).to_owned()) - .collect(), - ) - }); - let remainder = &text[marker + 1..]; - let (oid, _) = remainder.split_once('\x1f').ok_or_else(|| { - super::Error::Internal("git log emitted an invalid record".to_owned()) - })?; - let oid = oid.trim(); - if !(matches!(oid.len(), 40 | 64) && oid.bytes().all(|byte| byte.is_ascii_hexdigit())) { - return Err(super::Error::Internal( - "git log emitted an invalid object id".to_owned(), - )); - } - result.push(Selected { - oid: oid.to_owned(), - graph: graph.to_owned(), - graph_continuations, - }); - prefix_start = marker + 1 + oid.len() + 1; - } - Ok(result) -} - -fn decorations( - repository: &git2::Repository, -) -> Result<BTreeMap<git2::Oid, Vec<super::commit::Decoration>>, super::Error> { - let mut result = BTreeMap::new(); - if let Ok(head) = repository.head() - && let Ok(commit) = head.peel_to_commit() - { - result - .entry(commit.id()) - .or_insert_with(Vec::new) - .push(super::commit::Decoration { - label: "HEAD".to_owned(), - reference: None, - }); - } - let references = repository.references().map_err(super::Error::from_git)?; - for reference in references.flatten() { - let Ok(name) = reference.name() else { - continue; - }; - if !name.starts_with("refs/heads/") && !name.starts_with("refs/tags/") { - continue; - } - let Ok(commit) = reference.peel_to_commit() else { - continue; - }; - let label = name - .strip_prefix("refs/heads/") - .or_else(|| name.strip_prefix("refs/tags/")) - .unwrap_or(name); - result - .entry(commit.id()) - .or_insert_with(Vec::new) - .push(super::commit::Decoration { - label: label.to_owned(), - reference: Some(name.to_owned()), - }); - } - Ok(result) -} - -fn statistics_from_git( - repository: &Path, - selected: &[Selected], - path: Option<&str>, - ignore_whitespace: bool, -) -> Result<BTreeMap<String, (usize, usize, usize)>, super::Error> { - if selected.is_empty() { - return Ok(BTreeMap::new()); - } - let mut command = git(repository); - command - .arg("log") - .arg("--no-walk=unsorted") - .arg("--root") - .arg("--diff-merges=first-parent") - .arg("--no-ext-diff") - .arg("--no-textconv") - .arg("--find-renames") - .arg("--numstat") - .arg("-z") - .arg("--format=format:GILTI-STATS%x00%H%x00"); - if ignore_whitespace { - command.arg("--ignore-all-space"); - } - for entry in selected { - command.arg(&entry.oid); - } - if let Some(path) = path { - command.arg("--").arg(path); - } - let output = command - .output() - .map_err(|error| super::Error::Internal(error.to_string()))?; - if !output.status.success() { - return Err(super::Error::Internal(format!( - "git log statistics failed: {}", - String::from_utf8_lossy(&output.stderr) - ))); - } - parse_statistics(&output.stdout) -} - -fn parse_statistics( - output: &[u8], -) -> Result<BTreeMap<String, (usize, usize, usize)>, super::Error> { - const MARKER: &[u8] = b"GILTI-STATS\0"; - let mut result = BTreeMap::new(); - let mut cursor = 0; - while let Some(relative) = output[cursor..] - .windows(MARKER.len()) - .position(|value| value == MARKER) - { - let start = cursor + relative + MARKER.len(); - let oid_end = output[start..] - .iter() - .position(|byte| *byte == 0) - .map(|end| start + end) - .ok_or_else(|| super::Error::Internal("git statistics omitted an OID".to_owned()))?; - let oid = std::str::from_utf8(&output[start..oid_end]).map_err(|_| { - super::Error::Internal("git statistics emitted invalid UTF-8".to_owned()) - })?; - if !(matches!(oid.len(), 40 | 64) && oid.bytes().all(|byte| byte.is_ascii_hexdigit())) { - return Err(super::Error::Internal( - "git statistics emitted an invalid object ID".to_owned(), - )); - } - let next = output[oid_end + 1..] - .windows(MARKER.len()) - .position(|value| value == MARKER) - .map_or(output.len(), |offset| oid_end + 1 + offset); - let mut files = 0; - let mut additions = 0; - let mut deletions = 0; - let mut fields = output[oid_end + 1..next].split(|byte| *byte == 0); - while let Some(field) = fields.next() { - let field = field.strip_prefix(b"\n").unwrap_or(field); - let mut values = field.splitn(3, |byte| *byte == b'\t'); - let (Some(added), Some(deleted), Some(path)) = - (values.next(), values.next(), values.next()) - else { - continue; - }; - let count = |value: &[u8]| { - (value != b"-") - .then(|| std::str::from_utf8(value).ok()?.parse::<usize>().ok()) - .flatten() - .unwrap_or(0) - }; - if (added == b"-" || added.iter().all(u8::is_ascii_digit)) - && (deleted == b"-" || deleted.iter().all(u8::is_ascii_digit)) - { - files += 1; - additions += count(added); - deletions += count(deleted); - if path.is_empty() { - fields.next(); - fields.next(); - } - } - } - result.insert(oid.to_owned(), (files, additions, deletions)); - cursor = next; - } - Ok(result) -} - -fn statistics_from_git2( - repository: &git2::Repository, - commit: &git2::Commit<'_>, - path: Option<&str>, - ignore_whitespace: bool, -) -> Result<(usize, usize, usize), super::Error> { - let old_tree = if commit.parent_count() > 0 { - Some( - commit - .parent(0) - .map_err(super::Error::from_git)? - .tree() - .map_err(super::Error::from_git)?, - ) - } else { - None - }; - let new_tree = commit.tree().map_err(super::Error::from_git)?; - let mut options = git2::DiffOptions::new(); - if ignore_whitespace { - options.ignore_whitespace(true); - } - let mut diff = repository - .diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), Some(&mut options)) - .map_err(super::Error::from_git)?; - diff.find_similar(None).map_err(super::Error::from_git)?; - let Some(path) = path else { - let stats = diff.stats().map_err(super::Error::from_git)?; - return Ok((stats.files_changed(), stats.insertions(), stats.deletions())); - }; - let mut files = 0; - let mut additions = 0; - let mut deletions = 0; - for (index, delta) in diff.deltas().enumerate() { - if delta.old_file().path_bytes() != Some(path.as_bytes()) - && delta.new_file().path_bytes() != Some(path.as_bytes()) - { - continue; - } - files += 1; - if let Some(patch) = git2::Patch::from_diff(&diff, index).map_err(super::Error::from_git)? { - let (_, added, deleted) = patch.line_stats().map_err(super::Error::from_git)?; - additions += added; - deletions += deleted; - } - } - Ok((files, additions, deletions)) -} - -fn previous_path( - repository: &git2::Repository, - commit: &git2::Commit<'_>, - path: Option<String>, -) -> Result<Option<String>, super::Error> { - let Some(path) = path else { return Ok(None) }; - if commit.parent_count() == 0 { - return Ok(Some(path)); - } - let parent = commit.parent(0).map_err(super::Error::from_git)?; - let old_tree = parent.tree().map_err(super::Error::from_git)?; - let new_tree = commit.tree().map_err(super::Error::from_git)?; - let mut diff = repository - .diff_tree_to_tree(Some(&old_tree), Some(&new_tree), None) - .map_err(super::Error::from_git)?; - diff.find_similar(None).map_err(super::Error::from_git)?; - for delta in diff.deltas() { - if delta.status() == git2::Delta::Renamed - && delta.new_file().path_bytes() == Some(path.as_bytes()) - { - return Ok(delta - .old_file() - .path_bytes() - .map(|path| String::from_utf8_lossy(path).into_owned())); - } - } - Ok(Some(path)) -} - -fn identity(signature: git2::Signature<'_>) -> super::commit::Identity { - super::commit::Identity { - name: signature - .name() - .map(str::to_owned) - .unwrap_or_else(|_| String::from_utf8_lossy(signature.name_bytes()).into_owned()), - email: signature - .email() - .map(str::to_owned) - .unwrap_or_else(|_| String::from_utf8_lossy(signature.email_bytes()).into_owned()), - timestamp: signature.when().seconds(), - offset_minutes: signature.when().offset_minutes(), - } -} - -#[cfg(test)] -mod tests { - #[test] - fn parses_control_delimited_graph_output() { - let rows = super::parse(b"* \x1e0123456789abcdef0123456789abcdef01234567\x1f\n|\\ \n| * \x1e89abcdef0123456789abcdef0123456789abcdef\x1f\n").unwrap(); - assert_eq!(rows.len(), 2); - assert_eq!(rows[0].graph, "* "); - assert_eq!(rows[1].graph, "| * "); - assert_eq!(rows[1].graph_continuations, ["|\\ "]); - } - - #[test] - fn parses_git_numstat_records_and_binary_files() { - let oid = "0123456789abcdef0123456789abcdef01234567"; - let other = "89abcdef0123456789abcdef0123456789abcdef"; - let output = format!( - "GILTI-STATS\0{oid}\0\0\n1\t2\tfile.txt\0-\t-\tbinary\0GILTI-STATS\0{other}\0\0\n0\t0\t\0old\0new\0" - ); - let statistics = super::parse_statistics(output.as_bytes()).unwrap(); - assert_eq!(statistics[oid], (2, 1, 2)); - assert_eq!(statistics[other], (1, 0, 0)); - } -} diff --git a/crates/gilti/src/models/mod.rs b/crates/gilti/src/models/mod.rs deleted file mode 100644 --- a/crates/gilti/src/models/mod.rs +++ /dev/null @@ -1,143 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -pub mod about; -pub mod archive; -pub mod archive_signature; -pub mod blame; -pub mod commit; -pub mod diff; -pub mod history; -pub mod object; -pub mod overview; -pub mod patch; -pub mod refs; -pub mod repositories; -pub mod repository; -pub mod revision; -pub mod stats; -pub mod tag; -pub mod time; -pub mod tree; - -#[derive(Debug)] -pub enum Error { - NotFound, - Internal(String), -} - -impl Error { - pub fn from_git(error: git2::Error) -> Self { - if matches!( - error.code(), - git2::ErrorCode::NotFound | git2::ErrorCode::UnbornBranch - ) { - Self::NotFound - } else { - Self::Internal(error.message().to_owned()) - } - } -} - -#[cfg(test)] -mod migration_tests { - #[test] - fn loads_diff_commit_stats_archive_and_patch_models() { - let root = - std::env::temp_dir().join(format!("gilti-five-model-test-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&root); - std::fs::create_dir_all(&root).unwrap(); - let repository = git2::Repository::init_bare(root.join("example.git")).unwrap(); - let signature = git2::Signature::now("Model Tester", "model@example.test").unwrap(); - let first_blob = repository.blob(b"first\n").unwrap(); - let mut builder = repository.treebuilder(None).unwrap(); - builder.insert("file.txt", first_blob, 0o100644).unwrap(); - let first_tree_oid = builder.write().unwrap(); - drop(builder); - let first_tree = repository.find_tree(first_tree_oid).unwrap(); - let first = repository - .commit( - Some("refs/heads/main"), - &signature, - &signature, - "first", - &first_tree, - &[], - ) - .unwrap(); - let first = repository.find_commit(first).unwrap(); - let first_oid = first.id(); - let second_blob = repository.blob(b"second\n").unwrap(); - let mut builder = repository.treebuilder(None).unwrap(); - builder.insert("file.txt", second_blob, 0o100644).unwrap(); - let second_tree_oid = builder.write().unwrap(); - drop(builder); - let second_tree = repository.find_tree(second_tree_oid).unwrap(); - let second = repository - .commit( - Some("refs/heads/main"), - &signature, - &signature, - "second\n\nbody", - &second_tree, - &[&first], - ) - .unwrap(); - repository.set_head("refs/heads/main").unwrap(); - drop(first); - drop(first_tree); - drop(second_tree); - drop(repository); - - let old = crate::router::Revision::Commit(first_oid.to_string()); - let new = crate::router::Revision::Commit(second.to_string()); - let options = super::diff::Options { - context: 3, - ignore_whitespace: false, - }; - let diff = super::diff::Diff::load( - &root, - "example", - Some(old.clone()), - new.clone(), - None, - options, - ) - .unwrap(); - assert_eq!(diff.files.len(), 1); - assert_eq!((diff.additions, diff.deletions), (1, 1)); - let commit = super::commit::Commit::load(&root, "example", new.clone(), options).unwrap(); - assert_eq!(commit.subject, "second"); - assert_eq!(commit.parents, [first_oid.to_string()]); - assert!(commit.diff.is_some()); - let stats = - super::stats::Stats::load(&root, "example", super::stats::Period::Week).unwrap(); - assert_eq!(stats.totals.iter().sum::<usize>(), 2); - let archive = - super::archive::Archive::load(&root, "example", &new, Some("file.txt")).unwrap(); - assert_eq!(archive.oid, second.to_string()); - let patch = super::patch::Patch::load(&root, "example", &old, &new).unwrap(); - assert_eq!(patch.old_oid, first_oid.to_string()); - assert_eq!(patch.new_oid, second.to_string()); - let history = super::history::History::load( - &root, - "example", - new, - super::history::Options { - path: Some("file.txt".to_owned()), - follow: true, - search: super::history::Search::Grep("second".to_owned()), - offset: 0, - limit: 1, - graph: true, - ignore_whitespace: false, - include_statistics: true, - }, - ) - .unwrap(); - assert_eq!(history.entries.len(), 1); - assert_eq!(history.entries[0].oid, second.to_string()); - assert!(!history.graph); - std::fs::remove_dir_all(root).unwrap(); - } -} diff --git a/crates/gilti/src/models/object.rs b/crates/gilti/src/models/object.rs deleted file mode 100644 --- a/crates/gilti/src/models/object.rs +++ /dev/null @@ -1,87 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -use std::path::Path; - -pub struct RawObject { - pub bytes: Vec<u8>, - pub binary: bool, -} - -impl RawObject { - pub fn load(root: &Path, repository: &str, oid: &str) -> Result<Self, super::Error> { - let repository = super::repository::open(root, repository)?; - let oid = git2::Oid::from_str_ext(oid, repository.object_format()) - .map_err(|_| super::Error::NotFound)?; - let odb = repository.odb().map_err(super::Error::from_git)?; - let object = odb.read(oid).map_err(super::Error::from_git)?; - let bytes = object.data().to_vec(); - let binary = bytes.iter().take(8000).any(|byte| *byte == 0); - Ok(Self { bytes, binary }) - } -} - -#[cfg(test)] -mod tests { - fn fixture() -> (std::path::PathBuf, git2::Repository) { - let root = std::env::temp_dir().join(format!( - "gilti-object-test-{}-{}", - std::process::id(), - std::thread::current().name().unwrap_or("thread") - )); - let _ = std::fs::remove_dir_all(&root); - std::fs::create_dir_all(&root).unwrap(); - let repository = git2::Repository::init_bare(root.join("example.git")).unwrap(); - (root, repository) - } - - #[test] - fn reads_raw_text_and_binary_objects_like_browser_blob() { - let (root, repository) = fixture(); - let odb = repository.odb().unwrap(); - let text_oid = odb.write(git2::ObjectType::Blob, b"hello\n").unwrap(); - let binary_oid = odb - .write(git2::ObjectType::Blob, b"binary\0payload") - .unwrap(); - - let text = super::RawObject::load(&root, "example", &text_oid.to_string()).unwrap(); - assert_eq!(text.bytes, b"hello\n"); - assert!(!text.binary); - let binary = super::RawObject::load(&root, "example", &binary_oid.to_string()).unwrap(); - assert_eq!(binary.bytes, b"binary\0payload"); - assert!(binary.binary); - - std::fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn reads_sha256_objects_with_the_repository_format() { - let root = - std::env::temp_dir().join(format!("gilti-object-sha256-test-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&root); - std::fs::create_dir_all(&root).unwrap(); - let mut options = git2::RepositoryInitOptions::new(); - options.bare(true).object_format(git2::ObjectFormat::Sha256); - let repository = git2::Repository::init_opts(root.join("example.git"), &options).unwrap(); - let oid = repository - .odb() - .unwrap() - .write(git2::ObjectType::Blob, b"sha256\n") - .unwrap(); - assert_eq!(oid.to_string().len(), 64); - - let object = super::RawObject::load(&root, "example", &oid.to_string()).unwrap(); - assert_eq!(object.bytes, b"sha256\n"); - std::fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn missing_object_is_not_found() { - let (root, _repository) = fixture(); - assert!(matches!( - super::RawObject::load(&root, "example", "0000000000000000000000000000000000000000"), - Err(crate::models::Error::NotFound) - )); - std::fs::remove_dir_all(root).unwrap(); - } -} diff --git a/crates/gilti/src/models/overview.rs b/crates/gilti/src/models/overview.rs deleted file mode 100644 --- a/crates/gilti/src/models/overview.rs +++ /dev/null @@ -1,122 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -use std::collections::HashMap; -use std::path::Path; - -pub struct Overview { - pub repository: super::repository::Info, - pub empty: bool, - pub branches: Vec<super::refs::Branch>, - pub tags: Vec<super::refs::Tag>, - pub commits: Vec<Commit>, -} - -pub struct Commit { - pub oid: String, - pub subject: String, - pub author: String, - pub timestamp: i64, - pub files: usize, - pub insertions: usize, - pub deletions: usize, - pub decorations: Vec<Decoration>, -} - -pub struct Decoration { - pub label: String, - pub reference: Option<String>, - pub tag: bool, -} - -impl Overview { - pub fn load(root: &Path, name: &str) -> Result<Self, super::Error> { - let repository = super::repository::open(root, name)?; - let info = super::repository::info(&repository, name); - let head = match repository.head().and_then(|head| head.peel_to_commit()) { - Ok(head) => head, - Err(error) if error.code() == git2::ErrorCode::UnbornBranch => { - return Ok(Self { - repository: info, - empty: true, - branches: Vec::new(), - tags: Vec::new(), - commits: Vec::new(), - }); - } - Err(error) => return Err(super::Error::from_git(error)), - }; - let refs = super::refs::Refs::load(root, name)?; - let mut decorations = HashMap::<git2::Oid, Vec<Decoration>>::new(); - decorations.entry(head.id()).or_default().push(Decoration { - label: "HEAD".to_owned(), - reference: None, - tag: false, - }); - for branch in &refs.branches { - if let Ok(oid) = repository.refname_to_id(&branch.reference) { - decorations.entry(oid).or_default().push(Decoration { - label: branch.name.clone(), - reference: Some(branch.reference.clone()), - tag: false, - }); - } - } - for tag in &refs.tags { - if let Ok(reference) = repository.find_reference(&tag.reference) - && let Ok(commit) = reference.peel_to_commit() - { - decorations - .entry(commit.id()) - .or_default() - .push(Decoration { - label: tag.name.clone(), - reference: Some(tag.reference.clone()), - tag: true, - }); - } - } - let mut walk = repository.revwalk().map_err(super::Error::from_git)?; - walk.push(head.id()).map_err(super::Error::from_git)?; - walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME) - .map_err(super::Error::from_git)?; - let mut commits = Vec::new(); - for oid in walk.take(10) { - let oid = oid.map_err(super::Error::from_git)?; - let commit = repository - .find_commit(oid) - .map_err(super::Error::from_git)?; - let tree = commit.tree().map_err(super::Error::from_git)?; - let parent_tree = commit.parent(0).ok().and_then(|parent| parent.tree().ok()); - let diff = repository - .diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), None) - .map_err(super::Error::from_git)?; - let stats = diff.stats().map_err(super::Error::from_git)?; - commits.push(Commit { - oid: oid.to_string(), - subject: String::from_utf8_lossy(commit.summary_bytes().unwrap_or_default()) - .into_owned(), - author: signature_name(&commit.author()), - timestamp: commit.time().seconds(), - files: stats.files_changed(), - insertions: stats.insertions(), - deletions: stats.deletions(), - decorations: decorations.remove(&oid).unwrap_or_default(), - }); - } - Ok(Self { - repository: info, - empty: false, - branches: refs.branches.into_iter().take(10).collect(), - tags: refs.tags.into_iter().take(10).collect(), - commits, - }) - } -} - -fn signature_name(signature: &git2::Signature<'_>) -> String { - signature - .name() - .map(str::to_owned) - .unwrap_or_else(|_| String::from_utf8_lossy(signature.name_bytes()).into_owned()) -} diff --git a/crates/gilti/src/models/patch.rs b/crates/gilti/src/models/patch.rs deleted file mode 100644 --- a/crates/gilti/src/models/patch.rs +++ /dev/null @@ -1,28 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -use std::path::{Path, PathBuf}; - -pub struct Patch { - pub repository_path: PathBuf, - pub old_oid: String, - pub new_oid: String, -} - -impl Patch { - pub fn load( - root: &Path, - name: &str, - old: &crate::router::Revision, - new: &crate::router::Revision, - ) -> Result<Self, super::Error> { - let repository = super::repository::open(root, name)?; - let old = super::revision::commit(&repository, old)?; - let new = super::revision::commit(&repository, new)?; - Ok(Self { - repository_path: repository.path().to_owned(), - old_oid: old.id().to_string(), - new_oid: new.id().to_string(), - }) - } -} diff --git a/crates/gilti/src/models/refs.rs b/crates/gilti/src/models/refs.rs deleted file mode 100644 --- a/crates/gilti/src/models/refs.rs +++ /dev/null @@ -1,196 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -use std::path::Path; - -pub struct Refs { - pub repository: super::repository::Info, - pub branches: Vec<Branch>, - pub tags: Vec<Tag>, -} - -pub struct Branch { - pub name: String, - pub reference: String, - pub subject: String, - pub author: String, - pub timestamp: i64, -} - -pub struct Tag { - pub name: String, - pub reference: String, - pub target: String, - pub downloadable: bool, - pub author: String, - pub timestamp: i64, -} - -impl Refs { - pub fn load(root: &Path, name: &str) -> Result<Self, super::Error> { - let repository = super::repository::open(root, name)?; - repository - .head() - .and_then(|head| head.peel_to_commit()) - .map_err(super::Error::from_git)?; - let info = super::repository::info(&repository, name); - let mut branches = Vec::new(); - let references = repository.references().map_err(super::Error::from_git)?; - for reference in references { - let reference = reference.map_err(super::Error::from_git)?; - let Ok(reference_name) = reference.name() else { - continue; - }; - if !reference_name.starts_with("refs/heads/") { - continue; - } - let commit = match reference.peel_to_commit() { - Ok(commit) => commit, - Err(_) => continue, - }; - branches.push(Branch { - name: reference_name["refs/heads/".len()..].to_owned(), - reference: reference_name.to_owned(), - subject: first_line(commit.message_bytes()), - author: signature_name(&commit.author()), - timestamp: commit.time().seconds(), - }); - } - branches.sort_by(|left, right| left.reference.cmp(&right.reference)); - - let mut tags = Vec::new(); - let references = repository.references().map_err(super::Error::from_git)?; - for reference in references { - let reference = reference.map_err(super::Error::from_git)?; - let Ok(reference_name) = reference.name() else { - continue; - }; - if !reference_name.starts_with("refs/tags/") { - continue; - } - let object = match reference - .resolve() - .and_then(|reference| { - reference - .target() - .ok_or_else(|| git2::Error::from_str("reference has no target")) - }) - .and_then(|oid| repository.find_object(oid, None)) - { - Ok(object) => object, - Err(_) => continue, - }; - let (author, timestamp) = if let Some(tag) = object.as_tag() { - let tagger = tag.tagger(); - ( - tagger.as_ref().map_or_else(String::new, signature_name), - tagger.as_ref().map_or(0, |tagger| tagger.when().seconds()), - ) - } else if let Some(commit) = object.as_commit() { - (signature_name(&commit.author()), commit.time().seconds()) - } else { - (String::new(), 0) - }; - let target = object - .as_tag() - .map_or_else(|| object.id(), git2::Tag::target_id); - let downloadable = object.peel_to_commit().is_ok(); - tags.push(Tag { - name: reference_name["refs/tags/".len()..].to_owned(), - reference: reference_name.to_owned(), - target: target.to_string(), - downloadable, - author, - timestamp, - }); - } - tags.sort_by(|left, right| { - right - .timestamp - .cmp(&left.timestamp) - .then_with(|| left.reference.cmp(&right.reference)) - }); - Ok(Self { - repository: info, - branches, - tags, - }) - } -} - -fn signature_name(signature: &git2::Signature<'_>) -> String { - signature - .name() - .map(str::to_owned) - .unwrap_or_else(|_| String::from_utf8_lossy(signature.name_bytes()).into_owned()) -} - -fn first_line(message: &[u8]) -> String { - String::from_utf8_lossy( - message - .split(|byte| *byte == b'\n') - .next() - .unwrap_or_default(), - ) - .into_owned() -} - -#[cfg(test)] -mod tests { - #[test] - fn loads_local_branches_and_annotated_tags() { - let root = std::env::temp_dir().join(format!("gilti-refs-test-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&root); - std::fs::create_dir_all(&root).unwrap(); - let repository = git2::Repository::init_bare(root.join("example.git")).unwrap(); - let signature = git2::Signature::new( - "Example Author", - "author@example.test", - &git2::Time::new(1_700_000_000, 0), - ) - .unwrap(); - let tree_oid = repository.treebuilder(None).unwrap().write().unwrap(); - let tree = repository.find_tree(tree_oid).unwrap(); - let commit_oid = repository - .commit( - Some("refs/heads/main"), - &signature, - &signature, - "Subject line\n\nBody", - &tree, - &[], - ) - .unwrap(); - repository.set_head("refs/heads/main").unwrap(); - repository - .reference("refs/heads/feature/x", commit_oid, false, "test") - .unwrap(); - let commit = repository.find_object(commit_oid, None).unwrap(); - repository - .tag("v1.0", &commit, &signature, "Release", false) - .unwrap(); - drop(commit); - drop(tree); - drop(repository); - - let refs = super::Refs::load(&root, "example").unwrap(); - assert_eq!( - refs.branches - .iter() - .map(|branch| branch.name.as_str()) - .collect::<Vec<_>>(), - ["feature/x", "main"] - ); - assert!( - refs.branches - .iter() - .all(|branch| branch.subject == "Subject line") - ); - assert_eq!(refs.tags.len(), 1); - assert_eq!(refs.tags[0].name, "v1.0"); - assert_eq!(refs.tags[0].author, "Example Author"); - assert!(refs.tags[0].downloadable); - - std::fs::remove_dir_all(root).unwrap(); - } -} diff --git a/crates/gilti/src/models/repositories.rs b/crates/gilti/src/models/repositories.rs deleted file mode 100644 --- a/crates/gilti/src/models/repositories.rs +++ /dev/null @@ -1,181 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -use std::path::Path; - -pub struct Repositories { - pub rows: Vec<Repository>, - pub offset: usize, - pub has_next: bool, -} - -pub struct Repository { - pub name: String, - pub description: String, - pub timestamp: Option<i64>, - pub populated: bool, -} - -#[derive(Clone, Copy)] -pub enum Sort { - Name, - Description, - Owner, - Idle, -} - -pub struct Filter { - pub search: Option<String>, - pub sort: Sort, - pub offset: usize, - pub limit: usize, -} - -impl Repositories { - pub fn load(root: &Path, filter: Filter) -> Result<Self, super::Error> { - let mut rows = Vec::new(); - scan(root, root, &mut rows)?; - if let Some(search) = filter.search.as_deref() { - let search = search.to_lowercase(); - rows.retain(|repository| { - repository.name.to_lowercase().contains(&search) - || repository.description.to_lowercase().contains(&search) - }); - } - if rows.is_empty() { - return Err(super::Error::NotFound); - } - match filter.sort { - Sort::Name | Sort::Owner => rows.sort_by(|left, right| left.name.cmp(&right.name)), - Sort::Description => rows.sort_by(|left, right| { - left.description - .cmp(&right.description) - .then_with(|| left.name.cmp(&right.name)) - }), - Sort::Idle => rows.sort_by(|left, right| { - right - .timestamp - .cmp(&left.timestamp) - .then_with(|| left.name.cmp(&right.name)) - }), - } - let has_next = rows.len() > filter.offset.saturating_add(filter.limit); - let rows = rows - .into_iter() - .skip(filter.offset) - .take(filter.limit) - .collect(); - Ok(Self { - rows, - offset: filter.offset, - has_next, - }) - } -} - -fn scan(root: &Path, directory: &Path, rows: &mut Vec<Repository>) -> Result<(), super::Error> { - let entries = - std::fs::read_dir(directory).map_err(|error| super::Error::Internal(error.to_string()))?; - for entry in entries { - let entry = entry.map_err(|error| super::Error::Internal(error.to_string()))?; - let file_type = entry - .file_type() - .map_err(|error| super::Error::Internal(error.to_string()))?; - if !file_type.is_dir() { - continue; - } - let file_name = entry.file_name(); - let Some(file_name) = file_name.to_str() else { - continue; - }; - if file_name.starts_with('.') { - continue; - } - let path = entry.path(); - if let Some(name) = repository_name(root, &path) { - if let Some(repository) = load(&path, name) { - rows.push(repository); - } - } else { - scan(root, &path, rows)?; - } - } - Ok(()) -} - -fn repository_name<'a>(root: &Path, path: &'a Path) -> Option<&'a str> { - let relative = path.strip_prefix(root).ok()?.to_str()?; - relative.strip_suffix(".git") -} - -fn load(path: &Path, name: &str) -> Option<Repository> { - let repository = git2::Repository::open_bare(path).ok()?; - let description = std::fs::read_to_string(path.join("description")) - .ok() - .map(|description| description.trim().to_owned()) - .filter(|description| !description.is_empty()) - .unwrap_or_else(|| "[no description]".to_owned()); - let commit = repository - .head() - .and_then(|head| head.peel_to_commit()) - .ok(); - Some(Repository { - name: name.to_owned(), - description, - timestamp: commit.as_ref().map(|commit| commit.time().seconds()), - populated: commit.is_some(), - }) -} - -#[cfg(test)] -mod tests { - fn filter(search: Option<&str>) -> super::Filter { - super::Filter { - search: search.map(str::to_owned), - sort: super::Sort::Name, - offset: 0, - limit: 50, - } - } - - #[test] - fn scans_nested_and_terminal_dot_git_names_and_filters() { - let root = - std::env::temp_dir().join(format!("gilti-repositories-test-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&root); - std::fs::create_dir_all(root.join("group")).unwrap(); - git2::Repository::init_bare(root.join("group/project.git")).unwrap(); - git2::Repository::init_bare(root.join("group/dotted.git.git")).unwrap(); - git2::Repository::init_bare(root.join("empty.git")).unwrap(); - std::fs::write( - root.join("group/project.git/description"), - "Project description\n", - ) - .unwrap(); - - let repositories = super::Repositories::load(&root, filter(None)).unwrap(); - assert_eq!( - repositories - .rows - .iter() - .map(|repository| repository.name.as_str()) - .collect::<Vec<_>>(), - ["empty", "group/dotted.git", "group/project"] - ); - assert!( - repositories - .rows - .iter() - .all(|repository| !repository.populated) - ); - let repositories = - super::Repositories::load(&root, filter(Some("Project description"))).unwrap(); - assert_eq!(repositories.rows.len(), 1); - assert_eq!(repositories.rows[0].name, "group/project"); - assert!(matches!( - super::Repositories::load(&root, filter(Some("missing"))), - Err(crate::models::Error::NotFound) - )); - std::fs::remove_dir_all(root).unwrap(); - } -} diff --git a/crates/gilti/src/models/repository.rs b/crates/gilti/src/models/repository.rs deleted file mode 100644 --- a/crates/gilti/src/models/repository.rs +++ /dev/null @@ -1,93 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -use std::path::{Path, PathBuf}; - -pub struct Info { - pub name: String, - pub description: String, - pub has_readme: bool, -} - -pub fn info(repository: &git2::Repository, name: &str) -> Info { - let description = std::fs::read_to_string(repository.path().join("description")) - .ok() - .map(|description| description.trim().to_owned()) - .filter(|description| !description.is_empty()) - .unwrap_or_else(|| "[no description]".to_owned()); - let has_readme = repository - .head() - .and_then(|head| head.peel_to_tree()) - .ok() - .is_some_and(|tree| { - ["README.md", "README"].iter().any(|path| { - tree.get_path(Path::new(path)) - .is_ok_and(|entry| entry.kind() == Some(git2::ObjectType::Blob)) - }) - }); - Info { - name: name.to_owned(), - description, - has_readme, - } -} - -pub fn open(root: &Path, name: &str) -> Result<git2::Repository, super::Error> { - git2::Repository::open_bare(path(root, name)?).map_err(super::Error::from_git) -} - -pub fn path(root: &Path, name: &str) -> Result<PathBuf, super::Error> { - let root = root - .canonicalize() - .map_err(|error| super::Error::Internal(error.to_string()))?; - let path = repository_path(&root, name) - .canonicalize() - .map_err(|error| { - if error.kind() == std::io::ErrorKind::NotFound { - super::Error::NotFound - } else { - super::Error::Internal(error.to_string()) - } - })?; - if !path.starts_with(&root) || !path.is_dir() { - return Err(super::Error::NotFound); - } - Ok(path) -} - -fn repository_path(root: &Path, name: &str) -> PathBuf { - let (parent, leaf) = name.rsplit_once('/').unwrap_or(("", name)); - root.join(parent).join(format!("{leaf}.git")) -} - -#[cfg(test)] -mod tests { - #[test] - fn appends_exactly_one_storage_suffix() { - let root = std::path::Path::new("/repositories"); - assert_eq!( - super::repository_path(root, "group/project"), - root.join("group/project.git") - ); - assert_eq!( - super::repository_path(root, "group/project.git"), - root.join("group/project.git.git") - ); - } - - #[test] - fn resolved_path_cannot_escape_the_repository_root() { - let parent = - std::env::temp_dir().join(format!("gilti-repository-path-test-{}", std::process::id())); - let root = parent.join("repositories"); - let outside = parent.join("outside.git"); - let _ = std::fs::remove_dir_all(&parent); - std::fs::create_dir_all(&root).unwrap(); - git2::Repository::init_bare(&outside).unwrap(); - assert!(matches!( - super::path(&root, "../outside"), - Err(crate::models::Error::NotFound) - )); - std::fs::remove_dir_all(parent).unwrap(); - } -} diff --git a/crates/gilti/src/models/revision.rs b/crates/gilti/src/models/revision.rs deleted file mode 100644 --- a/crates/gilti/src/models/revision.rs +++ /dev/null @@ -1,34 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -pub fn selector(revision: &crate::router::Revision) -> String { - match revision { - crate::router::Revision::Head => "HEAD".to_owned(), - crate::router::Revision::Ref(reference) | crate::router::Revision::Commit(reference) => { - reference.clone() - } - } -} - -pub fn commit<'repo>( - repository: &'repo git2::Repository, - revision: &crate::router::Revision, -) -> Result<git2::Commit<'repo>, super::Error> { - match revision { - crate::router::Revision::Head => repository - .head() - .and_then(|head| head.peel_to_commit()) - .map_err(|_| super::Error::NotFound), - crate::router::Revision::Ref(reference) => repository - .find_reference(reference) - .and_then(|reference| reference.peel_to_commit()) - .map_err(|_| super::Error::NotFound), - crate::router::Revision::Commit(oid) => { - let oid = git2::Oid::from_str_ext(oid, repository.object_format()) - .map_err(|_| super::Error::NotFound)?; - repository - .find_commit(oid) - .map_err(|_| super::Error::NotFound) - } - } -} diff --git a/crates/gilti/src/models/stats.rs b/crates/gilti/src/models/stats.rs deleted file mode 100644 --- a/crates/gilti/src/models/stats.rs +++ /dev/null @@ -1,187 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -use std::collections::BTreeMap; -use std::path::Path; - -#[derive(Clone, Copy, Eq, PartialEq)] -pub enum Period { - Week, - Month, - Quarter, - Year, -} - -impl Period { - pub fn name(self) -> &'static str { - match self { - Self::Week => "week", - Self::Month => "month", - Self::Quarter => "quarter", - Self::Year => "year", - } - } -} - -pub struct Stats { - pub repository: super::repository::Info, - pub labels: Vec<String>, - pub authors: Vec<Author>, - pub totals: Vec<usize>, -} - -pub struct Author { - pub name: String, - pub counts: Vec<usize>, - pub total: usize, -} - -impl Stats { - pub fn load(root: &Path, name: &str, period: Period) -> Result<Self, super::Error> { - let repository = super::repository::open(root, name)?; - let info = super::repository::info(&repository, name); - let head = super::revision::commit(&repository, &crate::router::Revision::Head)?; - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_or(0, |duration| duration.as_secs() as i64); - let labels = labels(period, now); - let positions = labels - .iter() - .enumerate() - .map(|(index, label)| (label.clone(), index)) - .collect::<BTreeMap<_, _>>(); - let mut authors = BTreeMap::<String, Vec<usize>>::new(); - let mut walk = repository.revwalk().map_err(super::Error::from_git)?; - walk.push(head.id()).map_err(super::Error::from_git)?; - walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME) - .map_err(super::Error::from_git)?; - for oid in walk { - let commit = repository - .find_commit(oid.map_err(super::Error::from_git)?) - .map_err(super::Error::from_git)?; - if commit.parent_count() > 1 { - continue; - } - let label = label(period, commit.time().seconds()); - let Some(index) = positions.get(&label).copied() else { - continue; - }; - let signature = commit.author(); - let author = signature - .name() - .map(str::to_owned) - .unwrap_or_else(|_| String::from_utf8_lossy(signature.name_bytes()).into_owned()); - authors.entry(author).or_insert_with(|| vec![0; 4])[index] += 1; - } - let mut authors = authors - .into_iter() - .map(|(name, counts)| Author { - total: counts.iter().sum(), - name, - counts, - }) - .collect::<Vec<_>>(); - authors.sort_by(|left, right| { - right - .total - .cmp(&left.total) - .then_with(|| left.name.cmp(&right.name)) - }); - let totals = (0..4) - .map(|index| authors.iter().map(|author| author.counts[index]).sum()) - .collect(); - Ok(Self { - repository: info, - labels, - authors, - totals, - }) - } -} - -fn labels(period: Period, now: i64) -> Vec<String> { - let mut time = tm(now); - truncate(period, &mut time); - let mut labels = Vec::with_capacity(4); - for _ in 0..4 { - labels.push(pretty(period, &time)); - decrement(period, &mut time); - } - labels.reverse(); - labels -} - -fn label(period: Period, timestamp: i64) -> String { - let mut time = tm(timestamp); - truncate(period, &mut time); - pretty(period, &time) -} - -fn tm(timestamp: i64) -> libc::tm { - super::time::utc(timestamp) - .unwrap_or_else(|| super::time::utc(0).expect("the Unix epoch is representable")) -} - -fn normalize(time: &mut libc::tm) { - // SAFETY: time points to an initialized libc::tm and timegm normalizes it in place. - unsafe { libc::timegm(time) }; -} - -fn truncate(period: Period, time: &mut libc::tm) { - match period { - Period::Week => { - time.tm_mday -= (time.tm_wday + 6) % 7; - } - Period::Month => time.tm_mday = 1, - Period::Quarter => { - time.tm_mday = 1; - time.tm_mon -= time.tm_mon % 3; - } - Period::Year => { - time.tm_mday = 1; - time.tm_mon = 0; - } - } - normalize(time); -} - -fn decrement(period: Period, time: &mut libc::tm) { - match period { - Period::Week => time.tm_mday -= 7, - Period::Month => time.tm_mon -= 1, - Period::Quarter => time.tm_mon -= 3, - Period::Year => time.tm_year -= 1, - } - normalize(time); -} - -fn pretty(period: Period, time: &libc::tm) -> String { - match period { - Period::Week => strftime("W%V %G", time), - Period::Month => { - const MONTHS: [&str; 12] = [ - "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", - ]; - format!("{} {}", MONTHS[time.tm_mon as usize], time.tm_year + 1900) - } - Period::Quarter => format!("Q{} {}", time.tm_mon / 3 + 1, time.tm_year + 1900), - Period::Year => (time.tm_year + 1900).to_string(), - } -} - -fn strftime(format: &str, time: &libc::tm) -> String { - let format = std::ffi::CString::new(format).expect("static format has no NUL"); - let mut buffer = [0_u8; 32]; - // SAFETY: the output buffer, format string and tm pointers are valid. - let length = unsafe { - libc::strftime( - buffer.as_mut_ptr().cast::<libc::c_char>(), - buffer.len(), - format.as_ptr(), - time, - ) - }; - std::str::from_utf8(&buffer[..length]) - .expect("strftime emits ASCII for this format") - .to_owned() -} diff --git a/crates/gilti/src/models/tag.rs b/crates/gilti/src/models/tag.rs deleted file mode 100644 --- a/crates/gilti/src/models/tag.rs +++ /dev/null @@ -1,112 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -use std::path::Path; - -pub struct Tag { - pub repository: super::repository::Info, - pub reference: String, - pub name: String, - pub oid: String, - pub targets: Vec<Target>, - pub annotated: bool, - pub tagger: String, - pub tagger_email: String, - pub timestamp: Option<i64>, - pub message: String, - pub downloadable: bool, -} - -pub struct Target { - pub oid: String, - pub commit: bool, -} - -impl Tag { - pub fn load(root: &Path, name: &str, reference: String) -> Result<Self, super::Error> { - if !reference.starts_with("refs/tags/") { - return Err(super::Error::NotFound); - } - let repository = super::repository::open(root, name)?; - let info = super::repository::info(&repository, name); - let git_reference = repository - .find_reference(&reference) - .map_err(super::Error::from_git)?; - let oid = git_reference - .resolve() - .map_err(super::Error::from_git)? - .target() - .ok_or(super::Error::NotFound)?; - let object = repository - .find_object(oid, None) - .map_err(super::Error::from_git)?; - let downloadable = object.peel_to_commit().is_ok(); - let (targets, annotated, tagger, tagger_email, timestamp, message) = - if let Some(tag) = object.as_tag() { - let tagger = tag.tagger(); - ( - targets(tag)?, - true, - tagger.as_ref().map_or_else(String::new, signature_name), - tagger.as_ref().map_or_else(String::new, signature_email), - tagger.as_ref().map(|tagger| tagger.when().seconds()), - String::from_utf8_lossy(tag.message_bytes().unwrap_or_default()).into_owned(), - ) - } else { - ( - vec![Target { - oid: object.id().to_string(), - commit: object.kind() == Some(git2::ObjectType::Commit), - }], - false, - String::new(), - String::new(), - None, - String::new(), - ) - }; - Ok(Self { - repository: info, - reference: reference.clone(), - name: reference["refs/tags/".len()..].to_owned(), - oid: oid.to_string(), - targets, - annotated, - tagger, - tagger_email, - timestamp, - message, - downloadable, - }) - } -} - -fn targets(tag: &git2::Tag<'_>) -> Result<Vec<Target>, super::Error> { - let mut targets = Vec::new(); - let mut object = tag.target().map_err(super::Error::from_git)?; - loop { - targets.push(Target { - oid: object.id().to_string(), - commit: object.kind() == Some(git2::ObjectType::Commit), - }); - let Some(tag) = object.as_tag() else { - break; - }; - object = tag.target().map_err(super::Error::from_git)?; - } - Ok(targets) -} - -fn signature_name(signature: &git2::Signature<'_>) -> String { - signature - .name() - .map(str::to_owned) - .unwrap_or_else(|_| String::from_utf8_lossy(signature.name_bytes()).into_owned()) -} - -fn signature_email(signature: &git2::Signature<'_>) -> String { - signature - .email() - .map(str::to_owned) - .unwrap_or_else(|_| String::from_utf8_lossy(signature.email_bytes()).into_owned()) -} diff --git a/crates/gilti/src/models/time.rs b/crates/gilti/src/models/time.rs deleted file mode 100644 --- a/crates/gilti/src/models/time.rs +++ /dev/null @@ -1,10 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -pub fn utc(timestamp: i64) -> Option<libc::tm> { - let mut value = std::mem::MaybeUninit::<libc::tm>::uninit(); - // SAFETY: both pointers are valid for the call; the result is read only when - // gmtime_r reports success and has initialized it. - let result = unsafe { libc::gmtime_r(×tamp, value.as_mut_ptr()) }; - (!result.is_null()).then(|| unsafe { value.assume_init() }) -} diff --git a/crates/gilti/src/models/tree.rs b/crates/gilti/src/models/tree.rs deleted file mode 100644 --- a/crates/gilti/src/models/tree.rs +++ /dev/null @@ -1,250 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -use std::path::Path; - -pub struct Tree { - pub repository: super::repository::Info, - pub revision: String, - pub path: Option<String>, - pub content: Content, -} - -pub enum Content { - Directory { - oid: String, - entries: Vec<Entry>, - }, - Blob { - oid: String, - bytes: Vec<u8>, - binary: bool, - }, -} - -pub struct Entry { - pub mode: u32, - pub name: String, - pub path: String, - pub oid: String, - pub kind: Kind, - pub size: usize, - pub symlink_target: Option<String>, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum Kind { - Tree, - Blob, - Submodule, -} - -impl Tree { - pub fn load( - root: &Path, - name: &str, - revision: crate::router::Revision, - path: Option<String>, - ) -> Result<Self, super::Error> { - let repository = super::repository::open(root, name)?; - let info = super::repository::info(&repository, name); - let selector = super::revision::selector(&revision); - let commit = super::revision::commit(&repository, &revision)?; - let root_tree = commit.tree().map_err(super::Error::from_git)?; - let content = match path.as_deref() { - None => directory(&repository, &root_tree, "")?, - Some(path) => { - let entry = root_tree - .get_path(Path::new(path)) - .map_err(super::Error::from_git)?; - match entry.kind() { - Some(git2::ObjectType::Tree) => { - let tree = repository - .find_tree(entry.id()) - .map_err(super::Error::from_git)?; - directory(&repository, &tree, path)? - } - Some(git2::ObjectType::Blob) => { - let blob = repository - .find_blob(entry.id()) - .map_err(super::Error::from_git)?; - let bytes = blob.content().to_vec(); - let binary = bytes.iter().take(8000).any(|byte| *byte == 0); - Content::Blob { - oid: entry.id().to_string(), - bytes, - binary, - } - } - _ => return Err(super::Error::NotFound), - } - } - }; - Ok(Self { - repository: info, - revision: selector, - path, - content, - }) - } -} - -fn directory( - repository: &git2::Repository, - tree: &git2::Tree<'_>, - base: &str, -) -> Result<Content, super::Error> { - let odb = repository.odb().map_err(super::Error::from_git)?; - let mut entries = Vec::with_capacity(tree.len()); - for entry in tree { - let name = String::from_utf8_lossy(entry.name_bytes()).into_owned(); - let path = if base.is_empty() { - name.clone() - } else { - format!("{base}/{name}") - }; - let kind = match entry.kind() { - Some(git2::ObjectType::Tree) => Kind::Tree, - Some(git2::ObjectType::Commit) => Kind::Submodule, - Some(git2::ObjectType::Blob) => Kind::Blob, - _ => continue, - }; - let size = if kind == Kind::Submodule { - 0 - } else { - odb.read_header(entry.id()) - .map_err(super::Error::from_git)? - .0 - }; - let symlink_target = if entry.filemode_raw() == 0o120000 { - repository - .find_blob(entry.id()) - .ok() - .map(|blob| String::from_utf8_lossy(blob.content()).into_owned()) - } else { - None - }; - entries.push(Entry { - mode: entry.filemode_raw() as u32, - name, - path, - oid: entry.id().to_string(), - kind, - size, - symlink_target, - }); - } - Ok(Content::Directory { - oid: tree.id().to_string(), - entries, - }) -} - -#[cfg(test)] -mod tests { - #[test] - fn loads_directories_blobs_modes_and_symlinks() { - let root = std::env::temp_dir().join(format!("gilti-tree-test-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&root); - std::fs::create_dir_all(&root).unwrap(); - let repository = git2::Repository::init_bare(root.join("example.git")).unwrap(); - let odb = repository.odb().unwrap(); - let readme = odb.write(git2::ObjectType::Blob, b"hello\n").unwrap(); - let executable = odb.write(git2::ObjectType::Blob, b"#!/bin/sh\n").unwrap(); - let symlink = odb.write(git2::ObjectType::Blob, b"README.md").unwrap(); - let nested = odb.write(git2::ObjectType::Blob, b"nested\n").unwrap(); - let subtree = { - let mut tree = repository.treebuilder(None).unwrap(); - tree.insert("file.txt", nested, 0o100644).unwrap(); - tree.write().unwrap() - }; - let tree_oid = { - let mut tree = repository.treebuilder(None).unwrap(); - tree.insert("README.md", readme, 0o100644).unwrap(); - tree.insert("run", executable, 0o100755).unwrap(); - tree.insert("link", symlink, 0o120000).unwrap(); - tree.insert("dir", subtree, 0o040000).unwrap(); - tree.write().unwrap() - }; - let tree = repository.find_tree(tree_oid).unwrap(); - let signature = git2::Signature::now("Author", "author@example.test").unwrap(); - repository - .commit( - Some("refs/heads/main"), - &signature, - &signature, - "Tree fixture", - &tree, - &[], - ) - .unwrap(); - repository.set_head("refs/heads/main").unwrap(); - drop(tree); - drop(odb); - drop(repository); - - let root_tree = - super::Tree::load(&root, "example", crate::router::Revision::Head, None).unwrap(); - let super::Content::Directory { entries, .. } = root_tree.content else { - panic!("expected directory") - }; - assert_eq!(entries.len(), 4); - assert_eq!( - entries - .iter() - .find(|entry| entry.name == "run") - .unwrap() - .mode, - 0o100755 - ); - assert_eq!( - entries - .iter() - .find(|entry| entry.name == "link") - .unwrap() - .symlink_target - .as_deref(), - Some("README.md") - ); - assert_eq!( - entries - .iter() - .find(|entry| entry.name == "dir") - .unwrap() - .kind, - super::Kind::Tree - ); - - let blob = super::Tree::load( - &root, - "example", - crate::router::Revision::Head, - Some("dir/file.txt".to_owned()), - ) - .unwrap(); - let super::Content::Blob { bytes, binary, .. } = blob.content else { - panic!("expected blob") - }; - assert_eq!(bytes, b"nested\n"); - assert!(!binary); - assert!(matches!( - super::Tree::load( - &root, - "example", - crate::router::Revision::Head, - Some("missing".to_owned()) - ), - Err(crate::models::Error::NotFound) - )); - assert!(matches!( - super::Tree::load( - &root, - "example", - crate::router::Revision::Commit(readme.to_string()), - None - ), - Err(crate::models::Error::NotFound) - )); - std::fs::remove_dir_all(root).unwrap(); - } -} diff --git a/crates/gilti/src/router.rs b/crates/gilti/src/router.rs deleted file mode 100644 --- a/crates/gilti/src/router.rs +++ /dev/null @@ -1,867 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -/* -URL scheme - -The URL space is divided into disjoint Gilti-specific and repository-specific -namespaces. The reserved top-level segment "-" belongs to Gilti and cannot be -used as the first segment of a repository name. - -Git objects are content-addressed and immutable: a full object ID always -identifies the same object contents. Refs and repository metadata are mutable, -and unreachable objects may eventually be removed. - -Routes marked I use only immutable selectors. Routes marked M depend on the -current repository state. A composite selector inherits mutability from its -components: a tree path under a full commit OID is immutable, while the same -path under a branch or tag ref is mutable. A comparison is immutable only when -both of its revision selectors are immutable. - -Repository names, ref names, and tree paths may all contain "/". Their -boundaries are expressed using literal "/+/" segments and are never inferred -from repository state. Structural delimiters are recognized before percent -decoding. A literal data segment equal to "+" must be percent-encoded as -"%2B". - -Route parameters whose names end in "*" may contain "/". They are terminated -by the next structural "/+/" segment, a route-specific terminal marker, or the -end of the route. The terminal ".git" suffix separates a repository name from -Git HTTP transport paths. - - {repo*} canonical repository name - {ref*} ref name relative to "refs/", for example "heads/main" - {path*} path within a Git tree - {commit_id} full OID of a commit; abbreviated IDs are not accepted - {object_id} full OID of any Git object; abbreviated IDs are not accepted - {rev*} "{commit_id}", "refs/{ref*}", or "HEAD" - {asset_path*} path below the Gilti static asset root - {old_rev*} revision selector - {new_rev*} revision selector - {dumb_http_path*} path below the Git dumb HTTP "objects/" endpoint - {lfs_path*} path below the Git LFS endpoint - -Archive, diff, blob, and object formats are representation options, not route -parameters. They are selected by the "format" query parameter, then by the -Accept header, and finally by the server default. The query parameter takes -precedence when both are present. Archive signature routes use the same query -parameter to identify the signed archive format. Raw blob contents, raw Git -object contents, and raw unified diffs are representations of their respective -views rather than separate views. - -Rendered documents are an HTML view mode selected with the "view" query -parameter. Supported values are "source" and "rendered". Renderable documents -may default to "rendered"; other blobs default to "source". - -Gilti views and operational endpoints: - -/-/about <- Gilti about page -/-/terms <- Gilti terms of use -/-/assets/{asset_path*} <- Static asset -/-/health <- Health check returning {"status":"ok"} -*/ - -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum Route { - // Repository views: - /// M: Repository list - /// / - Repositories, - - /// M: Repository overview - /// /{repo*} - Overview(RepoRoute<()>), - - /// M: Repository documentation - /// /{repo*}/+/about - About(RepoRoute<()>), - - /// M: Repository activity statistics - /// /{repo*}/+/stats - Stats(RepoRoute<()>), - - /// M: Recent tags, commits, and summary data - /// /{repo*}/+/summary - Summary(RepoRoute<()>), - - // Immutable object views: - /// I: Git object - /// /{repo*}/+/object/{object_id} - Object(RepoRoute<String>), - - // Refs and revisions: - /// M: Ref list - /// /{repo*}/+/refs - Refs(RepoRoute<()>), - - /// I/M: Revision overview and resolved target - /// /{repo*}/+/{rev*} - Revision(RepoRoute<Revision>), - - /// I/M: Commit log, optionally restricted to a path - /// /{repo*}/+/{rev*}/+/log - /// /{repo*}/+/{rev*}/+/log/{path*} - Log(RepoRoute<RevisionPath>), - - /// I/M: Tree, blob, document, or submodule - /// /{repo*}/+/{rev*}/+/tree - /// /{repo*}/+/{rev*}/+/tree/{path*} - Tree(RepoRoute<RevisionPath>), - - /// I/M: File blame - /// /{repo*}/+/{rev*}/+/blame/{path*} - Blame(RepoRoute<RevisionFile>), - - /// I/M: Archive of the root tree or a subtree - /// /{repo*}/+/{rev*}/+/archive - /// /{repo*}/+/{rev*}/+/archive/{path*} - Archive(RepoRoute<RevisionPath>), - - /// I/M: Detached archive signature - /// /{repo*}/+/{rev*}/+/archive-signature - ArchiveSignature(RepoRoute<Revision>), - - // Feed & updates: - /// M: Atom commit feed, optionally restricted to a path - /// /{repo*}/+/refs/{ref*}/+/feed/atom - /// /{repo*}/+/refs/{ref*}/+/feed/atom/{path*} - AtomFeed(RepoRoute<RefPath>), - - // Comparisons and patches: - /// I/M: Diff between two revisions, optionally restricted to a path - /// /{repo*}/+/diff/{old_rev*}..{new_rev*} - /// /{repo*}/+/diff/{old_rev*}..{new_rev*}/+/{path*} - Diff(RepoRoute<Comparison>), - - /// I/M: Mail-formatted patch or patch series, optionally restricted to a path - /// /{repo*}/+/patch/{old_rev*}..{new_rev*} - /// /{repo*}/+/patch/{old_rev*}..{new_rev*}/+/{path*} - Patch(RepoRoute<Comparison>), - - // Git HTTP transport endpoints: - /// M: Public clone URL - /// /{repo*}.git - GitClone(RepoRoute<()>), - - /// M: Service discovery and ref advertisement - /// /{repo*}.git/info/refs - GitInfoRefs(RepoRoute<()>), - - /// M: Fetch protocol endpoint - /// /{repo*}.git/git-upload-pack - GitUploadPack(RepoRoute<()>), - - /// M: Push protocol endpoint, when enabled - /// /{repo*}.git/git-receive-pack - GitReceivePack(RepoRoute<()>), - - /// M: Dumb HTTP HEAD advertisement, when enabled - /// /{repo*}.git/HEAD - GitHead(RepoRoute<()>), - - /// Git dumb HTTP object endpoint, when enabled - /// /{repo*}.git/objects/{dumb_http_path*} - GitObjects(RepoRoute<String>), - - /// Git LFS endpoint, when enabled - /// /{repo*}.git/info/lfs/{lfs_path*} - GitLfs(RepoRoute<String>), -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct RepoRoute<T> { - pub repo: String, - pub params: T, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct RevisionPath { - pub rev: Revision, - pub path: Option<String>, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct RevisionFile { - pub rev: Revision, - pub path: String, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct RefPath { - pub reference: String, - pub path: Option<String>, -} - -/// A revision range separated by `..` and an optional tree path separated by `/+/`. -/// It is immutable only when both revision selectors are full commit OIDs. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct Comparison { - pub old_rev: Revision, - pub new_rev: Revision, - pub path: Option<String>, -} - -/// A revision route is immutable for a full commit OID and mutable for a ref or -/// `HEAD`. An annotated tag displays its object chain and peeled target. -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum Revision { - Head, - Ref(String), - Commit(String), -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct ParseError; - -impl std::fmt::Display for ParseError { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter.write_str("invalid repository route") - } -} - -impl std::error::Error for ParseError {} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum TokenKind { - Text, - Slash, - Boundary, - Range, - GitSuffix, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct Token<'a> { - kind: TokenKind, - raw: &'a str, -} - -/// Parses a raw, percent-encoded URI path. Query parameters are handled separately. -pub fn parse(path: &str) -> Result<Route, ParseError> { - let tokens = lex(path)?; - Parser { - tokens: &tokens, - cursor: 0, - } - .parse() -} - -fn lex(path: &str) -> Result<Vec<Token<'_>>, ParseError> { - let bytes = path.as_bytes(); - let mut tokens = Vec::new(); - let (mut cursor, mut start) = (0, 0); - while cursor < bytes.len() { - if bytes[cursor] == b'%' { - if cursor + 2 >= bytes.len() - || !bytes[cursor + 1].is_ascii_hexdigit() - || !bytes[cursor + 2].is_ascii_hexdigit() - { - return Err(ParseError); - } - cursor += 3; - continue; - } - if matches!(bytes[cursor], b'?' | b'#') { - return Err(ParseError); - } - let delimiter = if bytes[cursor..].starts_with(b"/+/") { - Some((TokenKind::Boundary, 3)) - } else if bytes[cursor] == b'/' { - Some((TokenKind::Slash, 1)) - } else if bytes[cursor..].starts_with(b"..") { - Some((TokenKind::Range, 2)) - } else if bytes[cursor..].starts_with(b".git") - && (cursor + 4 == bytes.len() || bytes[cursor + 4] == b'/') - { - Some((TokenKind::GitSuffix, 4)) - } else { - None - }; - let Some((kind, width)) = delimiter else { - cursor += 1; - continue; - }; - if start < cursor { - tokens.push(Token { - kind: TokenKind::Text, - raw: &path[start..cursor], - }); - } - tokens.push(Token { - kind, - raw: &path[cursor..cursor + width], - }); - cursor += width; - start = cursor; - } - if start < cursor { - tokens.push(Token { - kind: TokenKind::Text, - raw: &path[start..cursor], - }); - } - Ok(tokens) -} - -struct Parser<'tokens, 'input> { - tokens: &'tokens [Token<'input>], - cursor: usize, -} - -impl<'tokens, 'input> Parser<'tokens, 'input> { - fn parse(mut self) -> Result<Route, ParseError> { - self.expect(TokenKind::Slash)?; - if self.end() { - return Ok(Route::Repositories); - } - let repo = decode_repo(self.take_until(&[TokenKind::Boundary, TokenKind::GitSuffix]))?; - match self.peek() { - None => Ok(Route::Overview(route(repo, ()))), - Some(TokenKind::Boundary) => { - self.cursor += 1; - self.view(repo) - } - Some(TokenKind::GitSuffix) => { - self.cursor += 1; - self.git(repo) - } - _ => Err(ParseError), - } - } - - fn view(&mut self, repo: String) -> Result<Route, ParseError> { - Ok(match self.peek_text() { - Some("about") => { - self.cursor += 1; - self.finish()?; - Route::About(route(repo, ())) - } - Some("stats") => { - self.cursor += 1; - self.finish()?; - Route::Stats(route(repo, ())) - } - Some("summary") => { - self.cursor += 1; - self.finish()?; - Route::Summary(route(repo, ())) - } - Some("refs") if self.cursor + 1 == self.tokens.len() => { - self.cursor += 1; - Route::Refs(route(repo, ())) - } - Some("object") => { - self.cursor += 1; - self.expect(TokenKind::Slash)?; - let object_id = parse_oid(self.take_text()?)?; - self.finish()?; - Route::Object(route(repo, object_id)) - } - Some("diff") | Some("patch") => { - let patch = self.take_text()? == "patch"; - self.expect(TokenKind::Slash)?; - return self.comparison(repo, patch); - } - _ => { - let rev = self.revision()?; - if self.end() { - Route::Revision(route(repo, rev)) - } else { - self.expect(TokenKind::Boundary)?; - return self.revision_view(repo, rev); - } - } - }) - } - - fn revision_view(&mut self, repo: String, rev: Revision) -> Result<Route, ParseError> { - Ok(match self.take_text()? { - "log" => Route::Log(route( - repo, - RevisionPath { - rev, - path: self.optional_path(TokenKind::Slash)?, - }, - )), - "tree" => Route::Tree(route( - repo, - RevisionPath { - rev, - path: self.optional_path(TokenKind::Slash)?, - }, - )), - "blame" => { - self.expect(TokenKind::Slash)?; - Route::Blame(route( - repo, - RevisionFile { - rev, - path: decode_path(self.rest())?, - }, - )) - } - "archive" => Route::Archive(route( - repo, - RevisionPath { - rev, - path: self.optional_path(TokenKind::Slash)?, - }, - )), - "archive-signature" => { - self.finish()?; - Route::ArchiveSignature(route(repo, rev)) - } - "feed" => { - self.expect(TokenKind::Slash)?; - self.expect_text("atom")?; - Route::AtomFeed(route( - repo, - RefPath { - reference: reference(rev)?, - path: self.optional_path(TokenKind::Slash)?, - }, - )) - } - _ => return Err(ParseError), - }) - } - - fn comparison(&mut self, repo: String, patch: bool) -> Result<Route, ParseError> { - let old_rev = self.revision()?; - self.expect(TokenKind::Range)?; - let new_rev = self.revision()?; - let comparison = route( - repo, - Comparison { - old_rev, - new_rev, - path: self.optional_path(TokenKind::Boundary)?, - }, - ); - Ok(if patch { - Route::Patch(comparison) - } else { - Route::Diff(comparison) - }) - } - - fn git(&mut self, repo: String) -> Result<Route, ParseError> { - if self.end() { - return Ok(Route::GitClone(route(repo, ()))); - } - self.expect(TokenKind::Slash)?; - Ok(match self.take_text()? { - "info" => { - self.expect(TokenKind::Slash)?; - match self.take_text()? { - "refs" => { - self.finish()?; - Route::GitInfoRefs(route(repo, ())) - } - "lfs" => { - self.expect(TokenKind::Slash)?; - Route::GitLfs(route(repo, decode_path(self.rest())?)) - } - _ => return Err(ParseError), - } - } - "git-upload-pack" => { - self.finish()?; - Route::GitUploadPack(route(repo, ())) - } - "git-receive-pack" => { - self.finish()?; - Route::GitReceivePack(route(repo, ())) - } - "HEAD" => { - self.finish()?; - Route::GitHead(route(repo, ())) - } - "objects" => { - self.expect(TokenKind::Slash)?; - Route::GitObjects(route(repo, decode_path(self.rest())?)) - } - _ => return Err(ParseError), - }) - } - - fn revision(&mut self) -> Result<Revision, ParseError> { - parse_revision(self.take_until(&[TokenKind::Boundary, TokenKind::Range])) - } - - fn optional_path(&mut self, separator: TokenKind) -> Result<Option<String>, ParseError> { - if self.end() { - Ok(None) - } else { - self.expect(separator)?; - decode_path(self.rest()).map(Some) - } - } - - fn take_until(&mut self, delimiters: &[TokenKind]) -> &'tokens [Token<'input>] { - let start = self.cursor; - while self.peek().is_some_and(|kind| !delimiters.contains(&kind)) { - self.cursor += 1; - } - &self.tokens[start..self.cursor] - } - - fn rest(&mut self) -> &'tokens [Token<'input>] { - let rest = &self.tokens[self.cursor..]; - self.cursor = self.tokens.len(); - rest - } - - fn peek(&self) -> Option<TokenKind> { - self.tokens.get(self.cursor).map(|token| token.kind) - } - - fn peek_text(&self) -> Option<&'input str> { - self.tokens - .get(self.cursor) - .filter(|token| token.kind == TokenKind::Text) - .map(|token| token.raw) - } - - fn take_text(&mut self) -> Result<&'input str, ParseError> { - let text = self.peek_text().ok_or(ParseError)?; - self.cursor += 1; - Ok(text) - } - - fn expect_text(&mut self, expected: &str) -> Result<(), ParseError> { - (self.take_text()? == expected) - .then_some(()) - .ok_or(ParseError) - } - - fn expect(&mut self, expected: TokenKind) -> Result<(), ParseError> { - (self.peek() == Some(expected)) - .then(|| self.cursor += 1) - .ok_or(ParseError) - } - - fn finish(&self) -> Result<(), ParseError> { - self.end().then_some(()).ok_or(ParseError) - } - - fn end(&self) -> bool { - self.cursor == self.tokens.len() - } -} - -fn parse_revision(value: &[Token<'_>]) -> Result<Revision, ParseError> { - if value.len() == 1 && value[0].kind == TokenKind::Text { - return match value[0].raw { - "HEAD" => Ok(Revision::Head), - oid => parse_oid(oid).map(Revision::Commit), - }; - } - if value.len() >= 2 && value[0].raw == "refs" && value[1].kind == TokenKind::Slash { - let reference = format!("refs/{}", decode_path(&value[2..])?); - return valid_ref(&reference) - .then_some(Revision::Ref(reference)) - .ok_or(ParseError); - } - Err(ParseError) -} - -fn parse_oid(value: &str) -> Result<String, ParseError> { - (matches!(value.len(), 40 | 64) - && value - .as_bytes() - .iter() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(byte))) - .then(|| value.to_owned()) - .ok_or(ParseError) -} - -fn valid_ref(value: &str) -> bool { - !value.ends_with('.') - && !value.contains("..") - && !value.contains("@{") - && value.split('/').all(|part| { - !part.is_empty() - && !part.starts_with('.') - && !part.ends_with(".lock") - && !part - .bytes() - .any(|byte| byte < b' ' || byte == 0x7f || b" ~^:?*[\\".contains(&byte)) - }) -} - -fn decode_repo(value: &[Token<'_>]) -> Result<String, ParseError> { - decode_name(value, true) -} - -fn decode_path(value: &[Token<'_>]) -> Result<String, ParseError> { - decode_name(value, false) -} - -fn decode_name(value: &[Token<'_>], repo: bool) -> Result<String, ParseError> { - if value.is_empty() { - return Err(ParseError); - } - for (index, component) in value - .split(|token| token.kind == TokenKind::Slash) - .enumerate() - { - let literal = |expected| component.len() == 1 && component[0].raw == expected; - if component.is_empty() - || component.iter().any(|token| { - !matches!( - token.kind, - TokenKind::Text | TokenKind::Range | TokenKind::GitSuffix - ) - }) - || literal(".") - || literal("..") - || literal("+") - || repo - && (index == 0 && literal("-") - || component - .last() - .is_some_and(|token| token.kind == TokenKind::GitSuffix)) - { - return Err(ParseError); - } - } - let mut decoded = String::new(); - for token in value { - decoded.push_str( - &percent_encoding::percent_decode_str(token.raw) - .decode_utf8() - .map_err(|_| ParseError)?, - ); - } - (!decoded.contains('\0')) - .then_some(decoded) - .ok_or(ParseError) -} - -fn reference(rev: Revision) -> Result<String, ParseError> { - match rev { - Revision::Ref(reference) => Ok(reference), - _ => Err(ParseError), - } -} - -fn route<T>(repo: String, params: T) -> RepoRoute<T> { - RepoRoute { repo, params } -} - -#[cfg(test)] -mod tests { - use super::{ - Comparison, RefPath, RepoRoute, Revision, RevisionFile, RevisionPath, Route, TokenKind, - lex, parse, - }; - - const COMMIT: &str = "0123456789abcdef0123456789abcdef01234567"; - const OTHER: &str = "89abcdef0123456789abcdef0123456789abcdef"; - - fn repository<T>(params: T) -> RepoRoute<T> { - repo("group/проект", params) - } - - fn repo<T>(name: &str, params: T) -> RepoRoute<T> { - RepoRoute { - repo: name.to_owned(), - params, - } - } - - #[test] - fn lexer_preserves_raw_tokens() { - use TokenKind::{Boundary, GitSuffix, Range, Slash, Text}; - - let tokens = lex("/repo%2Egit.git/diff/HEAD..refs/heads/main/+/src").unwrap(); - assert_eq!( - tokens - .iter() - .map(|token| (token.kind, token.raw)) - .collect::<Vec<_>>(), - [ - (Slash, "/"), - (Text, "repo%2Egit"), - (GitSuffix, ".git"), - (Slash, "/"), - (Text, "diff"), - (Slash, "/"), - (Text, "HEAD"), - (Range, ".."), - (Text, "refs"), - (Slash, "/"), - (Text, "heads"), - (Slash, "/"), - (Text, "main"), - (Boundary, "/+/"), - (Text, "src"), - ] - ); - } - - #[test] - fn parses_repository_views() { - assert_eq!(parse("/").unwrap(), Route::Repositories); - assert_eq!( - parse("/group/%D0%BF%D1%80%D0%BE%D0%B5%D0%BA%D1%82").unwrap(), - Route::Overview(repository(())) - ); - assert_eq!( - parse("/foo%2Egit/+/summary").unwrap(), - Route::Summary(repo("foo.git", ())) - ); - assert_eq!( - parse("/odd..repo").unwrap(), - Route::Overview(repo("odd..repo", ())) - ); - for (path, expected) in [ - ("about", Route::About(repository(()))), - ("stats", Route::Stats(repository(()))), - ("summary", Route::Summary(repository(()))), - ("refs", Route::Refs(repository(()))), - ] { - assert_eq!(parse(&format!("/group/проект/+/{path}")).unwrap(), expected); - } - assert_eq!( - parse(&format!("/group/проект/+/object/{COMMIT}")).unwrap(), - Route::Object(repository(COMMIT.to_owned())) - ); - } - - #[test] - fn parses_revision_views_and_escaped_paths() { - let reference = Revision::Ref("refs/heads/feature/+".to_owned()); - assert_eq!( - parse("/group/проект/+/refs/heads/feature/%2B").unwrap(), - Route::Revision(repository(reference.clone())) - ); - assert_eq!( - parse("/group/проект/+/refs/heads/feature/%2B/+/tree").unwrap(), - Route::Tree(repository(RevisionPath { - rev: reference.clone(), - path: None, - })) - ); - assert_eq!( - parse("/group/проект/+/refs/heads/feature/%2B/+/tree/src/%2B/lib.rs").unwrap(), - Route::Tree(repository(RevisionPath { - rev: reference.clone(), - path: Some("src/+/lib.rs".to_owned()), - })) - ); - assert_eq!( - parse("/group/проект/+/HEAD/+/tree/src/a..b/foo.git").unwrap(), - Route::Tree(repository(RevisionPath { - rev: Revision::Head, - path: Some("src/a..b/foo.git".to_owned()), - })) - ); - assert_eq!( - parse(&format!("/group/проект/+/{COMMIT}/+/log/src/lib.rs")).unwrap(), - Route::Log(repository(RevisionPath { - rev: Revision::Commit(COMMIT.to_owned()), - path: Some("src/lib.rs".to_owned()), - })) - ); - assert_eq!( - parse("/group/проект/+/HEAD/+/archive").unwrap(), - Route::Archive(repository(RevisionPath { - rev: Revision::Head, - path: None, - })) - ); - assert_eq!( - parse("/group/проект/+/refs/heads/main/+/blame/src/lib.rs").unwrap(), - Route::Blame(repository(RevisionFile { - rev: Revision::Ref("refs/heads/main".to_owned()), - path: "src/lib.rs".to_owned(), - })) - ); - } - - #[test] - fn parses_feeds_comparisons_and_patches() { - assert_eq!( - parse("/group/проект/+/refs/heads/main/+/feed/atom/src/lib.rs").unwrap(), - Route::AtomFeed(repository(RefPath { - reference: "refs/heads/main".to_owned(), - path: Some("src/lib.rs".to_owned()), - })) - ); - assert_eq!( - parse(&format!( - "/group/проект/+/diff/refs/heads/main..{COMMIT}/+/src/lib.rs" - )) - .unwrap(), - Route::Diff(repository(Comparison { - old_rev: Revision::Ref("refs/heads/main".to_owned()), - new_rev: Revision::Commit(COMMIT.to_owned()), - path: Some("src/lib.rs".to_owned()), - })) - ); - assert_eq!( - parse(&format!("/group/проект/+/patch/{COMMIT}..{OTHER}")).unwrap(), - Route::Patch(repository(Comparison { - old_rev: Revision::Commit(COMMIT.to_owned()), - new_rev: Revision::Commit(OTHER.to_owned()), - path: None, - })) - ); - } - - #[test] - fn parses_git_transport_routes() { - for (path, expected) in [ - ("/group/проект.git", Route::GitClone(repository(()))), - ("/foo%2Egit.git", Route::GitClone(repo("foo.git", ()))), - ( - "/group/проект.git/info/refs", - Route::GitInfoRefs(repository(())), - ), - ( - "/group/проект.git/git-upload-pack", - Route::GitUploadPack(repository(())), - ), - ( - "/group/проект.git/git-receive-pack", - Route::GitReceivePack(repository(())), - ), - ("/group/проект.git/HEAD", Route::GitHead(repository(()))), - ( - "/group/проект.git/objects/ab/cdef", - Route::GitObjects(repository("ab/cdef".to_owned())), - ), - ( - "/group/проект.git/info/lfs/objects/batch", - Route::GitLfs(repository("objects/batch".to_owned())), - ), - ] { - assert_eq!(parse(path).unwrap(), expected); - } - } - - #[test] - fn rejects_ambiguous_or_noncanonical_routes() { - for path in [ - "", - "/-/about", - "/repo/", - "/repo/+/", - "/repo/+/main", - "/repo/+/deadbeef", - "/repo/+/refs/heads/bad..ref", - "/repo/+/HEAD/+/blame", - "/repo/+/HEAD/+/feed/atom", - "/repo/+/diff/HEAD...HEAD", - "/repo/+/diff/HEAD..", - "/repo/+/tree", - "/repo.git/unknown", - "/repo/%zz", - "/repo?format=raw", - ] { - assert!(parse(path).is_err(), "accepted {path}"); - } - } -} diff --git a/crates/gilti/src/views/about.rs b/crates/gilti/src/views/about.rs deleted file mode 100644 --- a/crates/gilti/src/views/about.rs +++ /dev/null @@ -1,36 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -use maud::html; - -pub async fn serve( - context: &super::shared::Context, - route: crate::router::RepoRoute<()>, - method: axum::http::Method, -) -> axum::response::Response { - if method != axum::http::Method::GET && method != axum::http::Method::HEAD { - return super::method_not_allowed(); - } - let repositories = context.repositories; - let model = tokio::task::spawn_blocking(move || { - crate::models::about::About::load(std::path::Path::new(repositories), &route.repo) - }) - .await; - let model = match model { - Ok(Ok(model)) => model, - Ok(Err(error)) => return super::error(error), - Err(error) => { - return super::error(crate::models::Error::Internal(error.to_string())); - } - }; - let readme = String::from_utf8_lossy(&model.bytes); - let content = html! { div id="summary" { pre { (readme) } } }; - super::shared::render( - context, - &model.repository, - "HEAD", - super::shared::Page::About, - content, - &method, - ) -} diff --git a/crates/gilti/src/views/archive.rs b/crates/gilti/src/views/archive.rs deleted file mode 100644 --- a/crates/gilti/src/views/archive.rs +++ /dev/null @@ -1,188 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -use tokio::io::AsyncWriteExt; - -#[derive(Clone, Copy)] -pub enum Format { - Tar, - TarGzip, - TarBzip2, - TarLzip, - TarXz, - TarZstd, - Zip, -} - -impl Format { - pub fn parse(value: Option<&str>) -> Option<Self> { - match value.unwrap_or("tar.gz") { - "tar" => Some(Self::Tar), - "tar.gz" => Some(Self::TarGzip), - "tar.bz2" => Some(Self::TarBzip2), - "tar.lz" => Some(Self::TarLzip), - "tar.xz" => Some(Self::TarXz), - "tar.zst" => Some(Self::TarZstd), - "zip" => Some(Self::Zip), - _ => None, - } - } - - fn extension(self) -> &'static str { - match self { - Self::Tar => "tar", - Self::TarGzip => "tar.gz", - Self::TarBzip2 => "tar.bz2", - Self::TarLzip => "tar.lz", - Self::TarXz => "tar.xz", - Self::TarZstd => "tar.zst", - Self::Zip => "zip", - } - } - - fn content_type(self) -> &'static str { - match self { - Self::Tar => "application/x-tar; charset=UTF-8", - Self::TarGzip => "application/x-gzip; charset=UTF-8", - Self::TarBzip2 => "application/x-bzip2; charset=UTF-8", - Self::TarLzip => "application/x-lzip; charset=UTF-8", - Self::TarXz => "application/x-xz; charset=UTF-8", - Self::TarZstd => "application/x-zstd; charset=UTF-8", - Self::Zip => "application/x-zip; charset=UTF-8", - } - } - - fn git_format(self) -> &'static str { - match self { - Self::TarGzip => "tar.gz", - Self::Zip => "zip", - _ => "tar", - } - } - - fn compressor(self) -> Option<(&'static str, &'static [&'static str])> { - match self { - Self::TarBzip2 => Some(("/usr/bin/bzip2", &[])), - Self::TarLzip => Some(("/usr/bin/lzip", &[])), - Self::TarXz => Some(("/usr/bin/xz", &[])), - Self::TarZstd => Some(("/usr/bin/zstd", &["-T0"])), - _ => None, - } - } -} - -pub async fn serve( - context: &super::shared::Context, - route: crate::router::RepoRoute<crate::router::RevisionPath>, - format: Format, - method: axum::http::Method, -) -> axum::response::Response { - if method != axum::http::Method::GET && method != axum::http::Method::HEAD { - return super::method_not_allowed(); - } - let repositories = context.repositories; - let name = route.repo.clone(); - let revision = route.params.rev.clone(); - let path = route.params.path.clone(); - let model = tokio::task::spawn_blocking(move || { - crate::models::archive::Archive::load( - std::path::Path::new(repositories), - &name, - &revision, - path.as_deref(), - ) - }) - .await; - let model = match model { - Ok(Ok(model)) => model, - Ok(Err(error)) => return super::error(error), - Err(error) => return super::error(crate::models::Error::Internal(error.to_string())), - }; - let bytes = match generate(&model, route.params.path.as_deref(), format).await { - Ok(bytes) => bytes, - Err(error) => return super::error(crate::models::Error::Internal(error)), - }; - let filename = format!("{}.{}", model.prefix, format.extension()); - super::bytes_response( - format.content_type(), - Some(content_disposition(&filename)), - Some(format!("\"{}\"", model.oid)), - bytes, - &method, - ) -} - -async fn generate( - model: &crate::models::archive::Archive, - path: Option<&str>, - format: Format, -) -> Result<Vec<u8>, String> { - let mut command = tokio::process::Command::new(crate::GIT); - command - .arg("--git-dir") - .arg(&model.repository_path) - .arg("archive") - .arg(format!("--format={}", format.git_format())) - .arg(format!("--prefix={}/", model.prefix)) - .arg(&model.oid) - .env("GIT_CONFIG_GLOBAL", "/dev/null") - .env("GIT_CONFIG_NOSYSTEM", "1"); - if let Some(path) = path { - command.arg("--").arg(format!(":(literal){path}")); - } - let output = command.output().await.map_err(|error| error.to_string())?; - if !output.status.success() { - return Err(format!( - "git archive failed: {}", - String::from_utf8_lossy(&output.stderr) - )); - } - let Some((program, arguments)) = format.compressor() else { - return Ok(output.stdout); - }; - let mut child = tokio::process::Command::new(program) - .args(arguments) - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .spawn() - .map_err(|error| error.to_string())?; - let mut input = child.stdin.take().expect("compressor stdin is piped"); - let bytes = output.stdout; - let writer = tokio::spawn(async move { - input.write_all(&bytes).await?; - input.shutdown().await - }); - let output = child - .wait_with_output() - .await - .map_err(|error| error.to_string())?; - writer - .await - .map_err(|error| error.to_string())? - .map_err(|error| error.to_string())?; - if output.status.success() { - Ok(output.stdout) - } else { - Err(format!( - "archive compressor failed: {}", - String::from_utf8_lossy(&output.stderr) - )) - } -} - -fn content_disposition(filename: &str) -> String { - let fallback = filename - .chars() - .map(|character| { - if character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_') { - character - } else { - '_' - } - }) - .collect::<String>(); - let encoded = - percent_encoding::utf8_percent_encode(filename, percent_encoding::NON_ALPHANUMERIC); - format!("inline; filename=\"{fallback}\"; filename*=UTF-8''{encoded}") -} diff --git a/crates/gilti/src/views/archive_signature.rs b/crates/gilti/src/views/archive_signature.rs deleted file mode 100644 --- a/crates/gilti/src/views/archive_signature.rs +++ /dev/null @@ -1,53 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -pub async fn serve( - context: &super::shared::Context, - route: crate::router::RepoRoute<crate::router::Revision>, - format: Option<&str>, - method: axum::http::Method, -) -> axum::response::Response { - if method != axum::http::Method::GET && method != axum::http::Method::HEAD { - return super::method_not_allowed(); - } - let repositories = context.repositories; - let format = format.unwrap_or("tar.gz").to_owned(); - let model = tokio::task::spawn_blocking(move || { - crate::models::archive_signature::ArchiveSignature::load( - std::path::Path::new(repositories), - &route.repo, - route.params, - &format, - ) - }) - .await; - let model = match model { - Ok(Ok(model)) => model, - Ok(Err(error)) => return super::error(error), - Err(error) => { - return super::error(crate::models::Error::Internal(error.to_string())); - } - }; - let length = model.bytes.len(); - let body = if method == axum::http::Method::HEAD { - axum::body::Body::empty() - } else { - axum::body::Body::from(model.bytes) - }; - axum::response::Response::builder() - .status(axum::http::StatusCode::OK) - .header( - axum::http::header::CONTENT_TYPE, - "application/pgp-signature; charset=UTF-8", - ) - .header(axum::http::header::CONTENT_LENGTH, length) - .header( - axum::http::header::CONTENT_DISPOSITION, - format!("inline; filename=\"{}\"", model.filename), - ) - .header(axum::http::header::ETAG, format!("\"{}\"", model.oid)) - .header("x-content-type-options", "nosniff") - .header("content-security-policy", "default-src 'none'") - .body(body) - .expect("archive signature response is valid") -} diff --git a/crates/gilti/src/views/atom.rs b/crates/gilti/src/views/atom.rs deleted file mode 100644 --- a/crates/gilti/src/views/atom.rs +++ /dev/null @@ -1,247 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -pub async fn serve( - context: &super::shared::Context, - route: crate::router::RepoRoute<crate::router::RefPath>, - host: Option<&axum::http::HeaderValue>, - method: axum::http::Method, -) -> axum::response::Response { - if method != axum::http::Method::GET && method != axum::http::Method::HEAD { - return super::method_not_allowed(); - } - let repositories = context.repositories; - let name = route.repo; - let reference = route.params.reference; - let path = route.params.path; - let selection_path = path.clone(); - let model = tokio::task::spawn_blocking(move || { - crate::models::history::History::load( - std::path::Path::new(repositories), - &name, - crate::router::Revision::Ref(reference), - crate::models::history::Options { - path: selection_path, - follow: false, - search: crate::models::history::Search::None, - offset: 0, - limit: 10, - graph: false, - ignore_whitespace: false, - include_statistics: false, - }, - ) - }) - .await; - let model = match model { - Ok(Ok(model)) => model, - Ok(Err(error)) => return super::error(error), - Err(error) => return super::error(crate::models::Error::Internal(error.to_string())), - }; - let host = host - .and_then(|host| host.to_str().ok()) - .filter(|host| !host.is_empty()); - let body = document(&model, path.as_deref(), host); - super::bytes_response( - "text/xml; charset=UTF-8", - None, - None, - body.into_bytes(), - &method, - ) -} - -fn document( - model: &crate::models::history::History, - path: Option<&str>, - host: Option<&str>, -) -> String { - let repository = super::shared::repository_url(&model.repository.name); - let reference = &model.revision; - let mut feed = String::from("<feed xmlns='http://www.w3.org/2005/Atom'>\n<title>"); - text(&mut feed, &model.repository.name); - if let Some(path) = path { - feed.push('/'); - text(&mut feed, path); - } - feed.push_str(", branch "); - text(&mut feed, reference); - feed.push_str("</title>\n<subtitle>"); - text(&mut feed, &model.repository.description); - feed.push_str("</subtitle>\n"); - let self_path = path.map_or_else( - || { - format!( - "{repository}/+/{}/+/feed/atom", - super::shared::encode_path(&model.revision) - ) - }, - |path| { - format!( - "{repository}/+/{}/+/feed/atom/{}", - super::shared::encode_path(&model.revision), - super::shared::encode_path(path) - ) - }, - ); - if let Some(host) = host { - let absolute = |path: &str| format!("http://{host}{path}"); - element(&mut feed, "id", &absolute(&self_path)); - attribute( - &mut feed, - "link", - &[('r', "self"), ('h', &absolute(&self_path))], - ); - attribute( - &mut feed, - "link", - &[ - ('r', "alternate"), - ('t', "text/html"), - ('h', &absolute(&repository)), - ], - ); - } - if let Some(entry) = model.entries.first() { - element(&mut feed, "updated", ×tamp(&entry.committer)); - } - for entry in &model.entries { - feed.push_str("<entry>\n<title>"); - text(&mut feed, &entry.subject); - feed.push_str("</title>\n"); - element(&mut feed, "updated", ×tamp(&entry.committer)); - feed.push_str("<author>\n"); - if !entry.author.name.is_empty() { - element(&mut feed, "name", &entry.author.name); - } - if !entry.author.email.is_empty() { - element(&mut feed, "email", &entry.author.email); - } - feed.push_str("</author>\n"); - element(&mut feed, "published", ×tamp(&entry.author)); - if let Some(host) = host { - attribute( - &mut feed, - "link", - &[ - ('r', "alternate"), - ('t', "text/html"), - ('h', &format!("http://{host}{repository}/+/{}", entry.oid)), - ], - ); - } - element(&mut feed, "id", &urn(&entry.oid)); - feed.push_str("<content type='text'>\n"); - text(&mut feed, &entry.body); - feed.push_str("</content>\n</entry>\n"); - } - feed.push_str("</feed>\n"); - feed -} - -fn element(output: &mut String, name: &str, value: &str) { - output.push('<'); - output.push_str(name); - output.push('>'); - text(output, value); - output.push_str("</"); - output.push_str(name); - output.push_str(">\n"); -} - -fn attribute(output: &mut String, name: &str, attributes: &[(char, &str)]) { - output.push('<'); - output.push_str(name); - for (key, value) in attributes { - let key = match key { - 'r' => "rel", - 't' => "type", - 'h' => "href", - _ => unreachable!(), - }; - output.push(' '); - output.push_str(key); - output.push_str("='"); - attribute_value(output, value); - output.push('\''); - } - output.push_str("/>\n"); -} - -fn text(output: &mut String, value: &str) { - for character in value.chars() { - match character { - '&' => output.push_str("&"), - '<' => output.push_str("<"), - '>' => output.push_str(">"), - character if xml_character(character) => output.push(character), - _ => output.push('\u{fffd}'), - } - } -} -fn attribute_value(output: &mut String, value: &str) { - for character in value.chars() { - match character { - '&' => output.push_str("&"), - '<' => output.push_str("<"), - '\'' => output.push_str("'"), - '"' => output.push_str("""), - character if xml_character(character) => output.push(character), - _ => output.push('\u{fffd}'), - } - } -} - -fn xml_character(character: char) -> bool { - matches!(character, '\u{9}' | '\u{a}' | '\u{d}') - || ('\u{20}'..='\u{d7ff}').contains(&character) - || ('\u{e000}'..='\u{fffd}').contains(&character) - || character >= '\u{10000}' -} - -fn urn(oid: &str) -> String { - let algorithm = if oid.len() == 64 { "sha256" } else { "sha1" }; - format!("urn:{algorithm}:{oid}") -} - -fn timestamp(identity: &crate::models::commit::Identity) -> String { - let Some(value) = crate::models::time::utc(identity.timestamp) else { - return "1970-01-01T00:00:00Z".to_owned(); - }; - format!( - "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", - value.tm_year + 1900, - value.tm_mon + 1, - value.tm_mday, - value.tm_hour, - value.tm_min, - value.tm_sec - ) -} - -#[cfg(test)] -mod tests { - #[test] - fn escapes_xml_and_identifies_sha256() { - let mut output = String::new(); - super::text(&mut output, "<&>\0\u{1}\t"); - assert_eq!(output, "<&>��\t"); - assert_eq!( - super::urn(&"a".repeat(40)), - format!("urn:sha1:{}", "a".repeat(40)) - ); - assert_eq!( - super::urn(&"b".repeat(64)), - format!("urn:sha256:{}", "b".repeat(64)) - ); - assert_eq!( - super::timestamp(&crate::models::commit::Identity { - name: String::new(), - email: String::new(), - timestamp: 0, - offset_minutes: 330 - }), - "1970-01-01T00:00:00Z" - ); - } -} diff --git a/crates/gilti/src/views/blame.rs b/crates/gilti/src/views/blame.rs deleted file mode 100644 --- a/crates/gilti/src/views/blame.rs +++ /dev/null @@ -1,112 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -use maud::{Markup, html}; - -pub async fn serve( - context: &super::shared::Context, - route: crate::router::RepoRoute<crate::router::RevisionFile>, - method: axum::http::Method, -) -> axum::response::Response { - if method != axum::http::Method::GET && method != axum::http::Method::HEAD { - return super::method_not_allowed(); - } - let repositories = context.repositories; - let model = tokio::task::spawn_blocking(move || { - crate::models::blame::Blame::load( - std::path::Path::new(repositories), - &route.repo, - route.params.rev, - route.params.path, - ) - }) - .await; - let model = match model { - Ok(Ok(model)) => model, - Ok(Err(error)) => return super::error(error), - Err(error) => { - return super::error(crate::models::Error::Internal(error.to_string())); - } - }; - let content = content(&model); - super::shared::render( - context, - &model.repository, - &model.revision, - super::shared::Page::Tree, - content, - &method, - ) -} - -fn content(model: &crate::models::blame::Blame) -> Markup { - let repo = super::shared::repository_url(&model.repository.name); - let revision = super::shared::encode_path(&model.revision); - let path = super::shared::encode_path(&model.path); - html! { - div class="path" { "path: " (breadcrumbs(&repo, &revision, &model.path)) } - "blob: " (&model.oid) " (" - a href=(format!("{repo}/+/{revision}/+/tree/{path}?format=raw")) { "plain" } - ") (" - a href=(format!("{repo}/+/{revision}/+/tree/{path}")) { "tree" } - ")" - @if model.binary { - div class="error" { "blob is binary." } - } @else { - (blame_table(model, &repo)) - } - } -} - -fn blame_table(model: &crate::models::blame::Blame, repo: &str) -> Markup { - let lines = String::from_utf8_lossy(&model.bytes) - .split_inclusive('\n') - .map(str::to_owned) - .collect::<Vec<_>>(); - html! { table class="blame blob" { - @for hunk in &model.hunks { - @for offset in 0..hunk.lines { - @let line = hunk.start + offset; - tr class=[(offset % 2 == 0).then_some("alt")] { - @if offset == 0 { - td class="hashes" rowspan=(hunk.lines) { - span class="oid" title=(format!( - "author {} <{}> {}\ncommitter {} <{}>\n\n{}", - hunk.author, hunk.author_email, - super::shared::age(hunk.timestamp), - hunk.committer, hunk.committer_email, - hunk.summary - )) { - a href=(format!("{repo}/+/{}", hunk.oid)) { (&hunk.short_oid) } - } - @if let Some(parent) = &hunk.parent { - " " a href=(format!( - "{repo}/+/{parent}/+/blame/{}", - super::shared::encode_path(&hunk.original_path) - )) title="Blame the previous revision" { "^" } - } - } - } - td class="linenumbers" { a id=(format!("n{line}")) href=(format!("#n{line}")) { (line) } } - td class="lines" { pre { code { (lines.get(line - 1).map_or("", String::as_str)) } } } - } - } - } - } } -} - -fn breadcrumbs(repository: &str, revision: &str, path: &str) -> Markup { - let parts = path.split('/').collect::<Vec<_>>(); - html! { - a href=(format!("{repository}/+/{revision}/+/tree")) { "root" } - @for (index, part) in parts.iter().enumerate() { - "/" - @let current = parts[..=index].join("/"); - @if index + 1 == parts.len() { - a href=(format!("{repository}/+/{revision}/+/blame/{}", super::shared::encode_path(¤t))) { (part) } - } @else { - a href=(format!("{repository}/+/{revision}/+/tree/{}", super::shared::encode_path(¤t))) { (part) } - } - } - } -} diff --git a/crates/gilti/src/views/diff.rs b/crates/gilti/src/views/diff.rs deleted file mode 100644 --- a/crates/gilti/src/views/diff.rs +++ /dev/null @@ -1,456 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -use maud::{Markup, html}; - -#[derive(Clone, Copy, Eq, PartialEq)] -pub enum Mode { - Unified, - SideBySide, - StatOnly, -} - -#[derive(Clone, Copy)] -pub struct Query { - pub context: u32, - pub ignore_whitespace: bool, - pub mode: Mode, -} - -impl Query { - pub fn from_request(query: &crate::RequestQuery) -> Result<Self, ()> { - let context = query - .value("GILTI_QUERY_CONTEXT") - .map(str::parse) - .transpose() - .map_err(|_| ())? - .unwrap_or(3); - let context = if context == 0 { 3 } else { context }; - if context > 40 { - return Err(()); - } - let ignore_whitespace = match query.value("GILTI_QUERY_IGNOREWS") { - None | Some("0") => false, - Some("1") => true, - _ => return Err(()), - }; - let mode = match query.value("GILTI_QUERY_DIFFTYPE") { - None | Some("0") => Mode::Unified, - Some("1") => Mode::SideBySide, - Some("2") => Mode::StatOnly, - _ => return Err(()), - }; - Ok(Self { - context, - ignore_whitespace, - mode, - }) - } - - pub fn options(self) -> crate::models::diff::Options { - crate::models::diff::Options { - context: self.context, - ignore_whitespace: self.ignore_whitespace, - } - } -} - -pub async fn serve( - context: &super::shared::Context, - route: crate::router::RepoRoute<crate::router::Comparison>, - query: Query, - raw: bool, - method: axum::http::Method, -) -> axum::response::Response { - if method != axum::http::Method::GET && method != axum::http::Method::HEAD { - return super::method_not_allowed(); - } - let repositories = context.repositories; - let name = route.repo.clone(); - let old = route.params.old_rev.clone(); - let new = route.params.new_rev.clone(); - let path = route.params.path.clone(); - let model = tokio::task::spawn_blocking(move || { - crate::models::diff::Diff::load( - std::path::Path::new(repositories), - &name, - Some(old), - new, - path, - query.options(), - ) - }) - .await; - let model = match model { - Ok(Ok(model)) => model, - Ok(Err(error)) => return super::error(error), - Err(error) => return super::error(crate::models::Error::Internal(error.to_string())), - }; - if raw { - return raw_response(context, &route, &model, query, &method).await; - } - let revision = model.new_revision.clone(); - let content = content(&model, query, route.params.path.as_deref(), true); - super::shared::render( - context, - &model.repository, - &revision, - super::shared::Page::Diff, - content, - &method, - ) -} - -pub fn content( - model: &crate::models::diff::Diff, - query: Query, - path: Option<&str>, - controls: bool, -) -> Markup { - html! { - @if controls { (content_controls(query)) } - div class="diffstat-header" { - "Diffstat" - @if let Some(path) = path { " (limited to '" (path) "')" } - } - table summary="diffstat" class="diffstat" { - @for file in &model.files { (file_stat(model, file)) } - } - div class="diffstat-summary" { - (model.files.len()) " files changed, " (model.additions) " insertions, " (model.deletions) " deletions" - } - @if query.mode != Mode::StatOnly { - @if query.mode == Mode::SideBySide { - table summary="ssdiff" class="ssdiff" { - @for file in &model.files { (side_file(model, file)) } - } - } @else { - table summary="diff" class="diff" { tr { td { - @for file in &model.files { (unified_file(model, file)) } - } } } - } - } - } -} - -pub fn content_controls(query: Query) -> Markup { - html! { div class="gilti-panel" { - b { "diff options" } - form method="get" { table { - tr { td colspan="2" {} } - tr { td class="label" { "context:" } td class="ctrl" { - select name="context" onchange="this.form.submit();" { - @for value in [1_u32,2,3,4,5,6,7,8,9,10,15,20,25,30,35,40] { - option value=(value) selected[query.context == value] { (value) } - } - } - } } - tr { td class="label" { "space:" } td class="ctrl" { - select name="ignorews" onchange="this.form.submit();" { - option value="0" selected[!query.ignore_whitespace] { "include" } - option value="1" selected[query.ignore_whitespace] { "ignore" } - } - } } - tr { td class="label" { "mode:" } td class="ctrl" { - select name="dt" onchange="this.form.submit();" { - option value="0" selected[query.mode == Mode::Unified] { "unified" } - option value="1" selected[query.mode == Mode::SideBySide] { "ssdiff" } - option value="2" selected[query.mode == Mode::StatOnly] { "stat only" } - } - } } - tr { td {} td class="ctrl" { noscript { input type="submit" value="reload"; } } } - } } - } } -} - -fn file_stat(model: &crate::models::diff::Diff, file: &crate::models::diff::File) -> Markup { - let path = if file.new_path.is_empty() { - &file.old_path - } else { - &file.new_path - }; - let repo = super::shared::repository_url(&model.repository.name); - let old = super::shared::encode_path(model.old_revision.as_deref().unwrap_or("HEAD")); - let new = super::shared::encode_path(&model.new_revision); - let path_url = super::shared::encode_path(path); - let class = status_class(file.status); - html! { tr { - td class="mode" { - (super::tree::filemode(if file.new_mode == 0 { file.old_mode } else { file.new_mode })) - @if file.old_mode != 0 && file.new_mode != 0 && file.old_mode != file.new_mode { - span class="modechange" { "[" (super::tree::filemode(file.old_mode)) "]" } - } - } - td class=(class) { - a href=(format!("{repo}/+/diff/{old}..{new}/+/{path_url}")) { (path) } - @if matches!(file.status, git2::Delta::Renamed | git2::Delta::Copied) { - " (" @if file.status == git2::Delta::Copied { "copied" } @else { "renamed" } - " from " (&file.old_path) ")" - } - } - td class="right" { - @if file.binary { "bin" } @else { (file.additions + file.deletions) } - } - td class="graph" { - @if file.binary { (file.old_size) " -> " (file.new_size) " bytes" } - @else { "+" (file.additions) " −" (file.deletions) } - } - } } -} - -fn unified_file(model: &crate::models::diff::Diff, file: &crate::models::diff::File) -> Markup { - html! { - (file_header(model, file)) - @if file.binary { div class="ctx" { "Binary files differ" } } - @for hunk in &file.hunks { - div class="hunk" { (&hunk.header) } - @for line in &hunk.lines { - div class=(line_class(line.origin)) { - @if matches!(line.origin, '+' | '-' | ' ') { (line.origin) } - (&line.content) - } - } - } - } -} - -fn side_file(model: &crate::models::diff::Diff, file: &crate::models::diff::File) -> Markup { - html! { - tr { td colspan="4" { (file_header(model, file)) } } - @if file.binary { tr { td colspan="4" { "Binary files differ" } } } - @for hunk in &file.hunks { - tr { td colspan="4" class="hunk" { (&hunk.header) } } - @for (old, new) in side_rows(hunk) { tr { - td class="lineno" { @if let Some(line) = old { (line.old_line.unwrap_or_default()) } } - td class=[old.map(|line| line_class(line.origin))] { (side_content(old, new, true)) } - td class="lineno" { @if let Some(line) = new { (line.new_line.unwrap_or_default()) } } - td class=[new.map(|line| line_class(line.origin))] { (side_content(new, old, false)) } - } } - } - } -} - -fn side_rows( - hunk: &crate::models::diff::Hunk, -) -> Vec<( - Option<&crate::models::diff::Line>, - Option<&crate::models::diff::Line>, -)> { - let mut rows = Vec::new(); - let mut removed = Vec::new(); - let mut added = Vec::new(); - let flush = |rows: &mut Vec<_>, removed: &mut Vec<_>, added: &mut Vec<_>| { - let count = removed.len().max(added.len()); - for index in 0..count { - rows.push((removed.get(index).copied(), added.get(index).copied())); - } - removed.clear(); - added.clear(); - }; - for line in &hunk.lines { - match line.origin { - '-' => removed.push(line), - '+' => added.push(line), - _ => { - flush(&mut rows, &mut removed, &mut added); - rows.push((Some(line), Some(line))); - } - } - } - flush(&mut rows, &mut removed, &mut added); - rows -} - -fn side_content( - line: Option<&crate::models::diff::Line>, - other: Option<&crate::models::diff::Line>, - old: bool, -) -> Markup { - let Some(line) = line else { - return html! {}; - }; - let paired_change = - other.is_some_and(|other| matches!((line.origin, other.origin), ('-', '+') | ('+', '-'))); - if !paired_change { - return html! { (&line.content) }; - } - let other = &other.expect("paired line exists").content; - let common = if old { - common_subsequence(&line.content, other) - } else { - common_subsequence(other, &line.content) - }; - let segments = highlighted_segments(&line.content, &common); - let class = if old { "del" } else { "add" }; - html! { - @for (changed, text) in segments { - @if changed { span class=(class) { (text) } } @else { (text) } - } - } -} - -fn common_subsequence(old: &str, new: &str) -> Vec<char> { - let old = old.chars().collect::<Vec<_>>(); - let new = new.chars().collect::<Vec<_>>(); - if old.len() >= 200 || new.len() >= 200 { - return Vec::new(); - } - let mut lengths = vec![vec![0_usize; new.len() + 1]; old.len() + 1]; - for left in (0..old.len()).rev() { - for right in (0..new.len()).rev() { - lengths[left][right] = if old[left] == new[right] { - lengths[left + 1][right + 1] + 1 - } else { - lengths[left + 1][right].max(lengths[left][right + 1]) - }; - } - } - let (mut left, mut right) = (0, 0); - let mut common = Vec::with_capacity(lengths[0][0]); - while left < old.len() && right < new.len() { - if old[left] == new[right] { - common.push(old[left]); - left += 1; - right += 1; - } else if lengths[left + 1][right] >= lengths[left][right + 1] { - left += 1; - } else { - right += 1; - } - } - common -} - -fn highlighted_segments(value: &str, common: &[char]) -> Vec<(bool, String)> { - let mut common = common.iter().copied().peekable(); - let mut segments = Vec::<(bool, String)>::new(); - for character in value.chars() { - let changed = common.peek().copied() != Some(character); - if !changed { - common.next(); - } - if let Some((last_changed, text)) = segments.last_mut() - && *last_changed == changed - { - text.push(character); - continue; - } - segments.push((changed, character.to_string())); - } - segments -} - -fn file_header(model: &crate::models::diff::Diff, file: &crate::models::diff::File) -> Markup { - let old = if file.old_path.is_empty() { - "/dev/null" - } else { - &file.old_path - }; - let new = if file.new_path.is_empty() { - "/dev/null" - } else { - &file.new_path - }; - html! { div class="head" { - "diff --git a/" (old) " b/" (new) - @if file.old_mode == 0 { br; "new file mode " (format!("{:06o}", file.new_mode)) } - @if file.new_mode == 0 { br; "deleted file mode " (format!("{:06o}", file.old_mode)) } - @if let (Some(old_oid), Some(new_oid)) = (&file.old_oid, &file.new_oid) { - br; "index " (&old_oid[..old_oid.len().min(7)]) ".." (&new_oid[..new_oid.len().min(7)]) - @if file.old_mode != 0 && file.new_mode != 0 { - " " (format!("{:06o}", file.old_mode)) - @if file.old_mode != file.new_mode { ".." (format!("{:06o}", file.new_mode)) } - } - } - br; "--- " @if file.old_mode == 0 { "/dev/null" } @else { "a/" (old) } - br; "+++ " @if file.new_mode == 0 { "/dev/null" } @else { "b/" (new) } - @if model.old_oid.is_none() { "" } - } } -} - -fn line_class(origin: char) -> &'static str { - match origin { - '+' => "add", - '-' => "del", - '@' => "hunk", - _ => "ctx", - } -} - -fn status_class(status: git2::Delta) -> &'static str { - match status { - git2::Delta::Added => "add", - git2::Delta::Copied => "cpy", - git2::Delta::Deleted => "del", - git2::Delta::Modified => "upd", - git2::Delta::Renamed => "mov", - git2::Delta::Typechange => "typ", - git2::Delta::Conflicted => "stg", - _ => "unk", - } -} - -async fn raw_response( - context: &super::shared::Context, - route: &crate::router::RepoRoute<crate::router::Comparison>, - model: &crate::models::diff::Diff, - query: Query, - method: &axum::http::Method, -) -> axum::response::Response { - let repository = match crate::models::repository::path( - std::path::Path::new(context.repositories), - &route.repo, - ) { - Ok(repository) => repository, - Err(error) => return super::error(error), - }; - let mut command = tokio::process::Command::new(crate::GIT); - command - .arg("--git-dir") - .arg(repository) - .arg("-c") - .arg("color.ui=false") - .arg("diff") - .arg("--no-ext-diff") - .arg("--no-textconv") - .arg("--no-renames") - .arg(format!("--unified={}", query.context)); - if query.ignore_whitespace { - command.arg("--ignore-all-space"); - } - command - .arg(model.old_oid.as_deref().unwrap_or("")) - .arg(&model.new_oid); - if let Some(path) = &route.params.path { - command.arg("--").arg(path); - } - let output = match command.output().await { - Ok(output) if output.status.success() => output.stdout, - Ok(output) => { - eprintln!( - "gilti: git diff failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - return super::error(crate::models::Error::Internal("git diff failed".to_owned())); - } - Err(error) => return super::error(crate::models::Error::Internal(error.to_string())), - }; - super::bytes_response("text/plain; charset=UTF-8", None, None, output, method) -} - -#[cfg(test)] -mod tests { - #[test] - fn highlights_side_by_side_character_changes() { - let common = super::common_subsequence("alpha", "aloha"); - assert_eq!(common, ['a', 'l', 'h', 'a']); - assert_eq!( - super::highlighted_segments("alpha", &common), - [ - (false, "al".to_owned()), - (true, "p".to_owned()), - (false, "ha".to_owned()), - ] - ); - } -} diff --git a/crates/gilti/src/views/log.rs b/crates/gilti/src/views/log.rs deleted file mode 100644 --- a/crates/gilti/src/views/log.rs +++ /dev/null @@ -1,298 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -use maud::{Markup, html}; - -pub struct Query { - pub offset: usize, - pub show_message: bool, - pub follow: bool, - pub ignore_whitespace: bool, - pub search: Search, -} - -pub enum Search { - None, - Grep(String), - Author(String), - Committer(String), - Range(String), -} - -impl Query { - pub fn from_request(query: &crate::RequestQuery) -> Result<Self, ()> { - let offset = match query.value("GILTI_QUERY_OFFSET") { - None => 0, - Some(value) => value.parse().map_err(|_| ())?, - }; - let boolean = |name| match query.value(name) { - None | Some("0") => Ok(false), - Some("1") => Ok(true), - Some(_) => Err(()), - }; - let search = match ( - query.value("GILTI_QUERY_GREP"), - query.value("GILTI_QUERY_SEARCH"), - ) { - (None, None) => Search::None, - (Some(_), None) | (None, Some(_)) => return Err(()), - (Some("grep"), Some(value)) => Search::Grep(value.to_owned()), - (Some("author"), Some(value)) => Search::Author(value.to_owned()), - (Some("committer"), Some(value)) => Search::Committer(value.to_owned()), - (Some("range"), Some(value)) if valid_range(value) => Search::Range(value.to_owned()), - _ => return Err(()), - }; - Ok(Self { - offset, - show_message: boolean("GILTI_QUERY_SHOWMSG")?, - follow: boolean("GILTI_QUERY_FOLLOW")?, - ignore_whitespace: boolean("GILTI_QUERY_IGNOREWS")?, - search, - }) - } - - fn history(&self) -> crate::models::history::Search { - match &self.search { - Search::None => crate::models::history::Search::None, - Search::Grep(value) => crate::models::history::Search::Grep(value.clone()), - Search::Author(value) => crate::models::history::Search::Author(value.clone()), - Search::Committer(value) => crate::models::history::Search::Committer(value.clone()), - Search::Range(value) => crate::models::history::Search::Range(value.clone()), - } - } - - fn suffix(&self, offset: usize, show_message: bool, follow: bool) -> String { - let mut values = Vec::new(); - if offset != 0 { - values.push(format!("ofs={offset}")); - } - if show_message { - values.push("showmsg=1".to_owned()); - } - if follow { - values.push("follow=1".to_owned()); - } - if self.ignore_whitespace { - values.push("ignorews=1".to_owned()); - } - match &self.search { - Search::None => {} - Search::Grep(value) => { - values.push("qt=grep".to_owned()); - values.push(format!("q={}", query(value))); - } - Search::Author(value) => { - values.push("qt=author".to_owned()); - values.push(format!("q={}", query(value))); - } - Search::Committer(value) => { - values.push("qt=committer".to_owned()); - values.push(format!("q={}", query(value))); - } - Search::Range(value) => { - values.push("qt=range".to_owned()); - values.push(format!("q={}", query(value))); - } - } - if values.is_empty() { - String::new() - } else { - format!("?{}", values.join("&")) - } - } -} - -pub async fn serve( - context: &super::shared::Context, - route: crate::router::RepoRoute<crate::router::RevisionPath>, - query: Query, - method: axum::http::Method, -) -> axum::response::Response { - if method != axum::http::Method::GET && method != axum::http::Method::HEAD { - return super::method_not_allowed(); - } - let repositories = context.repositories; - let name = route.repo; - let revision = route.params.rev; - let path = route.params.path; - let options = crate::models::history::Options { - path: path.clone(), - follow: query.follow, - search: query.history(), - offset: query.offset, - limit: crate::models::history::LOG_PAGE_SIZE, - graph: true, - ignore_whitespace: query.ignore_whitespace, - include_statistics: true, - }; - let model = tokio::task::spawn_blocking(move || { - crate::models::history::History::load( - std::path::Path::new(repositories), - &name, - revision, - options, - ) - }) - .await; - let model = match model { - Ok(Ok(value)) => value, - Ok(Err(error)) => return super::error(error), - Err(error) => return super::error(crate::models::Error::Internal(error.to_string())), - }; - super::shared::render( - context, - &model.repository, - &model.revision, - super::shared::Page::Log, - content(&model, &query, path.as_deref()), - &method, - ) -} - -fn content(model: &crate::models::history::History, query: &Query, path: Option<&str>) -> Markup { - let base = format!( - "{}/+/{}/+/log", - super::shared::repository_url(&model.repository.name), - super::shared::encode_path(&model.revision) - ); - let base = path.map_or(base.clone(), |path| { - format!("{base}/{}", super::shared::encode_path(path)) - }); - let columns = if model.graph { 6 } else { 5 }; - html! { - form class="right" method="get" action=(&base) { - @if query.show_message { input type="hidden" name="showmsg" value="1"; } - @if query.follow { input type="hidden" name="follow" value="1"; } - @if query.ignore_whitespace { input type="hidden" name="ignorews" value="1"; } - select name="qt" { - option value="grep" selected[matches!(query.search, Search::Grep(_))] { "log msg" } - option value="author" selected[matches!(query.search, Search::Author(_))] { "author" } - option value="committer" selected[matches!(query.search, Search::Committer(_))] { "committer" } - option value="range" selected[matches!(query.search, Search::Range(_))] { "range" } - } - input class="txt" type="search" size="10" name="q" value=(search_value(&query.search)); - input type="submit" value="search"; - } - @if let Some(path) = path { - div class="path" { - "path: " (path) " (" - a href=(format!("{}{}", base, query.suffix(query.offset, query.show_message, !query.follow))) { (if query.follow { "unfollow" } else { "follow" }) } - ")" - } - } - table class="list nowrap" { - tr class="nohover" { - @if model.graph { th {} } @else { th class="left" { "Age" } } - th class="left" { "Commit message (" a href=(format!("{}{}", base, query.suffix(query.offset, !query.show_message, query.follow))) { (if query.show_message { "Collapse" } else { "Expand" }) } ")" } - th class="left" { "Author" } - @if model.graph { th class="left" { "Age" } } - th class="left" { "Files" } - th class="left" { "Lines" } - } - @for entry in &model.entries { - @if model.graph { - @for continuation in &entry.graph_continuations { - tr class="nohover" { td class="commitgraph" { (graph(continuation)) } td colspan=(columns - 1) {} } - } - } - tr class=[query.show_message.then_some("logheader")] { - @if model.graph { td class="commitgraph" { (graph(&entry.graph)) } } @else { td { (age(&entry.committer)) } } - td class=[query.show_message.then_some("logsubject")] { - a href=(format!("{}/+/{}", super::shared::repository_url(&model.repository.name), entry.oid)) { (&entry.subject) } - @for decoration in &entry.decorations { - @if let Some(reference) = &decoration.reference { - @if reference.starts_with("refs/tags/") { - span class="decoration" { " " a class="tag-deco" href=(format!("{}/+/{}", super::shared::repository_url(&model.repository.name), super::shared::encode_path(reference))) { (&decoration.label) } } - } @else { - @let link = entry.path.as_ref().map_or_else(|| format!("{}/+/{}/+/log{}", super::shared::repository_url(&model.repository.name), super::shared::encode_path(reference), query.suffix(0, query.show_message, query.follow)), |path| format!("{}/+/{}/+/log/{}{}", super::shared::repository_url(&model.repository.name), super::shared::encode_path(reference), super::shared::encode_path(path), query.suffix(0, query.show_message, query.follow))); - span class="decoration" { " " a class="branch-deco" href=(link) { (&decoration.label) } } - } - } @else { span class="decoration" { " " (&decoration.label) } } - } - } - td { (&entry.author.name) } - @if model.graph { td { (age(&entry.committer)) } } - td { (entry.files) } - td { span class="deletions" { "-" (entry.deletions) } "/" span class="insertions" { "+" (entry.additions) } } - } - @if query.show_message { tr class="nohover-highlight" { td colspan=(columns) class="logmsg" { (&entry.body) @if let Some(notes) = &entry.notes { "\n" (notes) } } } } - } - } - ul class="pager" { - @if query.offset > 0 { li { a href=(format!("{}{}", base, query.suffix(query.offset.saturating_sub(crate::models::history::LOG_PAGE_SIZE), query.show_message, query.follow))) { "[prev]" } } } - @if model.has_next { li { a href=(format!("{}{}", base, query.suffix(query.offset + crate::models::history::LOG_PAGE_SIZE, query.show_message, query.follow))) { "[next]" } } } - } - } -} - -fn graph(line: &str) -> Markup { - html! { - @for (index, character) in line.chars().enumerate() { - span class=(format!("column{}", index / 2 % 6 + 1)) { (character) } - } - } -} - -fn search_value(search: &Search) -> &str { - match search { - Search::None => "", - Search::Grep(value) - | Search::Author(value) - | Search::Committer(value) - | Search::Range(value) => value, - } -} - -fn age(identity: &crate::models::commit::Identity) -> String { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_or(0, |duration| duration.as_secs() as i64); - if now.saturating_sub(identity.timestamp) < 14 * 24 * 60 * 60 { - return super::shared::age(identity.timestamp); - } - let local = identity.timestamp + i64::from(identity.offset_minutes) * 60; - let Some(value) = crate::models::time::utc(local) else { - return super::shared::age(identity.timestamp); - }; - format!( - "{:04}-{:02}-{:02}", - value.tm_year + 1900, - value.tm_mon + 1, - value.tm_mday - ) -} - -fn valid_range(value: &str) -> bool { - let mut any = false; - for selector in value.split_ascii_whitespace() { - any = true; - let valid = |selector: &str| { - selector == "HEAD" - || selector.starts_with("refs/") - || (matches!(selector.len(), 40 | 64) - && selector.bytes().all(|byte| byte.is_ascii_hexdigit())) - }; - if selector.starts_with('-') || !selector.split("..").all(valid) { - return false; - } - } - any -} - -fn query(value: &str) -> String { - percent_encoding::utf8_percent_encode(value, percent_encoding::NON_ALPHANUMERIC).to_string() -} - -#[cfg(test)] -mod tests { - #[test] - fn query_is_strict() { - assert!(super::Query::from_request(&crate::RequestQuery::default()).is_ok()); - let query = crate::request_query(Some("follow=2")).unwrap(); - assert!(super::Query::from_request(&query).is_err()); - let query = crate::request_query(Some("qt=range&q=-bad")).unwrap(); - assert!(super::Query::from_request(&query).is_err()); - let query = crate::request_query(Some("qt=range&q=HEAD..refs%2Fheads%2Fmain")).unwrap(); - assert!(super::Query::from_request(&query).is_ok()); - } -} diff --git a/crates/gilti/src/views/mod.rs b/crates/gilti/src/views/mod.rs deleted file mode 100644 --- a/crates/gilti/src/views/mod.rs +++ /dev/null @@ -1,80 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -pub mod about; -pub mod archive; -pub mod archive_signature; -pub mod atom; -pub mod blame; -pub mod diff; -pub mod log; -pub mod object; -pub mod overview; -pub mod patch; -pub mod refs; -pub mod repositories; -pub mod revision; -pub mod shared; -pub mod stats; -pub mod tag; -pub mod tree; - -fn method_not_allowed() -> axum::response::Response { - axum::response::Response::builder() - .status(axum::http::StatusCode::METHOD_NOT_ALLOWED) - .header(axum::http::header::CONTENT_TYPE, "text/plain") - .body(axum::body::Body::from("method not allowed\n")) - .expect("static response is valid") -} - -pub(crate) fn bad_request(message: &'static str) -> axum::response::Response { - axum::response::Response::builder() - .status(axum::http::StatusCode::BAD_REQUEST) - .header(axum::http::header::CONTENT_TYPE, "text/plain") - .body(axum::body::Body::from(message)) - .expect("static response is valid") -} - -pub(crate) fn bytes_response( - content_type: &'static str, - content_disposition: Option<String>, - etag: Option<String>, - bytes: Vec<u8>, - method: &axum::http::Method, -) -> axum::response::Response { - let length = bytes.len(); - let body = if method == axum::http::Method::HEAD { - axum::body::Body::empty() - } else { - axum::body::Body::from(bytes) - }; - let mut response = axum::response::Response::builder() - .status(axum::http::StatusCode::OK) - .header(axum::http::header::CONTENT_TYPE, content_type) - .header(axum::http::header::CONTENT_LENGTH, length); - if let Some(value) = content_disposition { - response = response.header(axum::http::header::CONTENT_DISPOSITION, value); - } - if let Some(value) = etag { - response = response.header(axum::http::header::ETAG, value); - } - response.body(body).expect("byte response is valid") -} - -fn error(error: crate::models::Error) -> axum::response::Response { - let (status, message) = match error { - crate::models::Error::NotFound => (axum::http::StatusCode::NOT_FOUND, "not found\n"), - crate::models::Error::Internal(message) => { - eprintln!("gilti: repository view failed: {message}"); - ( - axum::http::StatusCode::INTERNAL_SERVER_ERROR, - "internal server error\n", - ) - } - }; - axum::response::Response::builder() - .status(status) - .header(axum::http::header::CONTENT_TYPE, "text/plain") - .body(axum::body::Body::from(message)) - .expect("static response is valid") -} diff --git a/crates/gilti/src/views/object.rs b/crates/gilti/src/views/object.rs deleted file mode 100644 --- a/crates/gilti/src/views/object.rs +++ /dev/null @@ -1,52 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -pub async fn serve( - repositories: &'static str, - route: crate::router::RepoRoute<String>, - method: axum::http::Method, -) -> axum::response::Response { - if method != axum::http::Method::GET && method != axum::http::Method::HEAD { - return axum::response::Response::builder() - .status(axum::http::StatusCode::METHOD_NOT_ALLOWED) - .header(axum::http::header::CONTENT_TYPE, "text/plain") - .body(axum::body::Body::from("method not allowed\n")) - .expect("static response is valid"); - } - let model = tokio::task::spawn_blocking(move || { - crate::models::object::RawObject::load( - std::path::Path::new(repositories), - &route.repo, - &route.params, - ) - }) - .await; - let model = match model { - Ok(Ok(model)) => model, - Ok(Err(error)) => return super::error(error), - Err(error) => { - return super::error(crate::models::Error::Internal(error.to_string())); - } - }; - let length = model.bytes.len(); - let body = if method == axum::http::Method::HEAD { - axum::body::Body::empty() - } else { - axum::body::Body::from(model.bytes) - }; - axum::response::Response::builder() - .status(axum::http::StatusCode::OK) - .header( - axum::http::header::CONTENT_TYPE, - if model.binary { - "application/octet-stream; charset=UTF-8" - } else { - "text/plain; charset=UTF-8" - }, - ) - .header(axum::http::header::CONTENT_LENGTH, length) - .header("x-content-type-options", "nosniff") - .header("content-security-policy", "default-src 'none'") - .body(body) - .expect("object response is valid") -} diff --git a/crates/gilti/src/views/overview.rs b/crates/gilti/src/views/overview.rs deleted file mode 100644 --- a/crates/gilti/src/views/overview.rs +++ /dev/null @@ -1,155 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -use maud::{Markup, html}; - -pub async fn serve( - context: &super::shared::Context, - route: crate::router::RepoRoute<()>, - headers: &axum::http::HeaderMap, - method: axum::http::Method, -) -> axum::response::Response { - if method != axum::http::Method::GET && method != axum::http::Method::HEAD { - return super::method_not_allowed(); - } - let clone_url = clone_url(context, headers, &route.repo); - let repositories = context.repositories; - let model = tokio::task::spawn_blocking(move || { - crate::models::overview::Overview::load(std::path::Path::new(repositories), &route.repo) - }) - .await; - let model = match model { - Ok(Ok(model)) => model, - Ok(Err(error)) => return super::error(error), - Err(error) => { - return super::error(crate::models::Error::Internal(error.to_string())); - } - }; - let content = content(&model, &clone_url); - super::shared::render( - context, - &model.repository, - "HEAD", - super::shared::Page::Summary, - content, - &method, - ) -} - -fn content(model: &crate::models::overview::Overview, clone_url: &str) -> Markup { - let repo = super::shared::repository_url(&model.repository.name); - html! { - @if model.empty { - div class="error" { "Repository seems to be empty" } - } - table summary="repository info" class="list nowrap" { - @if !model.empty { - tr class="nohover" { - th class="left" { "Branch" } - th class="left" { "Commit message" } - th class="left" { "Author" } - th class="left" colspan="2" { "Age" } - } - @for branch in &model.branches { - @let revision = super::shared::encode_path(&branch.reference); - tr { - td { a href=(format!("{repo}/+/{revision}/+/log")) { (&branch.name) } } - td { a href=(format!("{repo}/+/{revision}")) { (&branch.subject) } } - td { (&branch.author) } - td colspan="2" { (super::shared::age(branch.timestamp)) } - } - } - @if !model.tags.is_empty() { - tr class="nohover" { td colspan="5" { " " } } - tr class="nohover" { - th class="left" { "Tag" } - th class="left" { "Download" } - th class="left" { "Author" } - th class="left" colspan="2" { "Age" } - } - @for tag in &model.tags { - @let revision = super::shared::encode_path(&tag.reference); - tr { - td { a href=(format!("{repo}/+/{revision}")) { (&tag.name) } } - td { - @if tag.downloadable { - @for (index, format) in ["tar", "tar.gz", "tar.bz2", "tar.lz", "tar.xz", "tar.zst", "zip"].iter().enumerate() { - @if index > 0 { " " } - (archive_link(&repo, &revision, format)) - } - } @else { - a href=(format!("{repo}/+/object/{}", tag.target)) { (&tag.target) } - } - } - td { (&tag.author) } - td colspan="2" { (super::shared::age(tag.timestamp)) } - } - } - } - @if !model.commits.is_empty() { - tr class="nohover" { td colspan="5" { " " } } - tr class="nohover" { - th class="left" { "Age" } - th class="left" { "Commit message" } - th class="left" { "Author" } - th class="left" { "Files" } - th class="left" { "Lines" } - } - @for commit in &model.commits { - tr { - td { (super::shared::age(commit.timestamp)) } - td { - a href=(format!("{repo}/+/{}", commit.oid)) { (&commit.subject) } - @if !commit.decorations.is_empty() { span class="decoration" { - @for decoration in &commit.decorations { - @if let Some(reference) = &decoration.reference { - @let revision = super::shared::encode_path(reference); - a class=(if decoration.tag { "tag-annotated-deco" } else { "branch-deco" }) href=(if decoration.tag { format!("{repo}/+/{revision}") } else { format!("{repo}/+/{revision}/+/log") }) { (&decoration.label) } - } @else { - a class="deco" href=(format!("{repo}/+/{}", commit.oid)) { (&decoration.label) } - } - } - } } - } - td { (&commit.author) } - td { (commit.files) } - td { span class="deletions" { "-" (commit.deletions) } "/" span class="insertions" { "+" (commit.insertions) } } - } - } - } - } - tr class="nohover" { td colspan="5" { " " } } - tr class="nohover" { th class="left" colspan="5" { "Clone" } } - tr { td colspan="5" { a rel="vcs-git" href=(clone_url) { (clone_url) } } } - } - } -} - -fn archive_link(repository: &str, revision: &str, format: &str) -> Markup { - html! { a href=(format!("{repository}/+/{revision}/+/archive?format={format}")) { (format) } } -} - -fn clone_url( - context: &super::shared::Context, - headers: &axum::http::HeaderMap, - repository: &str, -) -> String { - let repository = super::shared::encode_path(repository); - if !context.clone_prefix.is_empty() { - return format!( - "{}/{}.git", - context.clone_prefix.trim_end_matches('/'), - repository - ); - } - let scheme = headers - .get("x-forwarded-proto") - .and_then(|value| value.to_str().ok()) - .filter(|value| matches!(*value, "http" | "https")) - .unwrap_or("http"); - let host = headers - .get(axum::http::header::HOST) - .and_then(|value| value.to_str().ok()) - .unwrap_or("localhost"); - format!("{scheme}://{host}/{repository}.git") -} diff --git a/crates/gilti/src/views/patch.rs b/crates/gilti/src/views/patch.rs deleted file mode 100644 --- a/crates/gilti/src/views/patch.rs +++ /dev/null @@ -1,63 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -pub async fn serve( - context: &super::shared::Context, - route: crate::router::RepoRoute<crate::router::Comparison>, - method: axum::http::Method, -) -> axum::response::Response { - if method != axum::http::Method::GET && method != axum::http::Method::HEAD { - return super::method_not_allowed(); - } - let repositories = context.repositories; - let name = route.repo.clone(); - let old = route.params.old_rev.clone(); - let new = route.params.new_rev.clone(); - let model = tokio::task::spawn_blocking(move || { - crate::models::patch::Patch::load(std::path::Path::new(repositories), &name, &old, &new) - }) - .await; - let model = match model { - Ok(Ok(model)) => model, - Ok(Err(error)) => return super::error(error), - Err(error) => return super::error(crate::models::Error::Internal(error.to_string())), - }; - let mut command = tokio::process::Command::new(crate::GIT); - command - .arg("--git-dir") - .arg(&model.repository_path) - .arg("-c") - .arg("color.ui=false") - .arg("format-patch") - .arg("--stdout") - .arg("--keep-subject") - .arg("--no-renames") - .arg("--signature=Gilti") - .arg(format!("{}..{}", model.old_oid, model.new_oid)) - .env("GIT_CONFIG_GLOBAL", "/dev/null") - .env("GIT_CONFIG_NOSYSTEM", "1"); - if let Some(path) = &route.params.path { - command.arg("--").arg(format!(":(literal){path}")); - } - let output = match command.output().await { - Ok(output) if output.status.success() => output.stdout, - Ok(output) => { - eprintln!( - "gilti: git format-patch failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - return super::error(crate::models::Error::Internal( - "git format-patch failed".to_owned(), - )); - } - Err(error) => return super::error(crate::models::Error::Internal(error.to_string())), - }; - let filename = format!("{}..{}.patch", model.old_oid, model.new_oid); - super::bytes_response( - "text/plain; charset=UTF-8", - Some(format!("inline; filename=\"{filename}\"")), - None, - output, - &method, - ) -} diff --git a/crates/gilti/src/views/refs.rs b/crates/gilti/src/views/refs.rs deleted file mode 100644 --- a/crates/gilti/src/views/refs.rs +++ /dev/null @@ -1,87 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -use maud::{Markup, html}; - -pub async fn serve( - context: &super::shared::Context, - route: crate::router::RepoRoute<()>, - method: axum::http::Method, -) -> axum::response::Response { - if method != axum::http::Method::GET && method != axum::http::Method::HEAD { - return super::method_not_allowed(); - } - let repositories = context.repositories; - let model = tokio::task::spawn_blocking(move || { - crate::models::refs::Refs::load(std::path::Path::new(repositories), &route.repo) - }) - .await; - let model = match model { - Ok(Ok(model)) => model, - Ok(Err(error)) => return super::error(error), - Err(error) => { - return super::error(crate::models::Error::Internal(error.to_string())); - } - }; - let content = content(&model); - super::shared::render( - context, - &model.repository, - "HEAD", - super::shared::Page::Refs, - content, - &method, - ) -} - -fn content(model: &crate::models::refs::Refs) -> Markup { - let repo = super::shared::repository_url(&model.repository.name); - html! { table class="list nowrap" { - tr class="nohover" { - th class="left" { "Branch" } - th class="left" { "Commit message" } - th class="left" { "Author" } - th class="left" colspan="2" { "Age" } - } - @for branch in &model.branches { - @let revision = super::shared::encode_path(&branch.reference); - tr { - td { a href=(format!("{repo}/+/{revision}/+/log")) { (&branch.name) } } - td { a href=(format!("{repo}/+/{revision}")) { (&branch.subject) } } - td { (&branch.author) } - td colspan="2" { (super::shared::age(branch.timestamp)) } - } - } - @if !model.tags.is_empty() { - tr class="nohover" { td colspan="5" { " " } } - tr class="nohover" { - th class="left" { "Tag" } - th class="left" { "Download" } - th class="left" { "Author" } - th class="left" colspan="2" { "Age" } - } - } - @for tag in &model.tags { - @let revision = super::shared::encode_path(&tag.reference); - tr { - td { a href=(format!("{repo}/+/{revision}")) { (&tag.name) } } - td { - @if tag.downloadable { - @for (index, format) in ["tar", "tar.gz", "tar.bz2", "tar.lz", "tar.xz", "tar.zst", "zip"].iter().enumerate() { - @if index > 0 { " " } - (archive_link(&repo, &revision, format)) - } - } @else { - a href=(format!("{repo}/+/object/{}", tag.target)) { (&tag.target) } - } - } - td { (&tag.author) } - td colspan="2" { (super::shared::age(tag.timestamp)) } - } - } - } } -} - -fn archive_link(repository: &str, revision: &str, format: &str) -> Markup { - html! { a href=(format!("{repository}/+/{revision}/+/archive?format={format}")) { (format) } } -} diff --git a/crates/gilti/src/views/repositories.rs b/crates/gilti/src/views/repositories.rs deleted file mode 100644 --- a/crates/gilti/src/views/repositories.rs +++ /dev/null @@ -1,170 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -use maud::{DOCTYPE, html}; - -pub struct Query { - search: Option<String>, - sort: crate::models::repositories::Sort, - sort_name: &'static str, - offset: usize, -} - -impl Query { - pub fn from_request(query: &crate::RequestQuery) -> Self { - let (sort, sort_name) = match query.value("GILTI_QUERY_SORT") { - Some("desc") => (crate::models::repositories::Sort::Description, "desc"), - Some("owner") => (crate::models::repositories::Sort::Owner, "owner"), - Some("idle") => (crate::models::repositories::Sort::Idle, "idle"), - _ => (crate::models::repositories::Sort::Name, "name"), - }; - Self { - search: query.value("GILTI_QUERY_SEARCH").map(str::to_owned), - sort, - sort_name, - offset: query - .value("GILTI_QUERY_OFFSET") - .and_then(|offset| offset.parse().ok()) - .unwrap_or(0), - } - } -} - -pub async fn serve( - context: &super::shared::Context, - query: Query, - method: axum::http::Method, -) -> axum::response::Response { - if method != axum::http::Method::GET && method != axum::http::Method::HEAD { - return super::method_not_allowed(); - } - let repositories = context.repositories; - let filter = crate::models::repositories::Filter { - search: query.search.clone(), - sort: query.sort, - offset: query.offset, - limit: 50, - }; - let model = tokio::task::spawn_blocking(move || { - crate::models::repositories::Repositories::load(std::path::Path::new(repositories), filter) - }) - .await; - let model = match model { - Ok(Ok(model)) => model, - Ok(Err(error)) => return super::error(error), - Err(error) => { - return super::error(crate::models::Error::Internal(error.to_string())); - } - }; - let document = html! { - (DOCTYPE) - html lang="en" { - head { - title { (&*context.root_title) } - meta name="generator" content="Gilti"; - meta name="robots" content="index, nofollow"; - link rel="stylesheet" type="text/css" href="/-/assets/gilti.css"; - script type="text/javascript" src="/-/assets/gilti.js" {} - link rel="shortcut icon" href="/-/assets/favicon.ico"; - } - body { div id="gilti" { - table id="header" { - tr { - td class="logo" rowspan="2" { a href="/" { img src="/-/assets/gilti.png" alt="gilti logo"; } } - td class="main" { (&*context.root_title) } - } - tr { td class="sub" { (&*context.root_description) } } - } - table class="tabs" { tr { - td { a href="/" class="active" { "index" } } - td class="form" { form method="get" action="/" { - input type="search" name="q" size="10" value=(query.search.as_deref().unwrap_or_default()); - input type="submit" value="search"; - } } - } } - div class="content" { table summary="repository list" class="list nowrap" { - @if !model.rows.is_empty() { - tr class="nohover" { - th class="left" { a href=(sort_url("name", query.search.as_deref())) { "Name" } } - th class="left" { a href=(sort_url("desc", query.search.as_deref())) { "Description" } } - th class="left" { a href=(sort_url("owner", query.search.as_deref())) { "Owner" } } - th class="left" { a href=(sort_url("idle", query.search.as_deref())) { "Idle" } } - th class="left" { "Links" } - } - } - @for repository in &model.rows { - @let url = super::shared::repository_url(&repository.name); - tr { - td class="toplevel-repo" { a href=(&url) { (&repository.name) } } - td { a href=(&url) { (description(&repository.description)) } } - td { a href="/?q=" {} } - td { @if let Some(timestamp) = repository.timestamp { (super::shared::age(timestamp)) } } - td { - a class="button" href=(&url) { "summary" } - @if repository.populated { - a class="button" href=(format!("{url}/+/HEAD/+/log")) { "log" } - a class="button" href=(format!("{url}/+/HEAD/+/tree")) { "tree" } - } - } - } - } - } - @if model.offset > 0 || model.has_next { - ul class="pager" { - @if model.offset > 0 { - li { a href=(page_url(model.offset.saturating_sub(50), query.sort_name, query.search.as_deref())) { "[previous]" } } - } - @if model.has_next { - li { a href=(page_url(model.offset + 50, query.sort_name, query.search.as_deref())) { "[next]" } } - } - } - } - } - div class="footer" { "generated by Gilti" } - } } - } - } - .into_string(); - let length = document.len(); - let body = if method == axum::http::Method::HEAD { - axum::body::Body::empty() - } else { - axum::body::Body::from(document) - }; - axum::response::Response::builder() - .status(axum::http::StatusCode::OK) - .header(axum::http::header::CONTENT_TYPE, "text/html; charset=UTF-8") - .header(axum::http::header::CONTENT_LENGTH, length) - .body(body) - .expect("repository list response is valid") -} - -fn description(description: &str) -> String { - let mut characters = description.chars(); - let shown = characters.by_ref().take(80).collect::<String>(); - if characters.next().is_some() { - format!("{shown}...") - } else { - shown - } -} - -fn sort_url(sort: &str, search: Option<&str>) -> String { - search.map_or_else( - || format!("/?s={sort}"), - |search| format!("/?s={sort}&q={}", query_value(search)), - ) -} - -fn page_url(offset: usize, sort: &str, search: Option<&str>) -> String { - let mut url = format!("/?ofs={offset}&s={sort}"); - if let Some(search) = search { - url.push_str("&q="); - url.push_str(&query_value(search)); - } - url -} - -fn query_value(value: &str) -> String { - percent_encoding::utf8_percent_encode(value, percent_encoding::NON_ALPHANUMERIC).to_string() -} diff --git a/crates/gilti/src/views/revision.rs b/crates/gilti/src/views/revision.rs deleted file mode 100644 --- a/crates/gilti/src/views/revision.rs +++ /dev/null @@ -1,118 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -use maud::{Markup, html}; - -pub async fn serve( - context: &super::shared::Context, - route: crate::router::RepoRoute<crate::router::Revision>, - query: super::diff::Query, - method: axum::http::Method, -) -> axum::response::Response { - if method != axum::http::Method::GET && method != axum::http::Method::HEAD { - return super::method_not_allowed(); - } - let repositories = context.repositories; - let name = route.repo.clone(); - let revision = route.params.clone(); - let model = tokio::task::spawn_blocking(move || { - crate::models::commit::Commit::load( - std::path::Path::new(repositories), - &name, - revision, - query.options(), - ) - }) - .await; - let model = match model { - Ok(Ok(model)) => model, - Ok(Err(error)) => return super::error(error), - Err(error) => return super::error(crate::models::Error::Internal(error.to_string())), - }; - let content = content(&model, query); - super::shared::render_titled( - context, - &model.repository, - &model.revision, - super::shared::Page::Revision, - Some(&model.subject), - content, - &method, - ) -} - -fn content(model: &crate::models::commit::Commit, query: super::diff::Query) -> Markup { - let repo = super::shared::repository_url(&model.repository.name); - let revision = super::shared::encode_path(&model.revision); - html! { - (super::diff::content_controls(query)) - table summary="commit info" class="commit-info" { - tr { th { "author" } td { (&model.author.name) " <" (&model.author.email) ">" } - td class="right" { (timestamp(&model.author)) } } - tr { th { "committer" } td { (&model.committer.name) " <" (&model.committer.email) ">" } - td class="right" { (timestamp(&model.committer)) } } - tr { th { "commit" } td colspan="2" class="oid" { - a href=(format!("{repo}/+/{}", model.oid)) { (&model.oid) } - @let patch_old = model.parents.first().map_or("HEAD", String::as_str); - " (" a href=(format!("{repo}/+/patch/{patch_old}..{}", model.oid)) { "patch" } ")" - } } - tr { th { "tree" } td colspan="2" class="oid" { - a href=(format!("{repo}/+/{revision}/+/tree")) { (&model.tree) } - } } - @for parent in &model.parents { - tr { th { "parent" } td colspan="2" class="oid" { - a href=(format!("{repo}/+/{parent}")) { (parent) } - " (" a href=(format!("{repo}/+/diff/{parent}..{}", model.oid)) { "diff" } ")" - } } - } - tr { th { "download" } td colspan="2" class="oid" { - @for format in ["tar", "tar.gz", "tar.bz2", "tar.lz", "tar.xz", "tar.zst", "zip"] { - a href=(format!("{repo}/+/{revision}/+/archive?format={format}")) { (format) } br; - } - } } - } - div class="commit-subject" { - (&model.subject) - @for decoration in &model.decorations { - " (" - @if let Some(reference) = &decoration.reference { - @let reference = super::shared::encode_path(reference); - a href=(format!("{repo}/+/{reference}")) { (&decoration.label) } - } @else { (&decoration.label) } - ")" - } - } - div class="commit-msg" { (&model.message) } - @if let Some(notes) = &model.notes { - div class="notes-header" { "Notes" } - div class="notes" { (notes) } - div class="notes-footer" {} - } - @if let Some(diff) = &model.diff { - (super::diff::content(diff, query, None, false)) - } - } -} - -fn timestamp(identity: &crate::models::commit::Identity) -> String { - let adjusted = identity.timestamp + i64::from(identity.offset_minutes) * 60; - let value = crate::models::time::utc(adjusted) - .unwrap_or_else(|| crate::models::time::utc(0).expect("the Unix epoch is representable")); - let sign = if identity.offset_minutes < 0 { - '-' - } else { - '+' - }; - let offset = identity.offset_minutes.unsigned_abs(); - format!( - "{:04}-{:02}-{:02} {:02}:{:02}:{:02} {sign}{:02}{:02}", - value.tm_year + 1900, - value.tm_mon + 1, - value.tm_mday, - value.tm_hour, - value.tm_min, - value.tm_sec, - offset / 60, - offset % 60 - ) -} diff --git a/crates/gilti/src/views/shared.rs b/crates/gilti/src/views/shared.rs deleted file mode 100644 --- a/crates/gilti/src/views/shared.rs +++ /dev/null @@ -1,148 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -use maud::{DOCTYPE, Markup, html}; - -#[derive(Clone)] -pub struct Context { - pub repositories: &'static str, - pub root_title: std::sync::Arc<str>, - pub root_description: std::sync::Arc<str>, - pub clone_prefix: std::sync::Arc<str>, -} - -#[derive(Clone, Copy, Eq, PartialEq)] -pub enum Page { - About, - Summary, - Refs, - Log, - Tree, - Revision, - Diff, - Stats, -} - -pub fn render( - context: &Context, - repository: &crate::models::repository::Info, - revision: &str, - active: Page, - content: Markup, - method: &axum::http::Method, -) -> axum::response::Response { - render_titled(context, repository, revision, active, None, content, method) -} - -pub fn render_titled( - context: &Context, - repository: &crate::models::repository::Info, - revision: &str, - active: Page, - page_title: Option<&str>, - content: Markup, - method: &axum::http::Method, -) -> axum::response::Response { - let repo = repository_url(&repository.name); - let rev = encode_path(revision); - let title = page_title.map_or_else( - || format!("{} - {}", repository.name, repository.description), - |page_title| { - format!( - "{page_title} - {} - {}", - repository.name, repository.description - ) - }, - ); - let document = html! { - (DOCTYPE) - html lang="en" { - head { - title { (title) } - meta name="generator" content="Gilti"; - meta name="robots" content="index, nofollow"; - link rel="stylesheet" type="text/css" href="/-/assets/gilti.css"; - script type="text/javascript" src="/-/assets/gilti.js" {} - link rel="shortcut icon" href="/-/assets/favicon.ico"; - } - body { div id="gilti" { - table id="header" { - tr { - td class="logo" rowspan="2" { a href="/" { img src="/-/assets/gilti.png" alt="gilti logo"; } } - td class="main" { (&*context.root_title) " : " a href=(&repo) { (&repository.name) } } - } - tr { td class="sub" { (&repository.description) } } - } - table class="tabs" { tr { td { - @if repository.has_readme { - (tab(&format!("{repo}/+/about"), "about", active == Page::About)) - } - (tab(&repo, "summary", active == Page::Summary)) - (tab(&format!("{repo}/+/refs"), "refs", active == Page::Refs)) - (tab(&format!("{repo}/+/{rev}/+/log"), "log", active == Page::Log)) - (tab(&format!("{repo}/+/{rev}/+/tree"), "tree", active == Page::Tree)) - (tab(&format!("{repo}/+/{rev}"), "commit", active == Page::Revision)) - (tab(&format!("{repo}/+/diff/HEAD..{rev}"), "diff", active == Page::Diff)) - (tab(&format!("{repo}/+/stats"), "stats", active == Page::Stats)) - } } } - div class="content" { (content) } - div class="footer" { "generated by Gilti" } - } } - } - } - .into_string(); - let length = document.len(); - let body = if method == axum::http::Method::HEAD { - axum::body::Body::empty() - } else { - axum::body::Body::from(document) - }; - axum::response::Response::builder() - .status(axum::http::StatusCode::OK) - .header(axum::http::header::CONTENT_TYPE, "text/html; charset=UTF-8") - .header(axum::http::header::CONTENT_LENGTH, length) - .body(body) - .expect("HTML response is valid") -} - -fn tab(url: &str, label: &str, active: bool) -> Markup { - html! { a href=(url) class=[active.then_some("active")] { (label) } } -} - -pub fn repository_url(repository: &str) -> String { - format!("/{}", encode_path(repository)) -} - -pub fn encode_path(value: &str) -> String { - let mut encoded = String::new(); - for byte in value.bytes() { - if byte.is_ascii_alphanumeric() || byte == b'/' || byte == b'_' { - encoded.push(char::from(byte)); - } else { - use std::fmt::Write; - write!(encoded, "%{byte:02x}").expect("writing to String cannot fail"); - } - } - encoded -} - -pub fn age(timestamp: i64) -> String { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_or(0, |duration| duration.as_secs() as i64); - let seconds = now.saturating_sub(timestamp).max(0); - let (amount, unit) = if seconds < 120 * 60 { - (seconds / 60, "min.") - } else if seconds < 48 * 60 * 60 { - (seconds / (60 * 60), "hours") - } else if seconds < 14 * 24 * 60 * 60 { - (seconds / (24 * 60 * 60), "days") - } else if seconds < 60 * 24 * 60 * 60 { - (seconds / (7 * 24 * 60 * 60), "weeks") - } else if seconds < 730 * 24 * 60 * 60 { - (seconds / (30 * 24 * 60 * 60), "months") - } else { - (seconds / (365 * 24 * 60 * 60), "years") - }; - format!("{amount} {unit} ago") -} diff --git a/crates/gilti/src/views/stats.rs b/crates/gilti/src/views/stats.rs deleted file mode 100644 --- a/crates/gilti/src/views/stats.rs +++ /dev/null @@ -1,122 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -use maud::{Markup, html}; - -pub struct Query { - pub period: crate::models::stats::Period, - pub code: &'static str, - pub top: Option<usize>, -} - -pub enum QueryError { - BadRequest, - NotFound, -} - -impl Query { - pub fn from_request(query: &crate::RequestQuery) -> Result<Self, QueryError> { - let (period, code) = match query.value("GILTI_QUERY_PERIOD") { - None | Some("w" | "week") => (crate::models::stats::Period::Week, "w"), - Some("m" | "month") => (crate::models::stats::Period::Month, "m"), - Some("q" | "quarter") => (crate::models::stats::Period::Quarter, "q"), - Some("y" | "year") => (crate::models::stats::Period::Year, "y"), - Some(_) => return Err(QueryError::NotFound), - }; - let top = match query.value("GILTI_QUERY_OFFSET") { - None | Some("0") => Some(10), - Some("-1") => None, - Some(value) => Some( - value - .parse::<usize>() - .ok() - .filter(|value| *value > 0) - .ok_or(QueryError::BadRequest)?, - ), - }; - Ok(Self { period, code, top }) - } -} - -pub async fn serve( - context: &super::shared::Context, - route: crate::router::RepoRoute<()>, - query: Query, - method: axum::http::Method, -) -> axum::response::Response { - if method != axum::http::Method::GET && method != axum::http::Method::HEAD { - return super::method_not_allowed(); - } - let repositories = context.repositories; - let name = route.repo; - let model = tokio::task::spawn_blocking(move || { - crate::models::stats::Stats::load(std::path::Path::new(repositories), &name, query.period) - }) - .await; - let model = match model { - Ok(Ok(model)) => model, - Ok(Err(error)) => return super::error(error), - Err(error) => return super::error(crate::models::Error::Internal(error.to_string())), - }; - let content = content(&model, &query); - super::shared::render( - context, - &model.repository, - "HEAD", - super::shared::Page::Stats, - content, - &method, - ) -} - -fn content(model: &crate::models::stats::Stats, query: &Query) -> Markup { - let shown = query - .top - .unwrap_or(model.authors.len()) - .min(model.authors.len()); - html! { - div class="gilti-panel" { - b { "stat options" } - form method="get" { table { - tr { td colspan="2" {} } - tr { td class="label" { "Period:" } td class="ctrl" { - select name="period" onchange="this.form.submit();" { - @for (code, name) in [("w","week"),("m","month"),("q","quarter"),("y","year")] { - option value=(code) selected[query.code == code] { (name) } - } - } - } } - tr { td class="label" { "Authors:" } td class="ctrl" { - select name="ofs" onchange="this.form.submit();" { - @for value in [10_usize,25,50,100] { - option value=(value) selected[query.top == Some(value)] { (value) } - } - option value="-1" selected[query.top.is_none()] { "all" } - } - } } - tr { td {} td class="ctrl" { noscript { input type="submit" value="Reload"; } } } - } } - } - h2 { "Commits per author per " (query.period.name()) } - table class="stats" { - tr { th { "Author" } @for label in &model.labels { th { (label) } } th { "Total" } } - @for author in &model.authors[..shown] { - tr { td class="left" { (&author.name) } - @for count in &author.counts { td { (count) } } - td class="sum" { (author.total) } - } - } - @if shown < model.authors.len() { - @let others = &model.authors[shown..]; - tr { td class="left" { "Others (" (others.len()) ")" } - @for index in 0..4 { td { (others.iter().map(|author| author.counts[index]).sum::<usize>()) } } - td class="sum" { (others.iter().map(|author| author.total).sum::<usize>()) } - } - } - tr { td class="total" { "Total" } - @for total in &model.totals { td class="sum" { (total) } } - td class="sum" { (model.totals.iter().sum::<usize>()) } - } - } - } -} diff --git a/crates/gilti/src/views/tag.rs b/crates/gilti/src/views/tag.rs deleted file mode 100644 --- a/crates/gilti/src/views/tag.rs +++ /dev/null @@ -1,76 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -use maud::{Markup, html}; - -pub async fn serve( - context: &super::shared::Context, - route: crate::router::RepoRoute<crate::router::Revision>, - method: axum::http::Method, -) -> axum::response::Response { - if method != axum::http::Method::GET && method != axum::http::Method::HEAD { - return super::method_not_allowed(); - } - let crate::router::Revision::Ref(reference) = route.params else { - return super::error(crate::models::Error::NotFound); - }; - let repositories = context.repositories; - let model = tokio::task::spawn_blocking(move || { - crate::models::tag::Tag::load(std::path::Path::new(repositories), &route.repo, reference) - }) - .await; - let model = match model { - Ok(Ok(model)) => model, - Ok(Err(error)) => return super::error(error), - Err(error) => { - return super::error(crate::models::Error::Internal(error.to_string())); - } - }; - let content = content(&model); - super::shared::render( - context, - &model.repository, - &model.reference, - super::shared::Page::Revision, - content, - &method, - ) -} - -fn content(model: &crate::models::tag::Tag) -> Markup { - let repo = super::shared::repository_url(&model.repository.name); - let revision = super::shared::encode_path(&model.reference); - html! { - table class="commit-info" { - tr { td { "tag name" } td { (&model.name) @if model.annotated { " (" (&model.oid) ")" } } } - @if let Some(timestamp) = model.timestamp { - tr { td { "tag date" } td { (super::shared::age(timestamp)) } } - } - @if model.annotated { - tr { td { "tagged by" } td { (&model.tagger) " <" (&model.tagger_email) ">" } } - } - tr { td { "tagged object" } td class="oid" { - @for (index, target) in model.targets.iter().enumerate() { - @if index > 0 { " → " } - @if target.commit { - a href=(format!("{repo}/+/{}", target.oid)) { (&target.oid) } - } @else { - a href=(format!("{repo}/+/object/{}", target.oid)) { (&target.oid) } - } - } - } } - @if model.downloadable { - tr { td { "download" } td class="oid" { - @for format in ["tar", "tar.gz", "tar.bz2", "tar.lz", "tar.xz", "tar.zst", "zip"] { - a href=(format!("{repo}/+/{revision}/+/archive?format={format}")) { (format) } br; - } - } } - } - } - @if model.annotated { - @let mut lines = model.message.splitn(2, '\n'); - div class="commit-subject" { (lines.next().unwrap_or_default()) } - div class="commit-msg" { (lines.next().unwrap_or_default()) } - } - } -} diff --git a/crates/gilti/src/views/tree.rs b/crates/gilti/src/views/tree.rs deleted file mode 100644 --- a/crates/gilti/src/views/tree.rs +++ /dev/null @@ -1,293 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -use maud::{DOCTYPE, Markup, html}; - -pub async fn serve( - context: &super::shared::Context, - route: crate::router::RepoRoute<crate::router::RevisionPath>, - format: Option<&str>, - method: axum::http::Method, -) -> axum::response::Response { - if method != axum::http::Method::GET && method != axum::http::Method::HEAD { - return super::method_not_allowed(); - } - let repositories = context.repositories; - let model = tokio::task::spawn_blocking(move || { - crate::models::tree::Tree::load( - std::path::Path::new(repositories), - &route.repo, - route.params.rev, - route.params.path, - ) - }) - .await; - let model = match model { - Ok(Ok(model)) => model, - Ok(Err(error)) => return super::error(error), - Err(error) => { - return super::error(crate::models::Error::Internal(error.to_string())); - } - }; - if format == Some("raw") { - return raw(model, &method); - } - let content = html_content(&model); - super::shared::render( - context, - &model.repository, - &model.revision, - super::shared::Page::Tree, - content, - &method, - ) -} - -fn html_content(model: &crate::models::tree::Tree) -> Markup { - let repo = super::shared::repository_url(&model.repository.name); - let revision = super::shared::encode_path(&model.revision); - let prefix = format!("{repo}/+/{revision}/+/tree"); - html! { - @if let Some(path) = &model.path { - div class="path" { "path: " (breadcrumbs(&prefix, path)) } - } - @match &model.content { - crate::models::tree::Content::Directory { entries, .. } => { - table summary="tree listing" class="list" { - tr class="nohover" { - th class="left" { "Mode" } - th class="left" { "Name" } - th class="right" { "Size" } - th {} - } - @for entry in entries { - @let path = super::shared::encode_path(&entry.path); - tr { - td class="ls-mode" { (filemode(entry.mode)) } - td { - @match entry.kind { - crate::models::tree::Kind::Submodule => { - span class="ls-mod" { (&entry.name) " @ " (&entry.oid) } - } - _ => { - a href=(format!("{prefix}/{path}")) class=(entry_class(entry)) { (&entry.name) } - @if let Some(target) = &entry.symlink_target { - " -> " a href=(format!("{prefix}/{}", super::shared::encode_path(&symlink_path(&entry.path, target)))) class="ls-blob" { (target) } - } - } - } - } - td class="ls-size" { (entry.size) } - td { - a class="button" href=(format!("{repo}/+/{revision}/+/log/{path}")) { "log" } - a class="button" href=(format!("{repo}/+/stats")) { "stats" } - @if entry.kind != crate::models::tree::Kind::Submodule { - a class="button" href=(format!("{prefix}/{path}?format=raw")) { "plain" } - } - @if entry.kind == crate::models::tree::Kind::Blob && entry.symlink_target.is_none() { - a class="button" href=(format!("{repo}/+/{revision}/+/blame/{path}")) { "blame" } - } - } - } - } - } - } - crate::models::tree::Content::Blob { oid, bytes, binary } => { - "blob: " (oid) " (" - a href=(format!("{prefix}/{}?format=raw", super::shared::encode_path(model.path.as_deref().unwrap_or_default()))) { "plain" } - @if !binary { ") (" a href=(format!("{repo}/+/{revision}/+/blame/{}", super::shared::encode_path(model.path.as_deref().unwrap_or_default()))) { "blame" } } - ")" - @if *binary { (binary_blob(bytes)) } @else { (text_blob(bytes)) } - } - } - } -} - -pub(super) fn filemode(mode: u32) -> String { - let mut value = String::with_capacity(10); - value.push(match mode { - 0o040000 => 'd', - 0o120000 => 'l', - 0o160000 => 'm', - _ => '-', - }); - for bit in [ - 0o400, 0o200, 0o100, 0o040, 0o020, 0o010, 0o004, 0o002, 0o001, - ] { - value.push(if mode & bit == 0 { - '-' - } else { - match bit { - 0o400 | 0o040 | 0o004 => 'r', - 0o200 | 0o020 | 0o002 => 'w', - _ => 'x', - } - }); - } - value -} - -fn entry_class(entry: &crate::models::tree::Entry) -> String { - if entry.kind == crate::models::tree::Kind::Tree { - return "ls-dir".to_owned(); - } - let extension = entry.name.rsplit_once('.').map(|(_, extension)| extension); - extension.map_or_else( - || "ls-blob".to_owned(), - |extension| format!("ls-blob {extension}"), - ) -} - -fn symlink_path(path: &str, target: &str) -> String { - let mut parts = path.rsplit_once('/').map_or_else(Vec::new, |(parent, _)| { - parent.split('/').collect::<Vec<_>>() - }); - for part in target.split('/') { - match part { - "" | "." => {} - ".." => { - parts.pop(); - } - part => parts.push(part), - } - } - parts.join("/") -} - -fn breadcrumbs(prefix: &str, path: &str) -> Markup { - let parts = path.split('/').collect::<Vec<_>>(); - html! { - a href=(prefix) { "root" } - @for (index, part) in parts.iter().enumerate() { - "/" - @let path = parts[..=index].join("/"); - a href=(format!("{prefix}/{}", super::shared::encode_path(&path))) { (part) } - } - } -} - -fn text_blob(bytes: &[u8]) -> Markup { - let text = String::from_utf8_lossy(bytes); - let lines = if bytes.is_empty() { - 0 - } else { - bytes.iter().filter(|byte| **byte == b'\n').count() - + usize::from(bytes.last() != Some(&b'\n')) - }; - html! { table summary="blob content" class="blob" { tr { - td class="linenumbers" { pre { - @for line in 1..=lines { a id=(format!("n{line}")) href=(format!("#n{line}")) { (line) } "\n" } - } } - td class="lines" { pre { code { (text) } } } - } } } -} - -fn binary_blob(bytes: &[u8]) -> Markup { - html! { table summary="blob content" class="bin-blob" { - tr { th { "ofs" } th { "hex dump" } th { "ascii" } } - @for (row, chunk) in bytes.chunks(32).enumerate() { - tr { - td class="right" { (format!("{:04x}", row * 32)) } - td class="hex" { (chunk.iter().map(|byte| format!("{byte:02x}")).collect::<Vec<_>>().join(" ")) } - td class="hex" { (chunk.iter().map(|byte| if byte.is_ascii_graphic() { char::from(*byte) } else { '.' }).collect::<String>()) } - } - } - } } -} - -fn raw(model: crate::models::tree::Tree, method: &axum::http::Method) -> axum::response::Response { - let filename = model.path.as_deref().map(content_disposition); - match model.content { - crate::models::tree::Content::Blob { oid, bytes, binary } => { - let length = bytes.len(); - let body = if method == axum::http::Method::HEAD { - axum::body::Body::empty() - } else { - axum::body::Body::from(bytes) - }; - axum::response::Response::builder() - .status(axum::http::StatusCode::OK) - .header( - axum::http::header::CONTENT_TYPE, - if binary { - "application/octet-stream" - } else { - "text/plain; charset=UTF-8" - }, - ) - .header(axum::http::header::CONTENT_LENGTH, length) - .header(axum::http::header::ETAG, format!("\"{oid}\"")) - .header( - axum::http::header::CONTENT_DISPOSITION, - filename.as_deref().unwrap_or("inline"), - ) - .body(body) - .expect("raw blob response is valid") - } - crate::models::tree::Content::Directory { oid, entries } => { - let repo = super::shared::repository_url(&model.repository.name); - let revision = super::shared::encode_path(&model.revision); - let current = model.path.as_deref().unwrap_or_default(); - let title = if current.is_empty() { - "/".to_owned() - } else { - format!("/{current}/") - }; - let document = html! { - (DOCTYPE) - html { head { title { (&title) } } body { - h2 { (&title) } - ul { - @if let Some((parent, _)) = current.rsplit_once('/') { - li { a href=(format!("{repo}/+/{revision}/+/tree/{}?format=raw", super::shared::encode_path(parent))) { "../" } } - } @else if !current.is_empty() { - li { a href=(format!("{repo}/+/{revision}/+/tree?format=raw")) { "../" } } - } - @for entry in entries { - @let path = super::shared::encode_path(&entry.path); - li { - @if entry.kind == crate::models::tree::Kind::Submodule { - (&entry.name) " @ " (&entry.oid) - } @else { - a href=(format!("{repo}/+/{revision}/+/tree/{path}?format=raw")) { (&entry.name) @if entry.kind == crate::models::tree::Kind::Tree { "/" } } - } - } - } - } - } } - } - .into_string(); - let length = document.len(); - let body = if method == axum::http::Method::HEAD { - axum::body::Body::empty() - } else { - axum::body::Body::from(document) - }; - axum::response::Response::builder() - .status(axum::http::StatusCode::OK) - .header(axum::http::header::CONTENT_TYPE, "text/html; charset=UTF-8") - .header(axum::http::header::CONTENT_LENGTH, length) - .header(axum::http::header::ETAG, format!("\"{oid}\"")) - .body(body) - .expect("raw directory response is valid") - } - } -} - -fn content_disposition(path: &str) -> String { - let name = path.rsplit('/').next().unwrap_or(path); - let fallback = name - .chars() - .map(|character| { - if character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_') { - character - } else { - '_' - } - }) - .collect::<String>(); - format!( - "inline; filename=\"{fallback}\"; filename*=UTF-8''{}", - super::shared::encode_path(name) - ) -} diff --git a/deny.toml b/deny.toml deleted file mode 100644 --- a/deny.toml +++ /dev/null @@ -1,32 +0,0 @@ -# SPDX-FileCopyrightText: 2026 Nikolay Govorov -# SPDX-License-Identifier: AGPL-3.0-or-later - -[licenses] -allow = [ - # Dilution licenses available without restrictions - "MIT", - "ISC", - "Apache-2.0", - "BSD-3-Clause", - - # Custom but acceptable free licenses - "Zlib", - "Unicode-3.0", - - # GPL versions - "GPL-2.0-or-later", - "GPL-3.0-or-later", - "LGPL-2.0-or-later", - "LGPL-2.1-or-later", - "LGPL-3.0-or-later", -] -exceptions = [ - # The project itself is distributed under the AGPL, but avoid it in dependencies - { crate = "gilti", allow = ["AGPL-3.0-or-later"] }, - { crate = "gilti-ssh", allow = ["AGPL-3.0-or-later"] }, -] -unused-allowed-license = "allow" - -[sources] -unknown-git = "deny" -unknown-registry = "deny" diff --git a/mise.lock b/mise.lock index 8cccd80..08e84e7 100644 --- a/mise.lock +++ b/mise.lock @@ -32,14 +32,6 @@ url = "https://get.helm.sh/helm-v4.1.1-darwin-amd64.tar.gz" checksum = "sha256:bd0528a18d8a22431426eab3138a9479dfae00350719ac4150e81b123bfd6081" url = "https://get.helm.sh/helm-v4.1.1-windows-amd64.tar.gz" -[[tools.rust]] -version = "1.97.1" -backend = "core:rust" - -[tools.rust.options] -components = "clippy,llvm-tools-preview,rustfmt" -profile = "minimal" - [[tools.shellcheck]] version = "0.11.0" backend = "aqua:koalaman/shellcheck" diff --git a/mise.toml b/mise.toml index 37ffe1c..b1d7610 100644 --- a/mise.toml +++ b/mise.toml @@ -8,11 +8,10 @@ experimental = true [tools] helm = "4.1.1" -rust = { version = "1.97.1", profile = "minimal", components = ["clippy", "llvm-tools-preview", "rustfmt"] } shellcheck = "0.11.0" [task_config] dir = "{{cwd}}" includes = [ - "git::https://git.dimidiumlabs.io/platform.git//tasks?ref=8bc35fe8be889c50db2d1fb4425cc1b4097dc6b8", + "git::https://github.com/dimidiumlabs/infra.git//tasks?ref=8bc35fe8be889c50db2d1fb4425cc1b4097dc6b8", ] diff --git a/scripts/build-artifacts.sh b/scripts/build-artifacts.sh deleted file mode 100755 --- a/scripts/build-artifacts.sh +++ /dev/null @@ -1,39 +0,0 @@ -#!/bin/sh -# SPDX-FileCopyrightText: 2026 Nikolay Govorov -# SPDX-License-Identifier: AGPL-3.0-or-later - -set -eu -umask 022 - -arch=${1:?usage: build-artifacts.sh ARCH} -case $(uname -m) in - x86_64) native_arch=amd64 ;; - aarch64) native_arch=arm64 ;; - *) echo "unsupported build architecture: $(uname -m)" >&2; exit 1 ;; -esac -[ "$arch" = "$native_arch" ] || { - echo "requested $arch artifacts on $native_arch" >&2 - exit 1 -} -ldd --version 2>&1 | grep -q musl || { - echo 'container artifacts must be built in a musl environment' >&2 - exit 1 -} - -workspace=$PWD -build=$(mktemp -d) -trap 'rm -rf "$build"' EXIT INT TERM - -CARGO_TARGET_DIR="$build/target" cargo build \ - --manifest-path "$workspace/Cargo.toml" \ - --locked \ - --release \ - --workspace -output="$workspace/.container/binary-$arch" -rm -rf "$output" -install -Dm0755 "$build/target/release/gilti" "$output/gilti" -install -Dm0755 "$build/target/release/gilti-ssh" "$output/gilti-ssh" -install -Dm0644 crates/gilti/assets/gilti.css "$output/gilti.css" -install -Dm0644 crates/gilti/assets/gilti.js "$output/gilti.js" -install -Dm0644 crates/gilti/assets/gilti.png "$output/gilti.png" -install -Dm0644 crates/gilti/assets/favicon.ico "$output/favicon.ico" diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index 6da7570..4859c63 100755 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -4,79 +4,113 @@ # shellcheck shell=dash set -eu -umask 077 state=/var/lib/gilti run_dir=/run/gilti -ssh_run_dir=$run_dir/ssh +cache_dir=/var/cache/cgit git_home=$state/git -repositories=$git_home/repositories host_key_dir=$state/ssh -authorized_keys_source=${GILTI_AUTHORIZED_KEYS_FILE:-/etc/gilti/authorized_keys} -authorized_keys=$ssh_run_dir/authorized_keys + +bootstrap_dir=/run/gilti-bootstrap +admin_key=${GILTI_ADMIN_KEY_FILE:-$bootstrap_dir/admin.pub} log() { printf 'gilti: %s\n' "$*" >&2 } -prepare_runtime() { - [ "$(id -u)" -eq 0 ] || { log "the supervisor must start as root"; exit 1; } - for path in "$git_home" "$repositories"; do - [ ! -L "$path" ] || { log "refusing symlinked state path $path"; exit 1; } - done - install -d -m 0755 -o root -g root "$state" - install -d -m 0750 -o git -g git "$git_home" "$repositories" - install -d -m 0700 -o root -g root "$host_key_dir" - install -d -m 0755 -o root -g root "$run_dir" - install -d -m 0750 -o root -g git "$ssh_run_dir" - rm -f "$ssh_run_dir/sshd.pid" "$authorized_keys" "$authorized_keys".* +run_as_git() { + su-exec git:git env HOME="$git_home" USER=git LOGNAME=git "$@" +} + +validate_public_key_file() { + key_file=$1 + [ "$(awk 'NF && $1 !~ /^#/ { count++ } END { print count + 0 }' "$key_file")" -eq 1 ] && + ssh-keygen -l -f "$key_file" >/dev/null 2>&1 } -prepare_authorized_keys() { - [ -f "$authorized_keys_source" ] && [ -r "$authorized_keys_source" ] || { - log "SSH public keys are required at $authorized_keys_source" +validate_bootstrap_keys() { + [ -r "$admin_key" ] || { + log "fresh state requires an admin public key at $admin_key" exit 1 } + for key_file in "$bootstrap_dir"/*.pub; do + [ -e "$key_file" ] || continue + validate_public_key_file "$key_file" || { + log "bootstrap key $key_file is not exactly one valid SSH public key" + exit 1 + } + done +} - output=$authorized_keys.tmp.$$ - candidate=$authorized_keys.key.$$ - : >"$output" +stage_additional_admin_keys() { + install -d -m 0750 -o git -g git "$git_home/.gitolite" + install -d -m 0750 -o git -g git "$git_home/.gitolite/keydir" + install -d -m 0750 -o git -g git "$git_home/.gitolite/logs" count=0 - while IFS= read -r key || [ -n "$key" ]; do - case $key in - ''|'#'*) continue ;; - esac - case $key in - ssh-*|ecdsa-*|sk-*) ;; - *) - rm -f "$output" "$candidate" - log "$authorized_keys_source contains an invalid SSH public key" - exit 1 - ;; - esac - printf '%s\n' "$key" >"$candidate" - if ! ssh-keygen -l -f "$candidate" >/dev/null 2>&1; then - rm -f "$output" "$candidate" - log "$authorized_keys_source contains an invalid SSH public key" - exit 1 - fi - printf 'restrict %s\n' "$key" >>"$output" + for key_file in "$bootstrap_dir"/*.pub; do + [ -e "$key_file" ] || continue + [ "$key_file" = "$admin_key" ] && continue count=$((count + 1)) - done <"$authorized_keys_source" - rm -f "$candidate" + destination=$git_home/.gitolite/keydir/gilti-bootstrap-$count + install -d -m 0750 -o git -g git "$destination" + install -m 0644 -o git -g git "$key_file" "$destination/admin.pub" + done +} - if [ "$count" -eq 0 ]; then - rm -f "$output" - log "$authorized_keys_source contains no SSH public keys" - exit 1 +prepare_runtime() { + [ "$(id -u)" -eq 0 ] || { log "the supervisor must start as root"; exit 1; } + install -d -m 0755 -o root -g root "$state" + install -d -m 0750 -o git -g git "$git_home" "$cache_dir" + install -d -m 0700 -o root -g root "$host_key_dir" + install -d -m 0755 "$run_dir" + chown git:git "$cache_dir" + rm -f "$run_dir/fcgiwrap.sock" "$run_dir/nginx.pid" "$run_dir/sshd.pid" +} + +state_status() { + complete=true + for path in .gitolite.rc .gitolite repositories .ssh/authorized_keys; do + [ -e "$git_home/$path" ] || complete=false + done + if [ "$complete" = true ]; then + printf '%s\n' complete + return + fi + + partial=false + for path in .gitolite.rc .gitolite repositories .ssh/authorized_keys projects.list; do + [ ! -e "$git_home/$path" ] || partial=true + done + if [ "$partial" = true ]; then + printf '%s\n' partial + else + printf '%s\n' fresh fi - chown root:git "$output" - chmod 0640 "$output" - mv -f "$output" "$authorized_keys" } -prepare_host_key() { +initialize() { + prepare_runtime + + case $(state_status) in + complete) + ;; + partial) + log "refusing to overwrite partial Gitolite state in $git_home" + exit 1 + ;; + fresh) + validate_bootstrap_keys + stage_additional_admin_keys + log "initializing Gitolite" + run_as_git gitolite setup -pk "$admin_key" + ;; + esac + + if [ ! -e "$git_home/projects.list" ]; then + install -m 0640 -o git -g git /dev/null "$git_home/projects.list" + fi + host_key=$host_key_dir/ssh_host_ed25519_key if [ -L "$host_key" ]; then log "refusing symlinked SSH host key" @@ -98,53 +132,51 @@ prepare_host_key() { ssh-keygen -y -f "$host_key" >"$host_key.pub.tmp" chmod 0644 "$host_key.pub.tmp" mv -f "$host_key.pub.tmp" "$host_key.pub" -} -prepare() { - prepare_runtime - prepare_authorized_keys - prepare_host_key - /usr/local/bin/gilti --check - /usr/local/bin/gilti-ssh --check + nginx -t -e /dev/stderr -c /etc/nginx/nginx.conf /usr/sbin/sshd -t -f /etc/ssh/sshd_config } stop_services() { trap - TERM INT HUP - for pid in ${httpd_pid:-} ${sshd_pid:-}; do + for pid in ${fcgi_pid:-} ${sshd_pid:-} ${nginx_pid:-}; do kill -TERM "$pid" 2>/dev/null || true done attempts=0 while [ "$attempts" -lt 50 ]; do running=false - for pid in ${httpd_pid:-} ${sshd_pid:-}; do + for pid in ${fcgi_pid:-} ${sshd_pid:-} ${nginx_pid:-}; do kill -0 "$pid" 2>/dev/null && running=true done [ "$running" = true ] || break attempts=$((attempts + 1)) sleep 0.1 done - for pid in ${httpd_pid:-} ${sshd_pid:-}; do + for pid in ${fcgi_pid:-} ${sshd_pid:-} ${nginx_pid:-}; do kill -KILL "$pid" 2>/dev/null || true wait "$pid" 2>/dev/null || true done } supervise() { - prepare + initialize trap stop_services TERM INT HUP + HOME="$git_home" spawn-fcgi -n \ + -s "$run_dir/fcgiwrap.sock" -M 0660 -U git -G git \ + -u git -g git -- /usr/bin/fcgiwrap -f & + fcgi_pid=$! + /usr/sbin/sshd -D -e -f /etc/ssh/sshd_config & sshd_pid=$! - su-exec git:git env HOME="$git_home" USER=git LOGNAME=git \ - /usr/local/bin/gilti & - httpd_pid=$! + nginx -e /dev/stderr -c /etc/nginx/nginx.conf -g 'daemon off;' & + nginx_pid=$! while :; do - for pid in "$sshd_pid" "$httpd_pid"; do + for pid in "$fcgi_pid" "$sshd_pid" "$nginx_pid"; do if ! kill -0 "$pid" 2>/dev/null; then - if wait "$pid"; then status=1; else status=$?; fi + if wait "$pid"; then status=0; else status=$?; fi log "a service exited; stopping the pod" stop_services exit "$status" @@ -155,6 +187,9 @@ supervise() { } case ${1:-serve} in + init) + initialize + ;; serve) supervise ;; diff --git a/tests/chart.sh b/tests/chart.sh index 42f6910..d649656 100755 --- a/tests/chart.sh +++ b/tests/chart.sh @@ -15,7 +15,7 @@ injected=$(mktemp) trap 'rm -f "$rendered" "$invalid" "$injected"' EXIT helm template gilti "$chart" \ - --set-string 'ssh.authorizedKeys[0]=ssh-ed25519 AAAAcharttest gilti' \ + --set bootstrap.existingSecret=gilti-bootstrap \ --set httpRoute.enabled=true \ --set 'httpRoute.hostnames[0]=git.example.test' \ --set 'httpRoute.parentRefs[0].name=public' \ @@ -26,17 +26,17 @@ grep -q '^kind: HTTPRoute$' "$rendered" grep -q '^kind: TCPRoute$' "$rendered" grep -q '^apiVersion: gateway.networking.k8s.io/v1$' "$rendered" grep -q 'helm.sh/resource-policy: keep' "$rendered" -grep -q 'ssh-ed25519 AAAAcharttest gilti' "$rendered" -grep -q 'mountPath: /etc/gilti/authorized_keys' "$rendered" +grep -q 'secretName: gilti-bootstrap' "$rendered" +grep -q 'mountPath: /run/gilti-bootstrap' "$rendered" cat >"$injected" <<'EOF' -web: +cgit: rootTitle: |- Gilti scan-path=/var/lib/gilti/git/repositories EOF if helm template gilti "$chart" -f "$injected" >"$invalid" 2>&1; then - echo 'chart accepted multiline web configuration' >&2 + echo 'chart accepted multiline cgit configuration' >&2 exit 1 fi diff --git a/tests/smoke.sh b/tests/smoke.sh index 25f6304..a64a97b 100755 --- a/tests/smoke.sh +++ b/tests/smoke.sh @@ -10,7 +10,6 @@ volume=$name-state http_port=${HTTP_PORT:-18080} ssh_port=${SSH_PORT:-12222} work=$(mktemp -d) -authorized_keys=$work/authorized_keys remove_container() { "$engine" stop -t 10 "$name" >/dev/null 2>&1 || true @@ -27,26 +26,30 @@ trap cleanup EXIT INT TERM ssh-keygen -q -t ed25519 -N '' -f "$work/admin" ssh-keygen -q -t ed25519 -N '' -f "$work/admin-2" ssh-keygen -q -t ed25519 -N '' -f "$work/stranger" -cat "$work/admin.pub" "$work/admin-2.pub" >"$authorized_keys" +mkdir "$work/bootstrap" +cp "$work/admin.pub" "$work/bootstrap/admin.pub" +cp "$work/admin-2.pub" "$work/bootstrap/admin-2.pub" "$engine" volume create "$volume" >/dev/null -if "$engine" run --rm --entrypoint /bin/sh "$image" -c 'test -e /usr/local/bin/gilti-cgit' >/dev/null 2>&1; then - echo 'removed legacy browser binary is installed' >&2 - exit 1 -fi -printf '%s\n' 'not an SSH key' >"$work/bad-authorized-keys" +printf '%s\n' 'not an SSH key' >"$work/bootstrap/bad.pub" if "$engine" run --rm \ --cap-drop ALL \ --cap-add CHOWN --cap-add DAC_OVERRIDE --cap-add FOWNER \ --cap-add SETGID --cap-add SETUID --cap-add SYS_CHROOT \ --mount "type=volume,src=$volume,dst=/var/lib/gilti" \ - --mount "type=bind,src=$work/bad-authorized-keys,dst=/etc/gilti/authorized_keys,readonly" \ - "$image" >/dev/null 2>&1; then - echo 'initialization accepted a malformed SSH public key' >&2 + --mount "type=bind,src=$work/bootstrap,dst=/run/gilti-bootstrap,readonly" \ + "$image" init >/dev/null 2>&1; then + echo 'initialization accepted a malformed additional key' >&2 exit 1 fi +rm "$work/bootstrap/bad.pub" start() { + key_mount= + if [ "${1:-with-key}" = with-key ]; then + key_mount="--mount type=bind,src=$work/bootstrap,dst=/run/gilti-bootstrap,readonly" + fi + # shellcheck disable=SC2086 "$engine" run -d --name "$name" \ --read-only \ --cap-drop ALL \ @@ -54,13 +57,14 @@ start() { --cap-add SETGID --cap-add SETUID --cap-add SYS_CHROOT \ --tmpfs /run:rw,nosuid,nodev,noexec,size=32m \ --tmpfs /tmp:rw,nosuid,nodev,noexec,size=256m \ + --tmpfs /var/cache/cgit:rw,nosuid,nodev,noexec,size=256m \ --mount "type=volume,src=$volume,dst=/var/lib/gilti" \ - --mount "type=bind,src=$authorized_keys,dst=/etc/gilti/authorized_keys,readonly" \ + $key_mount \ -p "127.0.0.1:$http_port:8080" -p "127.0.0.1:$ssh_port:2222" \ "$image" >/dev/null i=0 - until curl -fsS "http://127.0.0.1:$http_port/-/health" >/dev/null 2>&1; do + until curl -fsS "http://127.0.0.1:$http_port/healthz" >/dev/null 2>&1; do i=$((i + 1)) if [ "$i" -ge 60 ]; then "$engine" logs "$name" >&2 @@ -76,154 +80,45 @@ start() { done } -start -sshd_config=$("$engine" exec "$name" /usr/sbin/sshd -T -f /etc/ssh/sshd_config \ - -C user=git,host=localhost,addr=127.0.0.1) -for expected in \ - 'authenticationmethods publickey' \ - 'passwordauthentication no' \ - 'kbdinteractiveauthentication no' \ - 'permitemptypasswords no' \ - 'permitrootlogin no' \ - 'disableforwarding yes' \ - 'permittty no' \ - 'permituserrc no' \ - 'permituserenvironment no' \ - 'forcecommand /usr/local/bin/gilti-ssh' \ - 'authorizedkeysfile /run/gilti/ssh/authorized_keys'; do - printf '%s\n' "$sshd_config" | grep -Fqx "$expected" || { - echo "effective sshd configuration is missing: $expected" >&2 - exit 1 - } -done -[ "$(curl -fsS "http://127.0.0.1:$http_port/-/health")" = '{"status":"ok"}' ] || { - echo 'unexpected health response' >&2 - exit 1 -} -status=$(curl -sS -o /dev/null -w '%{http_code}' -X POST "http://127.0.0.1:$http_port/") -[ "$status" = 405 ] || { - echo "POST to repository browser returned HTTP $status instead of 405" >&2 - exit 1 -} -curl -fsS "http://127.0.0.1:$http_port/-/assets/gilti.css" | grep -q 'div#gilti' -content_type=$(curl -fsSI "http://127.0.0.1:$http_port/-/assets/gilti.css" | - awk -F ': ' 'tolower($1) == "content-type" { gsub("\\r", "", $2); print $2 }') -[ "$content_type" = text/css ] || { - echo "unexpected gilti.css content type: $content_type" >&2 - exit 1 -} -curl -fsS "http://127.0.0.1:$http_port/-/assets/gilti.js" | grep -q 'function' -content_type=$(curl -fsSI "http://127.0.0.1:$http_port/-/assets/gilti.js" | - awk -F ': ' 'tolower($1) == "content-type" { gsub("\\r", "", $2); print $2 }') -[ "$content_type" = text/javascript ] || { - echo "unexpected gilti.js content type: $content_type" >&2 - exit 1 -} -curl -fsSI "http://127.0.0.1:$http_port/-/health" >/dev/null - -# shellcheck disable=SC2016 # Expanded by the shell inside the container. -httpd_uid=$("$engine" exec "$name" sh -c ' - for comm in /proc/[0-9]*/comm; do - [ "$(cat "$comm")" = gilti ] || continue - stat -c %u "${comm%/comm}" - exit - done - exit 1 -') -[ "$httpd_uid" = 10000 ] || { - echo "gilti runs as unexpected UID $httpd_uid" >&2 - exit 1 -} - +start with-key host_key_mode=$("$engine" exec "$name" stat -c '%u:%a' /var/lib/gilti/ssh/ssh_host_ed25519_key) [ "$host_key_mode" = 0:600 ] || { echo "unexpected SSH host-key ownership/mode: $host_key_mode" >&2 exit 1 } -keys_mode=$("$engine" exec "$name" stat -c '%u:%g:%a' /run/gilti/ssh/authorized_keys) -[ "$keys_mode" = 0:10000:640 ] || { - echo "unexpected authorized_keys ownership/mode: $keys_mode" >&2 - exit 1 -} -if "$engine" exec --user 10000:10000 "$name" sh -c \ - 'printf x >>/run/gilti/ssh/authorized_keys' 2>/dev/null; then - echo 'Git service user can modify authorized_keys' >&2 - exit 1 -fi -if "$engine" exec --user 10000:10000 "$name" rm /run/gilti/ssh/authorized_keys \ - 2>/dev/null; then - echo 'Git service user can remove authorized_keys' >&2 - exit 1 -fi if "$engine" exec --user 10000:10000 "$name" test -r /var/lib/gilti/ssh/ssh_host_ed25519_key; then - echo 'Git service user can read the SSH host private key' >&2 + echo 'Git/cgit user can read the SSH host private key' >&2 exit 1 fi - ssh_opts="-o BatchMode=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=$work/known_hosts -i $work/admin -p $ssh_port" -ssh_opts_2="-o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i $work/admin-2 -p $ssh_port" -# shellcheck disable=SC2086 -ssh $ssh_opts git@127.0.0.1 | grep -q 'Gilti: authenticated' # shellcheck disable=SC2086 -ssh $ssh_opts_2 git@127.0.0.1 | grep -q 'Gilti: authenticated' +ssh $ssh_opts git@127.0.0.1 info | grep -q 'hello admin' +ssh -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ + -i "$work/admin-2" -p "$ssh_port" git@127.0.0.1 info | grep -q 'hello admin' if ssh -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ - -i "$work/stranger" -p "$ssh_port" git@127.0.0.1 >/dev/null 2>&1; then + -i "$work/stranger" -p "$ssh_port" git@127.0.0.1 info >/dev/null 2>&1; then echo 'an unknown key was accepted' >&2 exit 1 fi -# shellcheck disable=SC2086 -if ssh $ssh_opts git@127.0.0.1 "git-upload-pack '../../etc/passwd'" >/dev/null 2>&1; then - echo 'gilti-ssh accepted an unsafe repository path' >&2 - exit 1 -fi -# A writable HOME must not let repository traffic install process-wide hooks. -# shellcheck disable=SC2016 # Expanded by the shell inside the container. -"$engine" exec --user 10000:10000 "$name" env HOME=/var/lib/gilti/git sh -c ' - mkdir -p "$HOME/evil-hooks" - printf "#!/bin/sh\ntouch /tmp/global-hook-ran\n" >"$HOME/evil-hooks/pre-receive" - chmod 0700 "$HOME/evil-hooks/pre-receive" - printf "[core]\n\thooksPath = %s/evil-hooks\n" "$HOME" >"$HOME/.gitconfig" -' - -mkdir "$work/testing" +GIT_SSH_COMMAND="ssh $ssh_opts" git clone \ + "ssh://git@127.0.0.1:$ssh_port/gitolite-admin" "$work/admin-repo" ( - cd "$work/testing" - git init -q -b main + cd "$work/admin-repo" git config user.name 'Gilti smoke test' git config user.email 'smoke@gilti.invalid' - printf '%s\n' '# Testing' >README.md - git add README.md - git commit -m 'Initial commit' >/dev/null - git remote add origin "ssh://git@127.0.0.1:$ssh_port/testing" - GIT_SSH_COMMAND="ssh $ssh_opts" git push -u origin main >/dev/null -) -if "$engine" exec "$name" test -e /tmp/global-hook-ran; then - echo 'gilti-ssh honored the writable global Git configuration' >&2 - exit 1 -fi -repository_modes=$("$engine" exec "$name" stat -c '%a:%u:%g' \ - /var/lib/gilti/git/repositories/testing.git \ - /var/lib/gilti/git/repositories/testing.git/config) -[ "$repository_modes" = "700:10000:10000 -600:10000:10000" ] || { - echo "unexpected repository ownership/modes: $repository_modes" >&2 - exit 1 -} -"$engine" exec --user 10000:10000 "$name" rm -rf \ - /var/lib/gilti/git/.gitconfig /var/lib/gilti/git/evil-hooks -GIT_SSH_COMMAND="ssh $ssh_opts_2" git clone \ - "ssh://git@127.0.0.1:$ssh_port/testing" "$work/testing-clone" >/dev/null -[ -f "$work/testing-clone/README.md" ] -( - cd "$work/testing-clone" - git config user.name 'Gilti smoke test 2' - git config user.email 'smoke-2@gilti.invalid' - printf '%s\n' 'Second key can push.' >>README.md - git add README.md - git commit -m 'Push with second key' >/dev/null - GIT_SSH_COMMAND="ssh $ssh_opts_2" git push origin main >/dev/null + cat >>conf/gitolite.conf <<'EOF' + +repo testing + R = gitweb + +repo private + RW+ = admin +EOF + git add conf/gitolite.conf + git commit -m 'Publish testing repository' >/dev/null + GIT_SSH_COMMAND="ssh $ssh_opts" git push origin HEAD >/dev/null ) i=0 @@ -232,82 +127,24 @@ until curl -fsS "http://127.0.0.1:$http_port/" | grep -q 'testing'; do [ "$i" -lt 30 ] || { "$engine" logs "$name" >&2; exit 1; } sleep 1 done -curl -fsS "http://127.0.0.1:$http_port/testing" | grep -q 'Initial commit' -curl -fsS "http://127.0.0.1:$http_port/testing/+/HEAD/+/tree/README%2emd" | grep -q 'Testing' -old_commit=$(git -C "$work/testing-clone" rev-parse HEAD^) -new_commit=$(git -C "$work/testing-clone" rev-parse HEAD) -curl -fsS "http://127.0.0.1:$http_port/testing/+/$new_commit" | grep -q 'Push with second key' -log_url="http://127.0.0.1:$http_port/testing/+/refs/heads/main/+/log" -curl -fsS "$log_url" -o "$work/log.html" -grep -q 'id="gilti"' "$work/log.html" -grep -q 'Push with second key' "$work/log.html" -log_length=$(wc -c <"$work/log.html" | tr -d ' ') -head_length=$(curl -fsSI "$log_url" | - awk -F ': ' 'tolower($1) == "content-length" { gsub("\\r", "", $2); print $2 }') -[ "$head_length" = "$log_length" ] || { - echo "Log HEAD length $head_length differs from GET length $log_length" >&2 +if curl -fsS "http://127.0.0.1:$http_port/" | grep -q 'private'; then + echo 'private repository appeared in the cgit index' >&2 exit 1 -} -feed_url="http://127.0.0.1:$http_port/testing/+/refs/heads/main/+/feed/atom" -curl -fsS "$feed_url" -o "$work/feed.xml" -grep -q '<feed xmlns=' "$work/feed.xml" -grep -q '<title>Push with second key</title>' "$work/feed.xml" -grep -q "<id>urn:sha1:$new_commit</id>" "$work/feed.xml" -feed_length=$(wc -c <"$work/feed.xml" | tr -d ' ') -head_length=$(curl -fsSI "$feed_url" | - awk -F ': ' 'tolower($1) == "content-length" { gsub("\\r", "", $2); print $2 }') -[ "$head_length" = "$feed_length" ] || { - echo "Atom HEAD length $head_length differs from GET length $feed_length" >&2 - exit 1 -} -curl -fsS "http://127.0.0.1:$http_port/testing/+/stats" | grep -q 'Gilti smoke test' -curl -fsS "http://127.0.0.1:$http_port/testing/+/diff/$old_commit..$new_commit?format=raw" | - grep -q '^+Second key can push\.' -curl -fsS "http://127.0.0.1:$http_port/testing/+/patch/$old_commit..$new_commit/+/README%2emd" | - grep -q '^Subject: Push with second key' -for format in tar tar.gz tar.bz2 tar.lz tar.xz tar.zst zip; do - curl -fsS "http://127.0.0.1:$http_port/testing/+/HEAD/+/archive?format=$format" \ - -o "$work/testing.$format" - [ -s "$work/testing.$format" ] || { - echo "empty $format archive" >&2 - exit 1 - } -done -summary_status=$(curl -sS -o /dev/null -w '%{http_code}:%{redirect_url}' \ - "http://127.0.0.1:$http_port/testing/+/summary") -[ "$summary_status" = "308:http://127.0.0.1:$http_port/testing" ] || { - echo "unexpected summary redirect: $summary_status" >&2 - exit 1 -} -GIT_CONFIG_GLOBAL=/dev/null git clone -q \ - "http://127.0.0.1:$http_port/testing.git" "$work/testing-http-clone" -[ -f "$work/testing-http-clone/README.md" ] -lfs_oid=2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881 -lfs_response=$(printf '{"operation":"download","objects":[{"oid":"%s","size":1}]}' "$lfs_oid" | - curl -fsS -H 'Content-Type: application/vnd.git-lfs+json' --data-binary @- \ - "http://127.0.0.1:$http_port/testing.git/info/lfs/objects/batch") -printf '%s' "$lfs_response" | grep -q '"code":404' || { - echo 'unexpected LFS batch response' >&2 +fi +status=$(curl -sS -o /dev/null -w '%{http_code}' "http://127.0.0.1:$http_port/private/") +[ "$status" = 404 ] || { + echo "private repository direct URL returned HTTP $status" >&2 exit 1 } -# The running sshd uses its startup snapshot, not the mounted source file. -cat "$work/admin.pub" >"$authorized_keys" -# shellcheck disable=SC2086 -ssh $ssh_opts_2 git@127.0.0.1 | grep -q 'Gilti: authenticated' fingerprint=$(ssh-keyscan -p "$ssh_port" 127.0.0.1 2>/dev/null | ssh-keygen -lf - | awk '{print $2}') remove_container -start +start without-key fingerprint_after=$(ssh-keyscan -p "$ssh_port" 127.0.0.1 2>/dev/null | ssh-keygen -lf - | awk '{print $2}') [ "$fingerprint" = "$fingerprint_after" ] || { echo 'SSH host key changed after restart' >&2 exit 1 } curl -fsS "http://127.0.0.1:$http_port/" | grep -q 'testing' -# shellcheck disable=SC2086 -ssh $ssh_opts git@127.0.0.1 | grep -q 'Gilti: authenticated' -# shellcheck disable=SC2086 -if ssh $ssh_opts_2 git@127.0.0.1 >/dev/null 2>&1; then - echo 'removed SSH key was accepted after restart' >&2 - exit 1 -fi +ssh -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ + -i "$work/admin-2" -p "$ssh_port" git@127.0.0.1 info | grep -q 'hello admin' |
