michal/tit

Browse tree · Show commit · Download archive

Diff

22bffa4062f3f53dd2bd7646

.github/workflows/ci.yml

Mode 100644100644; object 567e41ba194b45c626658dcd

@@ -31,5 +31,7 @@
       - run: cargo --version
       - run: cargo test --locked --all-targets --all-features
       - run: cargo test --locked --release --test auth --test ssh
+      - run: cargo test --locked --release --test git_repository --test git_http --test git_ssh
       - run: cargo test --locked --release --test sqlite_workload -- --ignored --nocapture
       - run: cargo build --locked --release
+      - run: wc -c target/release/tit

Cargo.lock

Mode 100644100644; object 4dd0b064da837aa3be339eb1

@@ -8,7 +8,7 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99"
 dependencies = [
- "crypto-common",
+ "crypto-common 0.2.2",
  "inout",
 ]
 
@@ -20,7 +20,7 @@
 dependencies = [
  "cipher",
  "cpubits",
- "cpufeatures",
+ "cpufeatures 0.3.0",
  "zeroize",
 ]
 
@@ -40,6 +40,12 @@
 ]
 
 [[package]]
+name = "allocator-api2"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
+
+[[package]]
 name = "android_system_properties"
 version = "0.1.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -55,6 +61,15 @@
 checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
 
 [[package]]
+name = "arc-swap"
+version = "1.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b"
+dependencies = [
+ "rustversion",
+]
+
+[[package]]
 name = "argon2"
 version = "0.6.0-rc.8"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -62,15 +77,70 @@
 dependencies = [
  "base64ct",
  "blake2",
- "cpufeatures",
+ "cpufeatures 0.3.0",
  "password-hash",
 ]
+
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
 
 [[package]]
 name = "autocfg"
 version = "1.5.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "axum"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
+dependencies = [
+ "axum-core",
+ "bytes",
+ "form_urlencoded",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "itoa",
+ "matchit",
+ "memchr",
+ "mime",
+ "percent-encoding",
+ "pin-project-lite",
+ "serde_core",
+ "serde_path_to_error",
+ "serde_urlencoded",
+ "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 = "base16ct"
@@ -97,6 +167,12 @@
 
 [[package]]
 name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitflags"
 version = "2.13.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
@@ -107,7 +183,16 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "061f1a09225e328e1ffbb378d2d49923c0ca5fee19fb5ac1cc9c1e9d52b93690"
 dependencies = [
- "digest",
+ "digest 0.11.3",
+]
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array 0.14.7",
 ]
 
 [[package]]
@@ -137,6 +222,17 @@
 dependencies = [
  "byteorder",
  "cipher",
+]
+
+[[package]]
+name = "bstr"
+version = "1.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530"
+dependencies = [
+ "memchr",
+ "regex-automata",
+ "serde_core",
 ]
 
 [[package]]
@@ -196,7 +292,7 @@
 dependencies = [
  "cfg-if",
  "cipher",
- "cpufeatures",
+ "cpufeatures 0.3.0",
  "rand_core",
  "zeroize",
 ]
@@ -220,8 +316,8 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
 dependencies = [
- "block-buffer",
- "crypto-common",
+ "block-buffer 0.12.1",
+ "crypto-common 0.2.2",
  "inout",
  "zeroize",
 ]
@@ -265,6 +361,15 @@
 checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
 
 [[package]]
+name = "clru"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "197fd99cb113a8d5d9b6376f3aa817f32c1078f2343b714fff7d2ca44fdf67d5"
+dependencies = [
+ "hashbrown 0.16.1",
+]
+
+[[package]]
 name = "cmov"
 version = "0.5.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -290,12 +395,45 @@
 
 [[package]]
 name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "cpufeatures"
 version = "0.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
 dependencies = [
  "libc",
 ]
+
+[[package]]
+name = "crc32fast"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "crossbeam-channel"
+version = "0.5.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
 
 [[package]]
 name = "crypto-bigint"
@@ -312,6 +450,16 @@
  "serdect",
  "subtle",
  "zeroize",
+]
+
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array 0.14.7",
+ "typenum",
 ]
 
 [[package]]
@@ -351,9 +499,9 @@
 checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23"
 dependencies = [
  "cfg-if",
- "cpufeatures",
+ "cpufeatures 0.3.0",
  "curve25519-dalek-derive",
- "digest",
+ "digest 0.11.3",
  "fiat-crypto",
  "rand_core",
  "rustc_version",
@@ -377,6 +525,37 @@
 version = "2.11.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
+
+[[package]]
+name = "defmt"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
+dependencies = [
+ "bitflags 1.3.2",
+ "defmt-macros",
+]
+
+[[package]]
+name = "defmt-macros"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
+dependencies = [
+ "defmt-parser",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "defmt-parser"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
+dependencies = [
+ "thiserror",
+]
 
 [[package]]
 name = "delegate"
@@ -411,13 +590,23 @@
 
 [[package]]
 name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer 0.10.4",
+ "crypto-common 0.1.7",
+]
+
+[[package]]
+name = "digest"
 version = "0.11.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
 dependencies = [
- "block-buffer",
+ "block-buffer 0.12.1",
  "const-oid",
- "crypto-common",
+ "crypto-common 0.2.2",
  "ctutils",
 ]
 
@@ -433,13 +622,19 @@
 ]
 
 [[package]]
+name = "dunce"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
+
+[[package]]
 name = "ecdsa"
 version = "0.17.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0"
 dependencies = [
  "der",
- "digest",
+ "digest 0.11.3",
  "elliptic-curve",
  "rfc6979",
  "signature",
@@ -481,8 +676,8 @@
 dependencies = [
  "base16ct",
  "crypto-bigint",
- "crypto-common",
- "digest",
+ "crypto-common 0.2.2",
+ "digest 0.11.3",
  "ff",
  "group",
  "hkdf",
@@ -493,6 +688,15 @@
  "sec1",
  "subtle",
  "zeroize",
+]
+
+[[package]]
+name = "encoding_rs"
+version = "0.8.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
+dependencies = [
+ "cfg-if",
 ]
 
 [[package]]
@@ -536,6 +740,16 @@
 checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
 
 [[package]]
+name = "faster-hex"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7223ae2d2f179b803433d9c830478527e92b8117eab39460edae7f1614d9fb73"
+dependencies = [
+ "heapless",
+ "serde",
+]
+
+[[package]]
 name = "fastrand"
 version = "2.5.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -562,6 +776,12 @@
 version = "0.1.9"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
+[[package]]
+name = "foldhash"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
 
 [[package]]
 name = "form_urlencoded"
@@ -662,9 +882,9 @@
 
 [[package]]
 name = "generic-array"
-version = "0.14.9"
+version = "0.14.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
 dependencies = [
  "typenum",
  "version_check",
@@ -676,7 +896,7 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "ab4e5aa225bc56696909483320f0ff9b600f1a971b52e07a17d70f3d9b43254b"
 dependencies = [
- "generic-array 0.14.9",
+ "generic-array 0.14.7",
  "rustversion",
  "typenum",
 ]
@@ -716,6 +936,604 @@
 ]
 
 [[package]]
+name = "gix"
+version = "0.84.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae54ae0ebd1a5a3c3f8d95dd3b5ca6e63f4fed9bfd585e13801a97d7bde8f9ce"
+dependencies = [
+ "gix-actor",
+ "gix-commitgraph",
+ "gix-config",
+ "gix-date",
+ "gix-diff",
+ "gix-discover",
+ "gix-error",
+ "gix-features",
+ "gix-fs",
+ "gix-glob",
+ "gix-hash",
+ "gix-hashtable",
+ "gix-lock",
+ "gix-object",
+ "gix-odb",
+ "gix-pack",
+ "gix-path",
+ "gix-protocol",
+ "gix-ref",
+ "gix-refspec",
+ "gix-revision",
+ "gix-revwalk",
+ "gix-sec",
+ "gix-shallow",
+ "gix-tempfile",
+ "gix-trace",
+ "gix-traverse",
+ "gix-url",
+ "gix-utils",
+ "gix-validate",
+ "gix-worktree-stream",
+ "nonempty",
+ "smallvec",
+ "thiserror",
+]
+
+[[package]]
+name = "gix-actor"
+version = "0.41.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8bc998b8f746dda8565450d08a63b792ced9165d8c27a1ed3f02799ec6a7820f"
+dependencies = [
+ "bstr",
+ "gix-date",
+ "gix-error",
+]
+
+[[package]]
+name = "gix-attributes"
+version = "0.33.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39b40888d0ed415c0744a6cdc61eebf0304c9d26ab726725b718443c322e5ba4"
+dependencies = [
+ "bstr",
+ "gix-glob",
+ "gix-path",
+ "gix-quote",
+ "gix-trace",
+ "kstring",
+ "smallvec",
+ "thiserror",
+ "unicode-bom",
+]
+
+[[package]]
+name = "gix-chunk"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9faee47943b638e58ddd5e275a4906ad3e4b6c8584f1d41bd18ab9032ec52afb"
+dependencies = [
+ "gix-error",
+]
+
+[[package]]
+name = "gix-command"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00706d4fef135ef4b01680d5218c6ee40cda8baf697b864296cbc887d19118f6"
+dependencies = [
+ "bstr",
+ "gix-path",
+ "gix-quote",
+ "gix-trace",
+ "shell-words",
+]
+
+[[package]]
+name = "gix-commitgraph"
+version = "0.37.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f675d0df484a7f6a47e64bd6f311af489d947c0323b0564f36d14f3d7762abb"
+dependencies = [
+ "bstr",
+ "gix-chunk",
+ "gix-error",
+ "gix-hash",
+ "memmap2",
+ "nonempty",
+]
+
+[[package]]
+name = "gix-config"
+version = "0.57.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4f2372d4b49ca28431e7d150cab9d25edc1890f0184bd57eb0e917c7799e63de"
+dependencies = [
+ "bstr",
+ "gix-config-value",
+ "gix-features",
+ "gix-glob",
+ "gix-path",
+ "gix-ref",
+ "gix-sec",
+ "smallvec",
+ "thiserror",
+ "unicode-bom",
+]
+
+[[package]]
+name = "gix-config-value"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed42168329552f6c2e5df09665c104199d45d84bedb53683738a49b57fe1baab"
+dependencies = [
+ "bitflags 2.13.1",
+ "bstr",
+ "gix-path",
+ "libc",
+ "thiserror",
+]
+
+[[package]]
+name = "gix-date"
+version = "0.15.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e47b9e8cdc688296609b706428de570f88b1e0eed7156dde7b4a89d26fa4567"
+dependencies = [
+ "bstr",
+ "gix-error",
+ "itoa",
+ "jiff",
+]
+
+[[package]]
+name = "gix-diff"
+version = "0.64.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b6d9528f32d94cef2edf39a1ac01fe5a0fc44ddbb18d9e44099936047c3302b"
+dependencies = [
+ "bstr",
+ "gix-hash",
+ "gix-object",
+ "thiserror",
+]
+
+[[package]]
+name = "gix-discover"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77bacdd12b7879d2178a80c58c2f319995e4654e1a7a23e3181e5c8a12b824f7"
+dependencies = [
+ "bstr",
+ "dunce",
+ "gix-fs",
+ "gix-path",
+ "gix-ref",
+ "gix-sec",
+ "thiserror",
+]
+
+[[package]]
+name = "gix-error"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4a9292309fd944e71b2a3c96d3c03a6feb8852db646febdde7cbb9f79cb5f329"
+dependencies = [
+ "bstr",
+]
+
+[[package]]
+name = "gix-features"
+version = "0.48.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1849ae154d38bc403185be14fa871e38e3c93ee606875d94e207fdb9fba52dbc"
+dependencies = [
+ "bytes",
+ "crc32fast",
+ "crossbeam-channel",
+ "gix-path",
+ "gix-trace",
+ "gix-utils",
+ "libc",
+ "once_cell",
+ "parking_lot",
+ "prodash",
+ "thiserror",
+ "walkdir",
+ "zlib-rs",
+]
+
+[[package]]
+name = "gix-filter"
+version = "0.31.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ecf74b7d16f6694ce4a3049074c41be0c7987105743674f1671807bd6dce09fa"
+dependencies = [
+ "bstr",
+ "encoding_rs",
+ "gix-attributes",
+ "gix-command",
+ "gix-hash",
+ "gix-object",
+ "gix-packetline",
+ "gix-path",
+ "gix-quote",
+ "gix-trace",
+ "gix-utils",
+ "smallvec",
+ "thiserror",
+]
+
+[[package]]
+name = "gix-fs"
+version = "0.21.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6cdff46db8798e47e2f727d84b9379aac5add3dd3d9d0b07bb4d7d5d640771fe"
+dependencies = [
+ "bstr",
+ "fastrand",
+ "gix-features",
+ "gix-path",
+ "gix-utils",
+ "thiserror",
+]
+
+[[package]]
+name = "gix-glob"
+version = "0.26.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1fcb8ef5b16bcf874abe9b68d8abb3c0493c876d367ab824151f30a0f3f3756"
+dependencies = [
+ "bitflags 2.13.1",
+ "bstr",
+ "gix-features",
+ "gix-path",
+]
+
+[[package]]
+name = "gix-hash"
+version = "0.25.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb0926d3819c837750b4e03c7754901e73f68b8c9b690753a6372a1bed4eedce"
+dependencies = [
+ "faster-hex",
+ "gix-features",
+ "sha1-checked",
+ "sha2",
+ "thiserror",
+]
+
+[[package]]
+name = "gix-hashtable"
+version = "0.15.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e261d54091f0d1c729bc83f54548c071bdec60a697de1e58e88bdfd7a99d24e"
+dependencies = [
+ "gix-hash",
+ "hashbrown 0.17.1",
+ "parking_lot",
+]
+
+[[package]]
+name = "gix-lock"
+version = "23.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "65c9dedd9e90b0d47624d2ed241d394e09294118364e87b9b7e5f1fe755f3c2c"
+dependencies = [
+ "gix-tempfile",
+ "gix-utils",
+ "thiserror",
+]
+
+[[package]]
+name = "gix-object"
+version = "0.61.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d5cd857e29429c7213bdef3f5aef83f8cc124774fe8ae0d27b1607d218d6d525"
+dependencies = [
+ "bstr",
+ "gix-actor",
+ "gix-date",
+ "gix-features",
+ "gix-hash",
+ "gix-hashtable",
+ "gix-utils",
+ "gix-validate",
+ "itoa",
+ "smallvec",
+ "thiserror",
+]
+
+[[package]]
+name = "gix-odb"
+version = "0.81.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d004c32858b1556f2d7874405edb3c97dc78fc09beaa87d57bb077ee2858a7d"
+dependencies = [
+ "arc-swap",
+ "gix-features",
+ "gix-fs",
+ "gix-hash",
+ "gix-hashtable",
+ "gix-object",
+ "gix-pack",
+ "gix-path",
+ "gix-quote",
+ "memmap2",
+ "parking_lot",
+ "tempfile",
+ "thiserror",
+]
+
+[[package]]
+name = "gix-pack"
+version = "0.71.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e43626f2a27d1033674ec1a196b845614231e6bbd949d5e21c133045ff56b174"
+dependencies = [
+ "clru",
+ "gix-chunk",
+ "gix-diff",
+ "gix-error",
+ "gix-features",
+ "gix-hash",
+ "gix-hashtable",
+ "gix-object",
+ "gix-path",
+ "gix-traverse",
+ "memmap2",
+ "parking_lot",
+ "smallvec",
+ "thiserror",
+]
+
+[[package]]
+name = "gix-packetline"
+version = "0.21.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b217dd0ee0c4021ecf169a4a519b1b4f80d15e3f3765f3dc466223dc0ac891d7"
+dependencies = [
+ "bstr",
+ "faster-hex",
+ "gix-trace",
+ "thiserror",
+]
+
+[[package]]
+name = "gix-path"
+version = "0.12.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cbbecb0f8dc5cdf6cbde69133f7072064dfc9da4cf0046913afb6857b07300fa"
+dependencies = [
+ "bstr",
+ "gix-trace",
+ "gix-validate",
+ "thiserror",
+]
+
+[[package]]
+name = "gix-protocol"
+version = "0.62.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "51dea3acb390707ab868f1f9584f18449eb95d869deffae96768e47d303595ee"
+dependencies = [
+ "bstr",
+ "gix-date",
+ "gix-features",
+ "gix-hash",
+ "gix-ref",
+ "gix-shallow",
+ "gix-transport",
+ "gix-utils",
+ "maybe-async",
+ "nonempty",
+ "thiserror",
+]
+
+[[package]]
+name = "gix-quote"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6e541fc33cc2b783b7979040d445a0c86a2eca747c8faea4ca84230d06ae6ef"
+dependencies = [
+ "bstr",
+ "gix-error",
+ "gix-utils",
+]
+
+[[package]]
+name = "gix-ref"
+version = "0.64.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c04f64c37eb7e6feb73c7060f8dc6f381cc5de5d53249bfd450bc48a86b2e8b"
+dependencies = [
+ "gix-actor",
+ "gix-features",
+ "gix-fs",
+ "gix-hash",
+ "gix-lock",
+ "gix-object",
+ "gix-path",
+ "gix-tempfile",
+ "gix-utils",
+ "gix-validate",
+ "memmap2",
+ "thiserror",
+]
+
+[[package]]
+name = "gix-refspec"
+version = "0.42.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b216ae06ec74b5f24ad0142026a997fb0a935b7410eaf9c1616fc3f0e6c5a6d3"
+dependencies = [
+ "bstr",
+ "gix-error",
+ "gix-glob",
+ "gix-hash",
+ "gix-revision",
+ "gix-validate",
+ "smallvec",
+ "thiserror",
+]
+
+[[package]]
+name = "gix-revision"
+version = "0.46.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b47c88884dd3c1a19a39da19d10211fcdea2809aadc86869b6e824a1774340f"
+dependencies = [
+ "bstr",
+ "gix-commitgraph",
+ "gix-date",
+ "gix-error",
+ "gix-hash",
+ "gix-object",
+ "gix-revwalk",
+ "nonempty",
+]
+
+[[package]]
+name = "gix-revwalk"
+version = "0.32.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85f5756abffe0917827aac683b13684ed99875bc398fa1f9b8f479b0681ef9e6"
+dependencies = [
+ "gix-commitgraph",
+ "gix-date",
+ "gix-error",
+ "gix-hash",
+ "gix-hashtable",
+ "gix-object",
+ "smallvec",
+ "thiserror",
+]
+
+[[package]]
+name = "gix-sec"
+version = "0.14.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab8519976e4c7e486270740a5400369f37940779b80bd1377d94cfa1125d01b3"
+dependencies = [
+ "bitflags 2.13.1",
+ "gix-path",
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "gix-shallow"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a292fc2fe548c5dfa575479d16b445b0ddf1dd2f56f1fec6aed386f82553cd97"
+dependencies = [
+ "bstr",
+ "gix-hash",
+ "gix-lock",
+ "nonempty",
+ "thiserror",
+]
+
+[[package]]
+name = "gix-tempfile"
+version = "23.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ef60812443484e67bf84e444cc71b4c78ae62deb822221774a4fa0c57fdb17f"
+dependencies = [
+ "gix-fs",
+ "libc",
+ "parking_lot",
+ "tempfile",
+]
+
+[[package]]
+name = "gix-trace"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44dc45eae785c0eb14173e0f152e6e224dcf4d45b6a6999a3aed22af541ad678"
+
+[[package]]
+name = "gix-transport"
+version = "0.57.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "186874f7ad1fb2f9a2f2aa9c2dabc7f9dd087bef74c1a0eee2b4a9cf0248fcb3"
+dependencies = [
+ "bstr",
+ "gix-command",
+ "gix-features",
+ "gix-packetline",
+ "gix-quote",
+ "gix-sec",
+ "gix-url",
+ "thiserror",
+]
+
+[[package]]
+name = "gix-traverse"
+version = "0.58.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8de590ecc86a3b2870665f2288324fa9f7f8672c7fc2d4e020fdd81cd1f7aed"
+dependencies = [
+ "bitflags 2.13.1",
+ "gix-commitgraph",
+ "gix-date",
+ "gix-hash",
+ "gix-hashtable",
+ "gix-object",
+ "gix-revwalk",
+ "smallvec",
+ "thiserror",
+]
+
+[[package]]
+name = "gix-url"
+version = "0.36.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57d68e70e96da0e5f9c871f1566349e0fd0e1a20bb483c7f54af1dd0b85b4b29"
+dependencies = [
+ "bstr",
+ "gix-path",
+ "percent-encoding",
+ "thiserror",
+]
+
+[[package]]
+name = "gix-utils"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d773a906e39472c2b00aaf1993cd120d40198c1ff6db07c0ee9a44d4431b66c1"
+dependencies = [
+ "fastrand",
+ "getrandom 0.4.3",
+ "unicode-normalization",
+]
+
+[[package]]
+name = "gix-validate"
+version = "0.11.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7bc6fc771c4063ba7cd2f47b91fb6076251c6a823b64b7fe7b8874b0fe4afae3"
+dependencies = [
+ "bstr",
+]
+
+[[package]]
+name = "gix-worktree-stream"
+version = "0.33.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d25e9ed30100c63f7590bc581c225e53f731a53e06aa79a245739c07f7dcc557"
+dependencies = [
+ "gix-attributes",
+ "gix-error",
+ "gix-features",
+ "gix-filter",
+ "gix-fs",
+ "gix-hash",
+ "gix-object",
+ "gix-path",
+ "gix-traverse",
+ "parking_lot",
+]
+
+[[package]]
 name = "group"
 version = "0.14.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -727,10 +1545,40 @@
 ]
 
 [[package]]
+name = "hash32"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606"
+dependencies = [
+ "byteorder",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.16.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
+dependencies = [
+ "allocator-api2",
+ "equivalent",
+ "foldhash",
+]
+
+[[package]]
 name = "hashbrown"
 version = "0.17.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "heapless"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad"
+dependencies = [
+ "hash32",
+ "stable_deref_trait",
+]
 
 [[package]]
 name = "heck"
@@ -765,8 +1613,53 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
 dependencies = [
- "digest",
+ "digest 0.11.3",
 ]
+
+[[package]]
+name = "http"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
+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.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2"
+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 = "hybrid-array"
@@ -778,6 +1671,41 @@
  "subtle",
  "typenum",
  "zeroize",
+]
+
+[[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]]
@@ -914,7 +1842,7 @@
 checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
 dependencies = [
  "equivalent",
- "hashbrown",
+ "hashbrown 0.17.1",
 ]
 
 [[package]]
@@ -940,6 +1868,65 @@
 ]
 
 [[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "jiff"
+version = "0.2.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e184d09547b80eb7e20d141ba2fb1fbac843ca53f4cf1b31210adc4c1adc6e16"
+dependencies = [
+ "defmt",
+ "jiff-core",
+ "jiff-static",
+ "jiff-tzdb-platform",
+ "log",
+ "portable-atomic",
+ "portable-atomic-util",
+ "serde_core",
+ "windows-link",
+]
+
+[[package]]
+name = "jiff-core"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09"
+dependencies = [
+ "defmt",
+]
+
+[[package]]
+name = "jiff-static"
+version = "0.2.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "323da076b7a6faf914dc677cb05a4b907742ff7375c8322c9e7f5061e5e0e9de"
+dependencies = [
+ "jiff-core",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "jiff-tzdb"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e"
+
+[[package]]
+name = "jiff-tzdb-platform"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8"
+dependencies = [
+ "jiff-tzdb",
+]
+
+[[package]]
 name = "js-sys"
 version = "0.3.103"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -957,7 +1944,7 @@
 checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa"
 dependencies = [
  "cfg-if",
- "cpufeatures",
+ "cpufeatures 0.3.0",
 ]
 
 [[package]]
@@ -966,8 +1953,17 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "01737161ba802849cfd486b5bd209d38ba4943494c249a8126005170c7621edd"
 dependencies = [
- "crypto-common",
+ "crypto-common 0.2.2",
  "rand_core",
+]
+
+[[package]]
+name = "kstring"
+version = "2.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b609e7ca5ea38f093c20a4a102335b247221c9643b7a6bc3510f196f99499a9e"
+dependencies = [
+ "static_assertions",
 ]
 
 [[package]]
@@ -1000,10 +1996,36 @@
 checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
 
 [[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
 name = "log"
 version = "0.4.33"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
+
+[[package]]
+name = "matchit"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
+
+[[package]]
+name = "maybe-async"
+version = "0.2.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "746873a384ad60adc5db74471dfaba74bd278afbdcfd81db93fafcdfc8b5ca0c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
 
 [[package]]
 name = "md5"
@@ -1016,6 +2038,21 @@
 version = "2.8.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "memmap2"
+version = "0.9.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "mime"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
 
 [[package]]
 name = "mio"
@@ -1059,11 +2096,17 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
 dependencies = [
- "bitflags",
+ "bitflags 2.13.1",
  "cfg-if",
  "cfg_aliases",
  "libc",
 ]
+
+[[package]]
+name = "nonempty"
+version = "0.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6"
 
 [[package]]
 name = "num-bigint"
@@ -1161,6 +2204,29 @@
 ]
 
 [[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link",
+]
+
+[[package]]
 name = "password-hash"
 version = "0.6.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1175,7 +2241,7 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629"
 dependencies = [
- "digest",
+ "digest 0.11.3",
  "hmac",
 ]
 
@@ -1251,7 +2317,7 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c"
 dependencies = [
- "cpufeatures",
+ "cpufeatures 0.3.0",
  "universal-hash",
  "zeroize",
 ]
@@ -1263,8 +2329,23 @@
 checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd"
 dependencies = [
  "cpubits",
- "cpufeatures",
+ "cpufeatures 0.3.0",
  "universal-hash",
+]
+
+[[package]]
+name = "portable-atomic"
+version = "1.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3"
+
+[[package]]
+name = "portable-atomic-util"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
+dependencies = [
+ "portable-atomic",
 ]
 
 [[package]]
@@ -1283,7 +2364,7 @@
 checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4"
 dependencies = [
  "crypto-bigint",
- "crypto-common",
+ "crypto-common 0.2.2",
  "ff",
  "rand_core",
  "subtle",
@@ -1310,6 +2391,15 @@
 checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
 dependencies = [
  "unicode-ident",
+]
+
+[[package]]
+name = "prodash"
+version = "31.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "962200e2d7d551451297d9fdce85138374019ada198e30ea9ede38034e27604c"
+dependencies = [
+ "parking_lot",
 ]
 
 [[package]]
@@ -1345,6 +2435,21 @@
 checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
 
 [[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags 2.13.1",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad"
+
+[[package]]
 name = "rfc6979"
 version = "0.6.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1374,7 +2479,7 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "11438310b19e3109b6446c33d1ed5e889428cf2e278407bc7896bc4aaea43323"
 dependencies = [
- "bitflags",
+ "bitflags 2.13.1",
  "fallible-iterator",
  "fallible-streaming-iterator",
  "libsqlite3-sys",
@@ -1388,7 +2493,7 @@
 checksum = "b8b67b5a0d8068c89dcbe9d95df986af7a851d1f3c604525274c37468e60464f"
 dependencies = [
  "aes",
- "bitflags",
+ "bitflags 2.13.1",
  "block-padding",
  "byteorder",
  "bytes",
@@ -1400,7 +2505,7 @@
  "data-encoding",
  "delegate",
  "der",
- "digest",
+ "digest 0.11.3",
  "ecdsa",
  "ed25519-dalek",
  "elliptic-curve",
@@ -1435,7 +2540,7 @@
  "salsa20",
  "scrypt",
  "sec1",
- "sha1",
+ "sha1 0.11.0",
  "sha2",
  "sha3 0.12.0",
  "signature",
@@ -1489,7 +2594,7 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
 dependencies = [
- "bitflags",
+ "bitflags 2.13.1",
  "errno",
  "libc",
  "linux-raw-sys",
@@ -1503,6 +2608,12 @@
 checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
 
 [[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
+[[package]]
 name = "salsa20"
 version = "0.11.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1511,6 +2622,21 @@
  "cfg-if",
  "cipher",
 ]
+
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
 
 [[package]]
 name = "scrypt"
@@ -1575,12 +2701,35 @@
 ]
 
 [[package]]
+name = "serde_path_to_error"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
+dependencies = [
+ "itoa",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
 name = "serde_spanned"
 version = "1.1.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
 dependencies = [
  "serde_core",
+]
+
+[[package]]
+name = "serde_urlencoded"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
+dependencies = [
+ "form_urlencoded",
+ "itoa",
+ "ryu",
+ "serde",
 ]
 
 [[package]]
@@ -1595,13 +2744,34 @@
 
 [[package]]
 name = "sha1"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.2.17",
+ "digest 0.10.7",
+]
+
+[[package]]
+name = "sha1"
 version = "0.11.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
 dependencies = [
  "cfg-if",
- "cpufeatures",
- "digest",
+ "cpufeatures 0.3.0",
+ "digest 0.11.3",
+]
+
+[[package]]
+name = "sha1-checked"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "89f599ac0c323ebb1c6082821a54962b839832b03984598375bff3975b804423"
+dependencies = [
+ "digest 0.10.7",
+ "sha1 0.10.7",
 ]
 
 [[package]]
@@ -1611,8 +2781,8 @@
 checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
 dependencies = [
  "cfg-if",
- "cpufeatures",
- "digest",
+ "cpufeatures 0.3.0",
+ "digest 0.11.3",
 ]
 
 [[package]]
@@ -1621,7 +2791,7 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1"
 dependencies = [
- "digest",
+ "digest 0.11.3",
  "keccak",
 ]
 
@@ -1631,10 +2801,16 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759"
 dependencies = [
- "digest",
+ "digest 0.11.3",
  "keccak",
  "sponge-cursor",
 ]
+
+[[package]]
+name = "shell-words"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
 
 [[package]]
 name = "shlex"
@@ -1648,7 +2824,7 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5"
 dependencies = [
- "digest",
+ "digest 0.11.3",
  "rand_core",
 ]
 
@@ -1717,7 +2893,7 @@
  "base64ct",
  "bytes",
  "ctutils",
- "digest",
+ "digest 0.11.3",
  "pem-rfc7468",
  "zeroize",
 ]
@@ -1739,7 +2915,7 @@
  "p521",
  "rand_core",
  "sec1",
- "sha1",
+ "sha1 0.11.0",
  "sha2",
  "signature",
  "ssh-cipher",
@@ -1752,6 +2928,12 @@
 version = "1.2.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "static_assertions"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
 
 [[package]]
 name = "subtle"
@@ -1780,6 +2962,12 @@
  "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 = "synstructure"
@@ -1836,10 +3024,28 @@
 ]
 
 [[package]]
+name = "tinyvec"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
+[[package]]
 name = "tit-cde"
 version = "0.1.0"
 dependencies = [
+ "axum",
  "clap",
+ "gix",
+ "gix-pack",
  "rand",
  "rusqlite",
  "russh",
@@ -1919,10 +3125,43 @@
 checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
 
 [[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-bom"
+version = "2.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7eec5d1121208364f6793f7d2e222bf75a915c19557537745b195b253dd64217"
 
 [[package]]
 name = "unicode-ident"
@@ -1931,12 +3170,21 @@
 checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
 
 [[package]]
+name = "unicode-normalization"
+version = "0.1.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
+dependencies = [
+ "tinyvec",
+]
+
+[[package]]
 name = "universal-hash"
 version = "0.6.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96"
 dependencies = [
- "crypto-common",
+ "crypto-common 0.2.2",
  "ctutils",
 ]
 
@@ -1976,6 +3224,16 @@
 version = "0.9.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
 
 [[package]]
 name = "wasi"
@@ -2036,6 +3294,15 @@
 checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
 dependencies = [
  "unicode-ident",
+]
+
+[[package]]
+name = "winapi-util"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
+dependencies = [
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -2335,3 +3602,9 @@
  "quote",
  "syn 2.0.119",
 ]
+
+[[package]]
+name = "zlib-rs"
+version = "0.6.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b142a20ec14a91d5bc708c1dc21b080c550113d8aa77afa29635673a65dd02c5"

Cargo.toml

Mode 100644100644; object f954d7e441095f6be756cbd1

@@ -11,7 +11,10 @@
 path = "src/main.rs"
 
 [dependencies]
+axum = { version = "0.8", default-features = false, features = ["http1", "query", "tokio"] }
 clap = { version = "4.6", default-features = false, features = ["derive", "error-context", "help", "std", "usage"] }
+gix = { version = "0.84", default-features = false, features = ["parallel", "sha1", "sha256"] }
+gix-pack = { version = "0.71", default-features = false, features = ["generate", "sha1", "sha256"] }
 rand = "0.10"
 rusqlite = { version = "0.40", default-features = false, features = ["backup", "bundled"] }
 russh = { version = "0.62", default-features = false, features = ["ring"] }

README.md

Mode 100644100644; object 39a1f0c8e2b51bdc87f8e963

@@ -52,6 +52,19 @@
 [architectural decision record](docs/adr/0002-ssh-identity.md) for the supported
 algorithms, limits, and current platform evidence.
 
+## Milestone 1C gate
+
+Install stock Git and OpenSSH. Then, run the read-side Git protocol gate:
+
+```text
+./scripts/check-m1c
+```
+
+This command uses stock Git to clone and fetch SHA-1 and SHA-256 repositories
+through smart HTTP and SSH. Read the read-side Git
+[architectural decision record](docs/adr/0003-read-side-git.md) for the protocol
+versions, limits, known omissions, and current platform evidence.
+
 ## Database check
 
 An initialized instance keeps its metadata in `tit.sqlite3`. Check an existing

deny.toml

Mode 100644100644; object 7b80f0d03a7649e475827416

@@ -11,6 +11,7 @@
     "ISC",
     "MIT",
     "Unicode-3.0",
+    "Zlib",
 ]
 
 [bans]

docs/adr/0003-read-side-git.md

Mode 100644; object 6477d8dfe1fb

@@ -1,0 +1,96 @@
+# Architectural decision record 0003: read-side Git protocol
+
+Status: Provisional
+
+Date: 2026-07-22
+
+## Context
+
+`tit` must serve Git repositories without the Git executable or another runtime
+service. Stock Git clients must clone and fetch SHA-1 and SHA-256 repositories
+through SSH and smart HTTP. The same repository path rules and object access
+rules must apply to each transport.
+
+## Decision
+
+Use `gix` version 0.84.0 to open bare repositories, read references, decode
+loose and packed objects, and identify each repository object format. Use
+`gix-pack` version 0.71.0 to write pack version 2 with the repository hash
+algorithm. Enable SHA-1, SHA-256, pack generation, and the thread-safe `gix`
+mode. Use Axum version 0.8.9 for the smart HTTP adapter. Keep Russh version
+0.62.4 for the SSH adapter.
+
+Use one upload-pack service behind the HTTP and SSH adapters. Resolve
+`owner/repository{,.git}` below one canonical repository root. Reject invalid
+names, additional path components, commands other than `git-upload-pack`, and
+paths that resolve outside the root. The SSH username does not select the
+repository or account.
+
+Support Git protocol versions 0, 1, and 2. Protocol versions 0 and 1 advertise
+`agent` and `object-format`. Protocol version 1 also sends its version packet.
+Protocol version 2 advertises `agent`, `object-format`,
+`ls-refs` with `symrefs` and `peel`, and `fetch` with `wait-for-done`. Smart
+HTTP uses the standard discovery and result content types and disables response
+caching. SSH and HTTP use the same reference and pack functions.
+
+The packet-line parser accepts data, flush, delimiter, and response-end
+packets. It rejects an invalid hexadecimal length, reserved length, incomplete
+header, incomplete payload, and oversized input. The server validates each
+wanted object against its advertised references. It walks commits, trees, and
+tags and does not follow a Gitlink into another repository. It removes objects
+that are reachable from a client `have` line.
+
+Generate a complete pack without deltas. This choice gives a small and
+inspectable feasibility implementation and works when the source repository
+stores objects in loose or delta form. It uses more CPU and network bytes than
+an optimized pack. Milestone 2 must measure normal repositories before it adds
+delta selection or bitmap traversal.
+
+Run repository reads and pack generation as blocking jobs. Keep a maximum of
+four blocking Git jobs for one repository root. The async HTTP and SSH loops do
+not do object traversal or compression.
+
+## Limits
+
+- A packet-line is at most 65,520 bytes.
+- One request is at most 1 MiB.
+- One generated pack contains at most 100,000 reachable objects.
+- One decoded object is at most 64 MiB.
+- The total decoded data and the generated pack are each at most 256 MiB.
+- One repository service runs at most four blocking Git jobs at the same time.
+
+The service does not advertise shallow fetches, partial clone filters, or
+side-band support in protocol versions 0 and 1. Protocol version 2 uses its
+necessary pack side band. The service does not support hidden references,
+replacement objects, or alternates policy in this milestone.
+
+## Evidence
+
+The local macOS gate uses stock Git 2.54.0 and Rust 1.96.0. Stock Git clones
+and repeatedly fetches SHA-1 and SHA-256 repositories through smart HTTP and
+SSH with protocol versions 1 and 2. SHA-1 tests also use protocol version 0. The
+fixtures include an empty repository, branches, an annotated tag, non-ASCII
+filenames, a 2 MiB blob, and packed delta objects. The tests compare the
+received references and objects with the source.
+
+Negative tests cover a damaged reachable object, an unadvertised want, the
+wrong object format, an invalid repository path, an invalid SSH command, an
+invalid HTTP service query, an invalid content type, an invalid protocol
+version, malformed packet lines, and oversized requests. A separate test
+process gives the server a `git` sentinel that fails and records use. An
+absolute stock Git client clones through that server. The clone succeeds and
+the sentinel is not used.
+
+The stripped local arm64 macOS release executable is 2,863,648 bytes. This
+decision stays provisional until the Ubuntu and macOS hosted M1C gates pass.
+
+## Consequences
+
+The selected `gix` crates remove custom repository, object, and pack-file
+decoders. The application still owns a small server-side upload-pack state
+machine because the available `gix-protocol` API is client-oriented.
+
+The initial pack generator holds the generated pack in memory and sends no
+deltas. The explicit 256 MiB limit prevents unbounded allocation, but this
+design is for the feasibility gate. A subsequent change can stream pack chunks
+after it keeps the same request, object, time, and concurrency limits.

scripts/check-m1c

Mode 100755; object 09a275e9fb30

@@ -1,0 +1,5 @@
+#!/bin/sh
+set -eu
+
+./scripts/check
+cargo test --locked --release --test git_repository --test git_http --test git_ssh

src/git/http.rs

Mode 100644; object 58faf3a1ca88

@@ -1,0 +1,180 @@
+use std::net::SocketAddr;
+use std::sync::Arc;
+
+use axum::Router;
+use axum::body::{Body, Bytes};
+use axum::extract::{Path, State};
+use axum::http::{HeaderMap, StatusCode, Uri, header};
+use axum::response::Response;
+use axum::routing::{get, post};
+use thiserror::Error;
+use tokio::net::TcpListener;
+use tokio::sync::oneshot;
+use tokio::task::JoinHandle;
+
+use super::packetline::MAX_REQUEST_BYTES;
+use super::transport::GitRepositories;
+use super::upload_pack::{ProtocolVersion, UploadPack};
+
+pub(crate) struct RunningGitHttpServer {
+    address: SocketAddr,
+    shutdown: oneshot::Sender<()>,
+    task: JoinHandle<std::io::Result<()>>,
+}
+
+impl RunningGitHttpServer {
+    pub(crate) async fn start(
+        address: SocketAddr,
+        repositories: GitRepositories,
+    ) -> Result<Self, GitHttpError> {
+        let listener = TcpListener::bind(address).await?;
+        let address = listener.local_addr()?;
+        let router = Router::new()
+            .route("/{owner}/{repository}/info/refs", get(info_refs))
+            .route("/{owner}/{repository}/git-upload-pack", post(upload_pack))
+            .with_state(Arc::new(repositories));
+        let (shutdown, receiver) = oneshot::channel();
+        let task = tokio::spawn(async move {
+            axum::serve(listener, router)
+                .with_graceful_shutdown(async {
+                    let _ = receiver.await;
+                })
+                .await
+        });
+        Ok(Self {
+            address,
+            shutdown,
+            task,
+        })
+    }
+
+    pub(crate) fn address(&self) -> SocketAddr {
+        self.address
+    }
+
+    pub(crate) async fn shutdown(self) -> Result<(), GitHttpError> {
+        let _ = self.shutdown.send(());
+        self.task.await.map_err(|_| GitHttpError::Join)??;
+        Ok(())
+    }
+}
+
+async fn info_refs(
+    State(repositories): State<Arc<GitRepositories>>,
+    Path((owner, repository)): Path<(String, String)>,
+    uri: Uri,
+    headers: HeaderMap,
+) -> Response {
+    if uri.query() != Some("service=git-upload-pack") {
+        return plain_response(StatusCode::BAD_REQUEST, "invalid Git service query\n");
+    }
+    let Ok(path) = repositories.resolve(&owner, &repository) else {
+        return plain_response(StatusCode::NOT_FOUND, "repository not found\n");
+    };
+    let Ok(version) = protocol_version(&headers) else {
+        return plain_response(StatusCode::BAD_REQUEST, "invalid Git protocol version\n");
+    };
+    let Ok(permit) = repositories.blocking_permit().await else {
+        return plain_response(
+            StatusCode::SERVICE_UNAVAILABLE,
+            "Git service is not available\n",
+        );
+    };
+    let result = tokio::task::spawn_blocking(move || {
+        let _permit = permit;
+        UploadPack::open(&path).and_then(|service| service.advertisement(version, true))
+    })
+    .await;
+    match result {
+        Ok(Ok(body)) => git_response(
+            StatusCode::OK,
+            "application/x-git-upload-pack-advertisement",
+            body,
+        ),
+        Ok(Err(_)) | Err(_) => {
+            plain_response(StatusCode::INTERNAL_SERVER_ERROR, "repository is damaged\n")
+        }
+    }
+}
+
+async fn upload_pack(
+    State(repositories): State<Arc<GitRepositories>>,
+    Path((owner, repository)): Path<(String, String)>,
+    headers: HeaderMap,
+    body: Bytes,
+) -> Response {
+    if headers
+        .get(header::CONTENT_TYPE)
+        .and_then(|value| value.to_str().ok())
+        != Some("application/x-git-upload-pack-request")
+    {
+        return plain_response(StatusCode::UNSUPPORTED_MEDIA_TYPE, "invalid content type\n");
+    }
+    if body.len() > MAX_REQUEST_BYTES {
+        return plain_response(StatusCode::PAYLOAD_TOO_LARGE, "Git request is too large\n");
+    }
+    let Ok(path) = repositories.resolve(&owner, &repository) else {
+        return plain_response(StatusCode::NOT_FOUND, "repository not found\n");
+    };
+    let Ok(version) = protocol_version(&headers) else {
+        return plain_response(StatusCode::BAD_REQUEST, "invalid Git protocol version\n");
+    };
+    let request = body.to_vec();
+    let Ok(permit) = repositories.blocking_permit().await else {
+        return plain_response(
+            StatusCode::SERVICE_UNAVAILABLE,
+            "Git service is not available\n",
+        );
+    };
+    let result = tokio::task::spawn_blocking(move || {
+        let _permit = permit;
+        UploadPack::open(&path).and_then(|service| service.respond(version, &request))
+    })
+    .await;
+    match result {
+        Ok(Ok(body)) => git_response(StatusCode::OK, "application/x-git-upload-pack-result", body),
+        Ok(Err(_)) => plain_response(StatusCode::BAD_REQUEST, "invalid Git request\n"),
+        Err(_) => plain_response(StatusCode::INTERNAL_SERVER_ERROR, "Git service failed\n"),
+    }
+}
+
+fn protocol_version(headers: &HeaderMap) -> Result<ProtocolVersion, ()> {
+    match headers
+        .get("git-protocol")
+        .and_then(|value| value.to_str().ok())
+    {
+        Some("version=2") => Ok(ProtocolVersion::V2),
+        None | Some("version=0") => Ok(ProtocolVersion::V0),
+        Some("version=1") => Ok(ProtocolVersion::V1),
+        Some(_) => Err(()),
+    }
+}
+
+fn git_response(status: StatusCode, content_type: &'static str, body: Vec<u8>) -> Response {
+    Response::builder()
+        .status(status)
+        .header(header::CONTENT_TYPE, content_type)
+        .header(
+            header::CACHE_CONTROL,
+            "no-cache, max-age=0, must-revalidate",
+        )
+        .header(header::PRAGMA, "no-cache")
+        .body(Body::from(body))
+        .expect("the static Git HTTP response is valid")
+}
+
+fn plain_response(status: StatusCode, message: &'static str) -> Response {
+    Response::builder()
+        .status(status)
+        .header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
+        .body(Body::from(message))
+        .expect("the static error response is valid")
+}
+
+#[derive(Debug, Error)]
+pub(crate) enum GitHttpError {
+    #[error("Git HTTP listener error: {0}")]
+    Io(#[from] std::io::Error),
+    #[error("Git HTTP server task failed")]
+    Join,
+}

src/git/mod.rs

Mode 100644; object 1c9cb1c196f5

@@ -1,0 +1,5 @@
+pub(crate) mod http;
+pub(crate) mod packetline;
+pub(crate) mod repository;
+pub(crate) mod transport;
+pub(crate) mod upload_pack;

src/git/packetline.rs

Mode 100644; object 03c508a25eae

@@ -1,0 +1,132 @@
+use thiserror::Error;
+
+pub(crate) const MAX_PACKET_BYTES: usize = 65_520;
+pub(crate) const MAX_REQUEST_BYTES: usize = 1024 * 1024;
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub(crate) enum Packet {
+    Data(Vec<u8>),
+    Flush,
+    Delimiter,
+    ResponseEnd,
+}
+
+pub(crate) fn encode_data(data: &[u8], output: &mut Vec<u8>) -> Result<(), PacketLineError> {
+    let length = data
+        .len()
+        .checked_add(4)
+        .ok_or(PacketLineError::PacketTooLarge)?;
+    if length > MAX_PACKET_BYTES {
+        return Err(PacketLineError::PacketTooLarge);
+    }
+    output.extend_from_slice(format!("{length:04x}").as_bytes());
+    output.extend_from_slice(data);
+    Ok(())
+}
+
+pub(crate) fn encode_flush(output: &mut Vec<u8>) {
+    output.extend_from_slice(b"0000");
+}
+
+pub(crate) fn encode_delimiter(output: &mut Vec<u8>) {
+    output.extend_from_slice(b"0001");
+}
+
+pub(crate) fn encode_sideband(data: &[u8], output: &mut Vec<u8>) -> Result<(), PacketLineError> {
+    for chunk in data.chunks(MAX_PACKET_BYTES - 5) {
+        let mut payload = Vec::with_capacity(chunk.len() + 1);
+        payload.push(1);
+        payload.extend_from_slice(chunk);
+        encode_data(&payload, output)?;
+    }
+    Ok(())
+}
+
+pub(crate) fn decode(input: &[u8]) -> Result<Vec<Packet>, PacketLineError> {
+    if input.len() > MAX_REQUEST_BYTES {
+        return Err(PacketLineError::RequestTooLarge);
+    }
+
+    let mut packets = Vec::new();
+    let mut offset = 0;
+    while offset < input.len() {
+        let header = input
+            .get(offset..offset + 4)
+            .ok_or(PacketLineError::TruncatedHeader)?;
+        if !header.iter().all(u8::is_ascii_hexdigit) {
+            return Err(PacketLineError::InvalidLength);
+        }
+        let header = std::str::from_utf8(header).map_err(|_| PacketLineError::InvalidLength)?;
+        let length =
+            usize::from_str_radix(header, 16).map_err(|_| PacketLineError::InvalidLength)?;
+        offset += 4;
+        match length {
+            0 => packets.push(Packet::Flush),
+            1 => packets.push(Packet::Delimiter),
+            2 => packets.push(Packet::ResponseEnd),
+            3 | 4 => return Err(PacketLineError::InvalidLength),
+            length if length > MAX_PACKET_BYTES => return Err(PacketLineError::PacketTooLarge),
+            length => {
+                let payload_length = length - 4;
+                let payload = input
+                    .get(offset..offset + payload_length)
+                    .ok_or(PacketLineError::TruncatedPacket)?;
+                packets.push(Packet::Data(payload.to_vec()));
+                offset += payload_length;
+            }
+        }
+    }
+    Ok(packets)
+}
+
+#[derive(Debug, Error, Eq, PartialEq)]
+pub(crate) enum PacketLineError {
+    #[error("packet-line request is too large")]
+    RequestTooLarge,
+    #[error("packet-line header is incomplete")]
+    TruncatedHeader,
+    #[error("packet-line length is not valid")]
+    InvalidLength,
+    #[error("packet-line is too large")]
+    PacketTooLarge,
+    #[error("packet-line payload is incomplete")]
+    TruncatedPacket,
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn round_trips_data_and_control_packets() {
+        let mut encoded = Vec::new();
+        encode_data(b"want object\n", &mut encoded).expect("encode data");
+        encode_delimiter(&mut encoded);
+        encode_flush(&mut encoded);
+
+        assert_eq!(
+            decode(&encoded).expect("decode packets"),
+            vec![
+                Packet::Data(b"want object\n".to_vec()),
+                Packet::Delimiter,
+                Packet::Flush
+            ]
+        );
+    }
+
+    #[test]
+    fn rejects_truncated_invalid_and_oversized_packets() {
+        assert_eq!(decode(b"000"), Err(PacketLineError::TruncatedHeader));
+        assert_eq!(decode(b"zzzz"), Err(PacketLineError::InvalidLength));
+        assert_eq!(decode(b"0004"), Err(PacketLineError::InvalidLength));
+        assert_eq!(decode(b"0008abc"), Err(PacketLineError::TruncatedPacket));
+        assert_eq!(
+            decode(&vec![b'0'; MAX_REQUEST_BYTES + 1]),
+            Err(PacketLineError::RequestTooLarge)
+        );
+        assert_eq!(
+            encode_data(&vec![0; MAX_PACKET_BYTES - 3], &mut Vec::new()),
+            Err(PacketLineError::PacketTooLarge)
+        );
+    }
+}

src/git/repository.rs

Mode 100644; object 07060b2da296

@@ -1,0 +1,280 @@
+use std::collections::HashSet;
+use std::path::{Path, PathBuf};
+
+use gix::hash::{Kind, ObjectId};
+use gix::objs::{Data, Kind as ObjectKind, tree::EntryKind};
+use gix_pack::data::Version;
+use gix_pack::data::output::{Count, Entry, bytes::FromEntriesIter};
+use thiserror::Error;
+
+const MAX_OBJECTS_PER_PACK: usize = 100_000;
+const MAX_OBJECT_BYTES: usize = 64 * 1024 * 1024;
+const MAX_PACK_BYTES: usize = 256 * 1024 * 1024;
+
+pub(crate) struct GitRepository {
+    repository: gix::Repository,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub(crate) struct GitReference {
+    pub(crate) name: Vec<u8>,
+    pub(crate) target: ObjectId,
+    pub(crate) peeled: Option<ObjectId>,
+    pub(crate) symbolic_target: Option<Vec<u8>>,
+}
+
+impl GitRepository {
+    pub(crate) fn open(path: &Path) -> Result<Self, GitRepositoryError> {
+        let repository = gix::open(path).map_err(|error| GitRepositoryError::Open {
+            path: path.to_owned(),
+            reason: error.to_string(),
+        })?;
+        if !repository.is_bare() {
+            return Err(GitRepositoryError::NotBare(path.to_owned()));
+        }
+        Ok(Self { repository })
+    }
+
+    pub(crate) fn object_format(&self) -> Kind {
+        self.repository.object_hash()
+    }
+
+    pub(crate) fn references(&self) -> Result<Vec<GitReference>, GitRepositoryError> {
+        let mut references = Vec::new();
+        let platform = self
+            .repository
+            .references()
+            .map_err(|error| GitRepositoryError::References(error.to_string()))?;
+        let iterator = platform
+            .all()
+            .map_err(|error| GitRepositoryError::References(error.to_string()))?;
+
+        for reference in iterator {
+            let reference =
+                reference.map_err(|error| GitRepositoryError::References(error.to_string()))?;
+            let Some(target) = reference.try_id().map(gix::Id::detach) else {
+                continue;
+            };
+            let name = reference.name().as_bstr().to_vec();
+            let peeled = if name.starts_with(b"refs/tags/") {
+                let mut candidate = reference.clone();
+                let candidate = candidate
+                    .peel_to_id()
+                    .map_err(|error| GitRepositoryError::References(error.to_string()))?
+                    .detach();
+                (candidate != target).then_some(candidate)
+            } else {
+                None
+            };
+            references.push(GitReference {
+                name,
+                target,
+                peeled,
+                symbolic_target: None,
+            });
+        }
+        references.sort_by(|left, right| left.name.cmp(&right.name));
+
+        if let Some(head) = self
+            .repository
+            .head_ref()
+            .map_err(|error| GitRepositoryError::References(error.to_string()))?
+        {
+            let target = head.id().detach();
+            references.insert(
+                0,
+                GitReference {
+                    name: b"HEAD".to_vec(),
+                    target,
+                    peeled: None,
+                    symbolic_target: Some(head.name().as_bstr().to_vec()),
+                },
+            );
+        }
+        Ok(references)
+    }
+
+    pub(crate) fn make_pack(
+        &self,
+        wants: &[ObjectId],
+        haves: &[ObjectId],
+    ) -> Result<Vec<u8>, GitRepositoryError> {
+        if wants.iter().any(|want| want.kind() != self.object_format())
+            || haves.iter().any(|have| have.kind() != self.object_format())
+        {
+            return Err(GitRepositoryError::WrongObjectFormat);
+        }
+        let advertised: HashSet<_> = self
+            .references()?
+            .into_iter()
+            .flat_map(|reference| [Some(reference.target), reference.peeled])
+            .flatten()
+            .collect();
+        if wants.is_empty() || wants.iter().any(|want| !advertised.contains(want)) {
+            return Err(GitRepositoryError::UnadvertisedWant);
+        }
+        let excluded = self.walk_reachable(haves, true)?;
+        let mut objects: Vec<_> = self
+            .walk_reachable(wants, false)?
+            .into_iter()
+            .filter(|id| !excluded.contains(id))
+            .collect();
+        objects.sort();
+
+        let object_count =
+            u32::try_from(objects.len()).map_err(|_| GitRepositoryError::ObjectLimit)?;
+        let mut entries = Vec::with_capacity(objects.len());
+        let mut total_object_bytes = 0_usize;
+        for id in objects {
+            let object = self.find_object(id)?;
+            total_object_bytes = total_object_bytes
+                .checked_add(object.data.len())
+                .ok_or(GitRepositoryError::ObjectLimit)?;
+            if object.data.len() > MAX_OBJECT_BYTES || total_object_bytes > MAX_PACK_BYTES {
+                return Err(GitRepositoryError::ObjectLimit);
+            }
+            let count = Count::from_data(id, None);
+            let data = Data::new(&object.data, object.kind, self.object_format());
+            entries.push(
+                Entry::from_data(&count, &data)
+                    .map_err(|error| GitRepositoryError::Pack(error.to_string()))?,
+            );
+        }
+
+        let chunks = entries
+            .into_iter()
+            .map(|entry| Ok::<_, std::io::Error>(vec![entry]));
+        let mut writer = FromEntriesIter::new(
+            chunks,
+            Vec::new(),
+            object_count,
+            Version::V2,
+            self.object_format(),
+        );
+        for result in writer.by_ref() {
+            result.map_err(|error| GitRepositoryError::Pack(error.to_string()))?;
+        }
+        let pack = writer.into_write();
+        if pack.len() > MAX_PACK_BYTES {
+            return Err(GitRepositoryError::PackLimit);
+        }
+        Ok(pack)
+    }
+
+    fn walk_reachable(
+        &self,
+        roots: &[ObjectId],
+        permit_missing_roots: bool,
+    ) -> Result<HashSet<ObjectId>, GitRepositoryError> {
+        let mut seen = HashSet::new();
+        let mut pending = roots.to_vec();
+        while let Some(id) = pending.pop() {
+            if !seen.insert(id) {
+                continue;
+            }
+            if seen.len() > MAX_OBJECTS_PER_PACK {
+                return Err(GitRepositoryError::ObjectLimit);
+            }
+            let object = match self.repository.try_find_object(id) {
+                Ok(Some(object)) => object,
+                Ok(None) if permit_missing_roots && roots.contains(&id) => {
+                    seen.remove(&id);
+                    continue;
+                }
+                Ok(None) => return Err(GitRepositoryError::MissingObject(id)),
+                Err(error) => {
+                    return Err(GitRepositoryError::Object {
+                        id,
+                        reason: error.to_string(),
+                    });
+                }
+            };
+            match object.kind {
+                ObjectKind::Blob => {}
+                ObjectKind::Commit => {
+                    let commit = object.try_to_commit_ref().map_err(|error| {
+                        GitRepositoryError::DamagedObject {
+                            id,
+                            reason: error.to_string(),
+                        }
+                    })?;
+                    pending.push(self.parse_id(commit.tree, id)?);
+                    for parent in commit.parents {
+                        pending.push(self.parse_id(parent, id)?);
+                    }
+                }
+                ObjectKind::Tree => {
+                    let tree = object.into_tree();
+                    for entry in tree.iter() {
+                        let entry = entry.map_err(|error| GitRepositoryError::DamagedObject {
+                            id,
+                            reason: error.to_string(),
+                        })?;
+                        if entry.kind() != EntryKind::Commit {
+                            pending.push(entry.oid().to_owned());
+                        }
+                    }
+                }
+                ObjectKind::Tag => {
+                    let tag = object.try_to_tag_ref().map_err(|error| {
+                        GitRepositoryError::DamagedObject {
+                            id,
+                            reason: error.to_string(),
+                        }
+                    })?;
+                    pending.push(self.parse_id(tag.target, id)?);
+                }
+            }
+        }
+        Ok(seen)
+    }
+
+    fn find_object(&self, id: ObjectId) -> Result<gix::Object<'_>, GitRepositoryError> {
+        self.repository
+            .find_object(id)
+            .map_err(|error| GitRepositoryError::Object {
+                id,
+                reason: error.to_string(),
+            })
+    }
+
+    fn parse_id(&self, input: &[u8], owner: ObjectId) -> Result<ObjectId, GitRepositoryError> {
+        let id = ObjectId::from_hex(input).map_err(|error| GitRepositoryError::DamagedObject {
+            id: owner,
+            reason: error.to_string(),
+        })?;
+        if id.kind() != self.object_format() {
+            return Err(GitRepositoryError::DamagedObject {
+                id: owner,
+                reason: "object ID uses the wrong hash format".to_owned(),
+            });
+        }
+        Ok(id)
+    }
+}
+
+#[derive(Debug, Error)]
+pub(crate) enum GitRepositoryError {
+    #[error("cannot open Git repository {path}: {reason}")]
+    Open { path: PathBuf, reason: String },
+    #[error("Git repository is not bare: {0}")]
+    NotBare(PathBuf),
+    #[error("cannot read Git references: {0}")]
+    References(String),
+    #[error("cannot read Git object {id}: {reason}")]
+    Object { id: ObjectId, reason: String },
+    #[error("Git object does not exist: {0}")]
+    MissingObject(ObjectId),
+    #[error("Git object {id} is damaged: {reason}")]
+    DamagedObject { id: ObjectId, reason: String },
+    #[error("client requested an object that was not advertised")]
+    UnadvertisedWant,
+    #[error("object ID uses the wrong repository hash format")]
+    WrongObjectFormat,
+    #[error("Git object count or decoded size exceeds the limit")]
+    ObjectLimit,
+    #[error("generated Git pack exceeds the limit")]
+    PackLimit,
+    #[error("cannot generate Git pack: {0}")]
+    Pack(String),
+}

src/git/transport.rs

Mode 100644; object e64ef0da25ad

@@ -1,0 +1,152 @@
+use std::fs;
+use std::path::{Path, PathBuf};
+
+use thiserror::Error;
+use tokio::sync::{AcquireError, OwnedSemaphorePermit, Semaphore};
+
+const MAX_BLOCKING_GIT_JOBS: usize = 4;
+
+#[derive(Clone)]
+pub(crate) struct GitRepositories {
+    root: PathBuf,
+    blocking_jobs: std::sync::Arc<Semaphore>,
+}
+
+impl GitRepositories {
+    pub(crate) fn new(root: &Path) -> Result<Self, RepositoryPathError> {
+        let root = fs::canonicalize(root).map_err(|source| RepositoryPathError::Root {
+            path: root.to_owned(),
+            source,
+        })?;
+        Ok(Self {
+            root,
+            blocking_jobs: std::sync::Arc::new(Semaphore::new(MAX_BLOCKING_GIT_JOBS)),
+        })
+    }
+
+    pub(crate) fn resolve(
+        &self,
+        owner: &str,
+        repository: &str,
+    ) -> Result<PathBuf, RepositoryPathError> {
+        if !valid_name(owner, 40) {
+            return Err(RepositoryPathError::InvalidName);
+        }
+        let repository = repository.strip_suffix(".git").unwrap_or(repository);
+        if !valid_name(repository, 100) {
+            return Err(RepositoryPathError::InvalidName);
+        }
+        let candidate = self.root.join(owner).join(format!("{repository}.git"));
+        let candidate =
+            fs::canonicalize(&candidate).map_err(|source| RepositoryPathError::Repository {
+                path: candidate,
+                source,
+            })?;
+        if !candidate.starts_with(&self.root) {
+            return Err(RepositoryPathError::OutsideRoot);
+        }
+        Ok(candidate)
+    }
+
+    pub(crate) fn resolve_ssh_command(
+        &self,
+        command: &[u8],
+    ) -> Result<PathBuf, RepositoryPathError> {
+        let command =
+            std::str::from_utf8(command).map_err(|_| RepositoryPathError::InvalidCommand)?;
+        let path = command
+            .strip_prefix("git-upload-pack '")
+            .and_then(|value| value.strip_suffix('\''))
+            .ok_or(RepositoryPathError::InvalidCommand)?;
+        if path.contains('\'') {
+            return Err(RepositoryPathError::InvalidCommand);
+        }
+        let path = path.strip_prefix('/').unwrap_or(path);
+        let mut components = path.split('/');
+        let owner = components
+            .next()
+            .ok_or(RepositoryPathError::InvalidCommand)?;
+        let repository = components
+            .next()
+            .ok_or(RepositoryPathError::InvalidCommand)?;
+        if components.next().is_some() {
+            return Err(RepositoryPathError::InvalidCommand);
+        }
+        self.resolve(owner, repository)
+    }
+
+    pub(crate) async fn blocking_permit(&self) -> Result<OwnedSemaphorePermit, AcquireError> {
+        self.blocking_jobs.clone().acquire_owned().await
+    }
+}
+
+fn valid_name(value: &str, maximum: usize) -> bool {
+    !value.is_empty()
+        && value.len() <= maximum
+        && value.is_ascii()
+        && value.bytes().all(|byte| {
+            byte.is_ascii_lowercase() || byte.is_ascii_digit() || b"._-".contains(&byte)
+        })
+        && !value.starts_with(['.', '-', '_'])
+        && !value.ends_with(['.', '-', '_'])
+        && !value.contains("..")
+}
+
+#[derive(Debug, Error)]
+pub(crate) enum RepositoryPathError {
+    #[error("cannot open repository root {path}: {source}")]
+    Root {
+        path: PathBuf,
+        source: std::io::Error,
+    },
+    #[error("repository owner or name is not valid")]
+    InvalidName,
+    #[error("SSH Git command is not valid")]
+    InvalidCommand,
+    #[error("cannot open repository {path}: {source}")]
+    Repository {
+        path: PathBuf,
+        source: std::io::Error,
+    },
+    #[error("repository resolves outside the repository root")]
+    OutsideRoot,
+}
+
+#[cfg(test)]
+mod tests {
+    use std::fs;
+
+    use tempfile::TempDir;
+
+    use super::*;
+
+    #[test]
+    fn resolves_only_strict_repository_paths_and_commands() {
+        let directory = TempDir::new().expect("create a repository root");
+        let repository = directory.path().join("alice/example.git");
+        fs::create_dir_all(&repository).expect("create a repository");
+        let repositories =
+            GitRepositories::new(directory.path()).expect("open the repository root");
+
+        assert_eq!(
+            repositories
+                .resolve("alice", "example")
+                .expect("resolve a repository"),
+            fs::canonicalize(&repository).expect("canonicalize the repository")
+        );
+        assert_eq!(
+            repositories
+                .resolve_ssh_command(b"git-upload-pack '/alice/example.git'")
+                .expect("resolve an SSH Git command"),
+            fs::canonicalize(&repository).expect("canonicalize the repository")
+        );
+        for command in [
+            b"git-upload-pack '../../etc'".as_slice(),
+            b"git-upload-pack '/alice/example.git'; uname".as_slice(),
+            b"git-receive-pack '/alice/example.git'".as_slice(),
+            b"git-upload-pack '/alice/example.git/extra'".as_slice(),
+        ] {
+            assert!(repositories.resolve_ssh_command(command).is_err());
+        }
+    }
+}

src/git/upload_pack.rs

Mode 100644; object 5380dc1d4a84

@@ -1,0 +1,333 @@
+use std::path::Path;
+
+use gix::hash::{Kind, ObjectId};
+use thiserror::Error;
+
+use super::packetline::{
+    Packet, PacketLineError, decode, encode_data, encode_flush, encode_sideband,
+};
+use super::repository::{GitReference, GitRepository, GitRepositoryError};
+
+const AGENT: &str = concat!("tit/", env!("CARGO_PKG_VERSION"));
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub(crate) enum ProtocolVersion {
+    V0,
+    V1,
+    V2,
+}
+
+pub(crate) struct UploadPack {
+    repository: GitRepository,
+}
+
+impl UploadPack {
+    pub(crate) fn open(path: &Path) -> Result<Self, UploadPackError> {
+        Ok(Self {
+            repository: GitRepository::open(path)?,
+        })
+    }
+
+    pub(crate) fn advertisement(
+        &self,
+        version: ProtocolVersion,
+        http: bool,
+    ) -> Result<Vec<u8>, UploadPackError> {
+        let mut output = Vec::new();
+        if http && version != ProtocolVersion::V2 {
+            encode_data(b"# service=git-upload-pack\n", &mut output)?;
+            encode_flush(&mut output);
+        }
+        match version {
+            ProtocolVersion::V0 => self.advertise_v0_or_v1(&mut output, false)?,
+            ProtocolVersion::V1 => self.advertise_v0_or_v1(&mut output, true)?,
+            ProtocolVersion::V2 => self.advertise_v2(&mut output)?,
+        }
+        Ok(output)
+    }
+
+    pub(crate) fn respond(
+        &self,
+        version: ProtocolVersion,
+        request: &[u8],
+    ) -> Result<Vec<u8>, UploadPackError> {
+        let packets = decode(request)?;
+        match version {
+            ProtocolVersion::V0 | ProtocolVersion::V1 => self.respond_v1(&packets),
+            ProtocolVersion::V2 => self.respond_v2(&packets),
+        }
+    }
+
+    fn advertise_v0_or_v1(
+        &self,
+        output: &mut Vec<u8>,
+        version_packet: bool,
+    ) -> Result<(), UploadPackError> {
+        if version_packet {
+            encode_data(b"version 1\n", output)?;
+        }
+        let references = self.repository.references()?;
+        let capabilities = format!(
+            "agent={AGENT} object-format={}",
+            hash_name(self.repository.object_format())
+        );
+        if references.is_empty() {
+            let zero = "0".repeat(self.repository.object_format().len_in_hex());
+            encode_data(
+                format!("{zero} capabilities^{{}}\0{capabilities}\n").as_bytes(),
+                output,
+            )?;
+        } else {
+            for (index, reference) in references.iter().enumerate() {
+                let suffix = if index == 0 {
+                    format!("\0{capabilities}")
+                } else {
+                    String::new()
+                };
+                encode_data(
+                    format!(
+                        "{} {}{suffix}\n",
+                        reference.target,
+                        String::from_utf8_lossy(&reference.name)
+                    )
+                    .as_bytes(),
+                    output,
+                )?;
+                if let Some(peeled) = reference.peeled {
+                    encode_data(
+                        format!(
+                            "{peeled} {}^{{}}\n",
+                            String::from_utf8_lossy(&reference.name)
+                        )
+                        .as_bytes(),
+                        output,
+                    )?;
+                }
+            }
+        }
+        encode_flush(output);
+        Ok(())
+    }
+
+    fn advertise_v2(&self, output: &mut Vec<u8>) -> Result<(), UploadPackError> {
+        for capability in [
+            "version 2\n".to_owned(),
+            format!("agent={AGENT}\n"),
+            format!(
+                "object-format={}\n",
+                hash_name(self.repository.object_format())
+            ),
+            "ls-refs=symrefs peel\n".to_owned(),
+            "fetch=wait-for-done\n".to_owned(),
+        ] {
+            encode_data(capability.as_bytes(), output)?;
+        }
+        encode_flush(output);
+        Ok(())
+    }
+
+    fn respond_v1(&self, packets: &[Packet]) -> Result<Vec<u8>, UploadPackError> {
+        let mut wants = Vec::new();
+        let mut haves = Vec::new();
+        let mut done = false;
+        for packet in packets {
+            let Packet::Data(line) = packet else {
+                continue;
+            };
+            let line = trim_line(line);
+            if let Some(value) = line.strip_prefix(b"want ") {
+                let id = value.split(|byte| *byte == b' ').next().unwrap_or_default();
+                wants.push(self.parse_id(id)?);
+            } else if let Some(value) = line.strip_prefix(b"have ") {
+                haves.push(self.parse_id(value)?);
+            } else if line == b"done" {
+                done = true;
+            } else {
+                return Err(UploadPackError::UnsupportedRequest);
+            }
+        }
+        if !done || wants.is_empty() {
+            return Err(UploadPackError::IncompleteNegotiation);
+        }
+
+        let pack = self.repository.make_pack(&wants, &haves)?;
+        let mut output = Vec::with_capacity(pack.len() + 8);
+        encode_data(b"NAK\n", &mut output)?;
+        output.extend_from_slice(&pack);
+        Ok(output)
+    }
+
+    fn respond_v2(&self, packets: &[Packet]) -> Result<Vec<u8>, UploadPackError> {
+        let delimiter = packets
+            .iter()
+            .position(|packet| *packet == Packet::Delimiter)
+            .ok_or(UploadPackError::UnsupportedRequest)?;
+        let command = command_name(&packets[..delimiter], self.repository.object_format())?;
+        let arguments = &packets[delimiter + 1..];
+        match command {
+            b"ls-refs" => self.respond_ls_refs(arguments),
+            b"fetch" => self.respond_fetch(arguments),
+            _ => Err(UploadPackError::UnsupportedRequest),
+        }
+    }
+
+    fn respond_ls_refs(&self, packets: &[Packet]) -> Result<Vec<u8>, UploadPackError> {
+        let mut symrefs = false;
+        let mut peel = false;
+        let mut prefixes = Vec::new();
+        for packet in packets {
+            match packet {
+                Packet::Data(line) => {
+                    let line = trim_line(line);
+                    if line == b"symrefs" {
+                        symrefs = true;
+                    } else if line == b"peel" {
+                        peel = true;
+                    } else if let Some(prefix) = line.strip_prefix(b"ref-prefix ") {
+                        prefixes.push(prefix.to_vec());
+                    } else {
+                        return Err(UploadPackError::UnsupportedRequest);
+                    }
+                }
+                Packet::Flush => {}
+                Packet::Delimiter | Packet::ResponseEnd => {
+                    return Err(UploadPackError::UnsupportedRequest);
+                }
+            }
+        }
+
+        let mut output = Vec::new();
+        for reference in self.repository.references()? {
+            if !prefixes.is_empty()
+                && !prefixes
+                    .iter()
+                    .any(|prefix| reference.name.starts_with(prefix))
+            {
+                continue;
+            }
+            encode_data(&format_ref(&reference, symrefs, peel), &mut output)?;
+        }
+        encode_flush(&mut output);
+        Ok(output)
+    }
+
+    fn respond_fetch(&self, packets: &[Packet]) -> Result<Vec<u8>, UploadPackError> {
+        let mut wants = Vec::new();
+        let mut haves = Vec::new();
+        let mut done = false;
+        for packet in packets {
+            match packet {
+                Packet::Data(line) => {
+                    let line = trim_line(line);
+                    if let Some(value) = line.strip_prefix(b"want ") {
+                        wants.push(self.parse_id(value)?);
+                    } else if let Some(value) = line.strip_prefix(b"have ") {
+                        haves.push(self.parse_id(value)?);
+                    } else if line == b"done" {
+                        done = true;
+                    } else if !matches!(
+                        line,
+                        b"thin-pack" | b"no-progress" | b"include-tag" | b"ofs-delta"
+                    ) {
+                        return Err(UploadPackError::UnsupportedRequest);
+                    }
+                }
+                Packet::Flush => {}
+                Packet::Delimiter | Packet::ResponseEnd => {
+                    return Err(UploadPackError::UnsupportedRequest);
+                }
+            }
+        }
+        if wants.is_empty() {
+            return Err(UploadPackError::IncompleteNegotiation);
+        }
+        if !done {
+            let mut output = Vec::new();
+            encode_data(b"acknowledgments\n", &mut output)?;
+            encode_data(b"NAK\n", &mut output)?;
+            encode_flush(&mut output);
+            return Ok(output);
+        }
+
+        let pack = self.repository.make_pack(&wants, &haves)?;
+        let mut output = Vec::with_capacity(pack.len() + 32);
+        encode_data(b"packfile\n", &mut output)?;
+        encode_sideband(&pack, &mut output)?;
+        encode_flush(&mut output);
+        Ok(output)
+    }
+
+    fn parse_id(&self, input: &[u8]) -> Result<ObjectId, UploadPackError> {
+        let id = ObjectId::from_hex(input).map_err(|_| UploadPackError::InvalidObjectId)?;
+        if id.kind() != self.repository.object_format() {
+            return Err(UploadPackError::InvalidObjectId);
+        }
+        Ok(id)
+    }
+}
+
+fn command_name(packets: &[Packet], object_format: Kind) -> Result<&[u8], UploadPackError> {
+    let mut command = None;
+    for packet in packets {
+        let Packet::Data(line) = packet else {
+            return Err(UploadPackError::UnsupportedRequest);
+        };
+        let line = trim_line(line);
+        if let Some(value) = line.strip_prefix(b"command=") {
+            if command.replace(value).is_some() {
+                return Err(UploadPackError::UnsupportedRequest);
+            }
+        } else if let Some(value) = line.strip_prefix(b"object-format=") {
+            if value != hash_name(object_format).as_bytes() {
+                return Err(UploadPackError::InvalidObjectId);
+            }
+        } else if !line.starts_with(b"agent=") {
+            return Err(UploadPackError::UnsupportedRequest);
+        }
+    }
+    command.ok_or(UploadPackError::UnsupportedRequest)
+}
+
+fn format_ref(reference: &GitReference, symrefs: bool, peel: bool) -> Vec<u8> {
+    let mut output = format!(
+        "{} {}",
+        reference.target,
+        String::from_utf8_lossy(&reference.name)
+    )
+    .into_bytes();
+    if symrefs && let Some(target) = &reference.symbolic_target {
+        output.extend_from_slice(b" symref-target:");
+        output.extend_from_slice(target);
+    }
+    if peel && let Some(target) = reference.peeled {
+        output.extend_from_slice(format!(" peeled:{target}").as_bytes());
+    }
+    output.push(b'\n');
+    output
+}
+
+fn trim_line(line: &[u8]) -> &[u8] {
+    line.strip_suffix(b"\n").unwrap_or(line)
+}
+
+fn hash_name(kind: Kind) -> &'static str {
+    match kind {
+        Kind::Sha1 => "sha1",
+        Kind::Sha256 => "sha256",
+        _ => unreachable!("gix returned an unsupported object hash"),
+    }
+}
+
+#[derive(Debug, Error)]
+pub(crate) enum UploadPackError {
+    #[error(transparent)]
+    Repository(#[from] GitRepositoryError),
+    #[error(transparent)]
+    PacketLine(#[from] PacketLineError),
+    #[error("upload-pack request is not supported")]
+    UnsupportedRequest,
+    #[error("upload-pack object ID is not valid for this repository")]
+    InvalidObjectId,
+    #[error("upload-pack negotiation is incomplete")]
+    IncompleteNegotiation,
+}

src/main.rs

Mode 100644100644; object 4ae77ffedf3848c736a69cce

@@ -2,6 +2,8 @@
 mod auth;
 mod cli;
 mod config;
+#[allow(dead_code, reason = "M1C proves Git reads before the CLI serves them")]
+mod git;
 #[allow(dead_code, reason = "M1B proves the SSH server before M2 calls it")]
 mod ssh;
 mod store;

src/ssh.rs

Mode 100644100644; object f3c93da72da315a2e93c109a

@@ -1,5 +1,5 @@
 use std::borrow::Cow;
-use std::collections::HashSet;
+use std::collections::{HashMap, HashSet};
 use std::net::SocketAddr;
 use std::sync::Arc;
 use std::sync::atomic::{AtomicUsize, Ordering};
@@ -15,6 +15,9 @@
 use tokio::task::JoinHandle;
 
 use crate::auth::SshPublicKey;
+use crate::git::packetline::{MAX_REQUEST_BYTES, Packet, decode, encode_data};
+use crate::git::transport::GitRepositories;
+use crate::git::upload_pack::{ProtocolVersion, UploadPack, UploadPackError};
 
 const VERSION_COMMAND: &[u8] = b"tit --version";
 const GIT_PROTOCOL_VARIABLE: &str = "GIT_PROTOCOL";
@@ -30,6 +33,22 @@
     pub(crate) async fn start(
         address: SocketAddr,
         authorized_keys: &[SshPublicKey],
+    ) -> Result<Self, SshServerError> {
+        Self::start_inner(address, authorized_keys, None).await
+    }
+
+    pub(crate) async fn start_with_git(
+        address: SocketAddr,
+        authorized_keys: &[SshPublicKey],
+        repositories: GitRepositories,
+    ) -> Result<Self, SshServerError> {
+        Self::start_inner(address, authorized_keys, Some(repositories)).await
+    }
+
+    async fn start_inner(
+        address: SocketAddr,
+        authorized_keys: &[SshPublicKey],
+        repositories: Option<GitRepositories>,
     ) -> Result<Self, SshServerError> {
         let listener = TcpListener::bind(address).await?;
         let address = listener.local_addr()?;
@@ -65,6 +84,7 @@
         let server = SshServer {
             authorized_keys,
             audit: Arc::clone(&audit),
+            repositories: repositories.map(Arc::new),
         };
         let (handle_sender, handle_receiver) = oneshot::channel();
         let task = tokio::spawn(async move {
@@ -113,6 +133,7 @@
 struct SshServer {
     authorized_keys: Arc<HashSet<PublicKey>>,
     audit: Arc<RequestAudit>,
+    repositories: Option<Arc<GitRepositories>>,
 }
 
 impl Server for SshServer {
@@ -122,6 +143,9 @@
         SshSession {
             authorized_keys: Arc::clone(&self.authorized_keys),
             audit: Arc::clone(&self.audit),
+            repositories: self.repositories.clone(),
+            protocol: ProtocolVersion::V0,
+            git_channels: HashMap::new(),
         }
     }
 }
@@ -129,6 +153,14 @@
 struct SshSession {
     authorized_keys: Arc<HashSet<PublicKey>>,
     audit: Arc<RequestAudit>,
+    repositories: Option<Arc<GitRepositories>>,
+    protocol: ProtocolVersion,
+    git_channels: HashMap<ChannelId, GitChannel>,
+}
+
+struct GitChannel {
+    service: UploadPack,
+    request: Vec<u8>,
 }
 
 impl Handler for SshSession {
@@ -235,6 +267,12 @@
         session: &mut Session,
     ) -> Result<(), Self::Error> {
         if variable_name == GIT_PROTOCOL_VARIABLE && valid_git_protocol(variable_value) {
+            self.protocol = match variable_value {
+                "version=0" => ProtocolVersion::V0,
+                "version=1" => ProtocolVersion::V1,
+                "version=2" => ProtocolVersion::V2,
+                _ => unreachable!("the Git protocol value was validated"),
+            };
             self.audit.accepted_env.fetch_add(1, Ordering::Relaxed);
             session.channel_success(channel)?;
         } else {
@@ -272,10 +310,131 @@
             session.eof(channel)?;
             session.close(channel)?;
         } else {
-            self.audit.rejected_exec.fetch_add(1, Ordering::Relaxed);
-            session.channel_failure(channel)?;
-            session.close(channel)?;
+            let service = if let Some(repositories) = &self.repositories
+                && let Ok(path) = repositories.resolve_ssh_command(command)
+                && let Ok(permit) = repositories.blocking_permit().await
+            {
+                let protocol = self.protocol;
+                tokio::task::spawn_blocking(move || {
+                    let _permit = permit;
+                    let service = UploadPack::open(&path)?;
+                    let advertisement = service.advertisement(protocol, false)?;
+                    Ok::<_, UploadPackError>((service, advertisement))
+                })
+                .await
+                .ok()
+                .and_then(Result::ok)
+            } else {
+                None
+            };
+            if let Some((service, advertisement)) = service {
+                self.audit.accepted_exec.fetch_add(1, Ordering::Relaxed);
+                session.channel_success(channel)?;
+                session.data(channel, advertisement)?;
+                self.git_channels.insert(
+                    channel,
+                    GitChannel {
+                        service,
+                        request: Vec::new(),
+                    },
+                );
+            } else {
+                self.audit.rejected_exec.fetch_add(1, Ordering::Relaxed);
+                session.channel_failure(channel)?;
+                session.close(channel)?;
+            }
         }
+        Ok(())
+    }
+
+    async fn data(
+        &mut self,
+        channel: ChannelId,
+        data: &[u8],
+        session: &mut Session,
+    ) -> Result<(), Self::Error> {
+        let Some(mut git) = self.git_channels.remove(&channel) else {
+            return Ok(());
+        };
+        if git.request.len().saturating_add(data.len()) > MAX_REQUEST_BYTES {
+            fail_git_channel(channel, session)?;
+            return Ok(());
+        }
+        git.request.extend_from_slice(data);
+
+        let packets = match decode(&git.request) {
+            Ok(packets) => packets,
+            Err(super::git::packetline::PacketLineError::TruncatedHeader)
+            | Err(super::git::packetline::PacketLineError::TruncatedPacket) => {
+                self.git_channels.insert(channel, git);
+                return Ok(());
+            }
+            Err(_) => {
+                fail_git_channel(channel, session)?;
+                return Ok(());
+            }
+        };
+        if packets == [Packet::Flush] {
+            finish_git_channel(channel, 0, session)?;
+            return Ok(());
+        }
+
+        match self.protocol {
+            ProtocolVersion::V0 | ProtocolVersion::V1 => {
+                let done = packets.last().is_some_and(
+                    |packet| matches!(packet, Packet::Data(line) if trim_line(line) == b"done"),
+                );
+                if done {
+                    match respond_git(self.repositories.clone(), self.protocol, git).await {
+                        Some((_, Ok(response))) => {
+                            session.data(channel, response)?;
+                            finish_git_channel(channel, 0, session)?;
+                        }
+                        Some((_, Err(_))) | None => fail_git_channel(channel, session)?,
+                    }
+                } else if packets.last() == Some(&Packet::Flush) {
+                    let mut response = Vec::new();
+                    encode_data(b"NAK\n", &mut response).expect("a NAK packet is within the limit");
+                    session.data(channel, response)?;
+                    self.git_channels.insert(channel, git);
+                } else {
+                    self.git_channels.insert(channel, git);
+                }
+            }
+            ProtocolVersion::V2 => {
+                if packets.last() != Some(&Packet::Flush) {
+                    self.git_channels.insert(channel, git);
+                    return Ok(());
+                }
+                let fetch = packets.iter().any(
+                    |packet| matches!(packet, Packet::Data(line) if trim_line(line) == b"command=fetch"),
+                );
+                let done = packets.iter().any(
+                    |packet| matches!(packet, Packet::Data(line) if trim_line(line) == b"done"),
+                );
+                match respond_git(self.repositories.clone(), self.protocol, git).await {
+                    Some((mut git, Ok(response))) => {
+                        session.data(channel, response)?;
+                        if fetch && done {
+                            finish_git_channel(channel, 0, session)?;
+                        } else {
+                            git.request.clear();
+                            self.git_channels.insert(channel, git);
+                        }
+                    }
+                    Some((_, Err(_))) | None => fail_git_channel(channel, session)?,
+                }
+            }
+        }
+        Ok(())
+    }
+
+    async fn channel_close(
+        &mut self,
+        channel: ChannelId,
+        _session: &mut Session,
+    ) -> Result<(), Self::Error> {
+        self.git_channels.remove(&channel);
         Ok(())
     }
 
@@ -331,8 +490,46 @@
     }
 }
 
+async fn respond_git(
+    repositories: Option<Arc<GitRepositories>>,
+    protocol: ProtocolVersion,
+    git: GitChannel,
+) -> Option<(GitChannel, Result<Vec<u8>, UploadPackError>)> {
+    let permit = repositories?.blocking_permit().await.ok()?;
+    tokio::task::spawn_blocking(move || {
+        let _permit = permit;
+        let response = git.service.respond(protocol, &git.request);
+        (git, response)
+    })
+    .await
+    .ok()
+}
+
 fn valid_git_protocol(value: &str) -> bool {
     matches!(value, "version=0" | "version=1" | "version=2")
+}
+
+fn trim_line(line: &[u8]) -> &[u8] {
+    line.strip_suffix(b"\n").unwrap_or(line)
+}
+
+fn finish_git_channel(
+    channel: ChannelId,
+    status: u32,
+    session: &mut Session,
+) -> Result<(), russh::Error> {
+    session.exit_status_request(channel, status)?;
+    session.eof(channel)?;
+    session.close(channel)?;
+    Ok(())
+}
+
+fn fail_git_channel(channel: ChannelId, session: &mut Session) -> Result<(), russh::Error> {
+    let mut error = Vec::new();
+    encode_data(b"ERR invalid Git request\n", &mut error)
+        .expect("a Git error packet is within the limit");
+    session.data(channel, error)?;
+    finish_git_channel(channel, 1, session)
 }
 
 #[derive(Default)]

tests/git_http.rs

Mode 100644; object c44187436546

@@ -1,0 +1,468 @@
+#[path = "../src/git/http.rs"]
+mod http;
+#[allow(
+    dead_code,
+    reason = "the HTTP test does not use each shared protocol API"
+)]
+#[path = "../src/git/packetline.rs"]
+mod packetline;
+#[allow(
+    dead_code,
+    reason = "the HTTP test does not inspect repository internals"
+)]
+#[path = "../src/git/repository.rs"]
+mod repository;
+#[allow(
+    dead_code,
+    reason = "the HTTP test uses transport resolution through HTTP"
+)]
+#[path = "../src/git/transport.rs"]
+mod transport;
+#[allow(dead_code, reason = "the HTTP test uses upload-pack through HTTP")]
+#[path = "../src/git/upload_pack.rs"]
+mod upload_pack;
+
+use std::fs;
+use std::io::{Read, Write};
+use std::net::{Ipv4Addr, SocketAddr};
+use std::os::unix::fs::PermissionsExt;
+use std::path::{Path, PathBuf};
+use std::process::Command;
+
+use http::RunningGitHttpServer;
+use tempfile::TempDir;
+use transport::GitRepositories;
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn stock_git_clones_and_fetches_both_hash_formats_over_smart_http() {
+    for format in ["sha1", "sha256"] {
+        let directory = TempDir::new().expect("create a Git HTTP fixture directory");
+        let repositories_root = directory.path().join("repositories");
+        let bare = repositories_root.join("alice/example.git");
+        let worktree = directory.path().join("worktree");
+        create_fixture(&worktree, &bare, format);
+        let repositories =
+            GitRepositories::new(&repositories_root).expect("open the repository root");
+        let server =
+            RunningGitHttpServer::start(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)), repositories)
+                .await
+                .expect("start the Git HTTP server");
+
+        let versions: &[&str] = if format == "sha1" {
+            &["0", "1", "2"]
+        } else {
+            &["1", "2"]
+        };
+        for version in versions {
+            let clone = directory.path().join(format!("clone-v{version}"));
+            let url = format!("http://{}/alice/example", server.address());
+            run(Command::new("git")
+                .args(["-c", &format!("protocol.version={version}"), "clone", "-q"])
+                .arg(&url)
+                .arg(&clone));
+            assert_eq!(head(&clone), head(&worktree));
+            assert_eq!(
+                rev_parse(&clone, "refs/remotes/origin/feature"),
+                rev_parse(&worktree, "refs/heads/feature")
+            );
+            assert_eq!(
+                rev_parse(&clone, "refs/tags/v1^{}"),
+                rev_parse(&worktree, "refs/tags/v1^{}")
+            );
+            assert_eq!(
+                fs::read(clone.join("non-ascii-\u{00e5}.txt")).expect("read a cloned file"),
+                b"first payload\n"
+            );
+            assert_eq!(
+                fs::metadata(clone.join("large-copy.bin"))
+                    .expect("inspect a cloned large blob")
+                    .len(),
+                2 * 1024 * 1024
+            );
+
+            append_commit(&worktree, format!("fetch-v{version}.txt"));
+            run(Command::new("git").arg("-C").arg(&worktree).args([
+                "push",
+                "-q",
+                &bare.to_string_lossy(),
+                "main",
+            ]));
+            run(Command::new("git").arg("-C").arg(&clone).args([
+                "-c",
+                &format!("protocol.version={version}"),
+                "fetch",
+                "-q",
+            ]));
+            assert_eq!(
+                rev_parse(&clone, "refs/remotes/origin/main"),
+                head(&worktree)
+            );
+        }
+
+        server.shutdown().await.expect("stop the Git HTTP server");
+    }
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn rejects_invalid_http_routes_and_oversized_requests() {
+    let directory = TempDir::new().expect("create a Git HTTP fixture directory");
+    let repositories_root = directory.path().join("repositories");
+    fs::create_dir_all(&repositories_root).expect("create the repository root");
+    let repository = repositories_root.join("alice/example.git");
+    fs::create_dir_all(repository.parent().expect("a repository owner directory"))
+        .expect("create the repository owner directory");
+    run(Command::new("git")
+        .args(["init", "-q", "--bare"])
+        .arg(&repository));
+    let repositories = GitRepositories::new(&repositories_root).expect("open the repository root");
+    let server =
+        RunningGitHttpServer::start(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)), repositories)
+            .await
+            .expect("start the Git HTTP server");
+
+    let missing = Command::new("git")
+        .args(["ls-remote", &format!("http://{}/../etc", server.address())])
+        .output()
+        .expect("run stock Git against an invalid route");
+    assert!(!missing.status.success());
+
+    let malformed = raw_http_request(
+        server.address(),
+        "/alice/example/git-upload-pack",
+        "application/x-git-upload-pack-request",
+        Some("version=2"),
+        b"zzzz",
+    );
+    assert!(malformed.starts_with(b"HTTP/1.1 400"));
+
+    let wrong_service = raw_http_get(
+        server.address(),
+        "/alice/example/info/refs?service=git-receive-pack",
+    );
+    assert!(wrong_service.starts_with(b"HTTP/1.1 400"));
+
+    let wrong_content_type = raw_http_request(
+        server.address(),
+        "/alice/example/git-upload-pack",
+        "text/plain",
+        Some("version=2"),
+        b"0000",
+    );
+    assert!(wrong_content_type.starts_with(b"HTTP/1.1 415"));
+
+    let wrong_version = raw_http_request(
+        server.address(),
+        "/alice/example/git-upload-pack",
+        "application/x-git-upload-pack-request",
+        Some("version=2:extra"),
+        b"0000",
+    );
+    assert!(wrong_version.starts_with(b"HTTP/1.1 400"));
+
+    let oversized = raw_http_request(
+        server.address(),
+        "/alice/example/git-upload-pack",
+        "application/x-git-upload-pack-request",
+        Some("version=2"),
+        &vec![b'0'; packetline::MAX_REQUEST_BYTES + 1],
+    );
+    assert!(oversized.starts_with(b"HTTP/1.1 413"));
+    server.shutdown().await.expect("stop the Git HTTP server");
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn stock_git_clones_empty_repositories_for_both_hash_formats() {
+    for format in ["sha1", "sha256"] {
+        let directory = TempDir::new().expect("create an empty repository fixture directory");
+        let repositories_root = directory.path().join("repositories");
+        let bare = repositories_root.join("alice/empty.git");
+        fs::create_dir_all(bare.parent().expect("an empty repository parent"))
+            .expect("create an empty repository owner directory");
+        run(Command::new("git")
+            .args(["init", "-q", "--bare", "--object-format", format])
+            .arg(&bare));
+        let repositories =
+            GitRepositories::new(&repositories_root).expect("open the repository root");
+        let server =
+            RunningGitHttpServer::start(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)), repositories)
+                .await
+                .expect("start the Git HTTP server");
+        let clone = directory.path().join("clone");
+        run(Command::new("git")
+            .args(["-c", "protocol.version=2", "clone", "-q"])
+            .arg(format!("http://{}/alice/empty", server.address()))
+            .arg(&clone));
+        let head = Command::new("git")
+            .arg("-C")
+            .arg(&clone)
+            .args(["rev-parse", "HEAD"])
+            .output()
+            .expect("inspect the empty clone");
+        assert!(!head.status.success());
+        server.shutdown().await.expect("stop the Git HTTP server");
+    }
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn server_process_does_not_invoke_git() {
+    const CHILD_VARIABLE: &str = "TIT_M1C_INSTRUMENTED_CHILD";
+    const ROOT_VARIABLE: &str = "TIT_M1C_REPOSITORY_ROOT";
+    const GIT_VARIABLE: &str = "TIT_M1C_GIT_BINARY";
+    const PATH_VARIABLE: &str = "TIT_M1C_DRIVER_PATH";
+    const EXEC_PATH_VARIABLE: &str = "TIT_M1C_GIT_EXEC_PATH";
+
+    if std::env::var_os(CHILD_VARIABLE).is_some() {
+        let root = PathBuf::from(std::env::var_os(ROOT_VARIABLE).expect("a repository root"));
+        let git = PathBuf::from(std::env::var_os(GIT_VARIABLE).expect("a stock Git binary"));
+        let driver_path = std::env::var_os(PATH_VARIABLE).expect("a stock Git PATH");
+        let git_exec_path = std::env::var_os(EXEC_PATH_VARIABLE).expect("a stock Git exec path");
+        let repositories = GitRepositories::new(&root).expect("open the repository root");
+        let server =
+            RunningGitHttpServer::start(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)), repositories)
+                .await
+                .expect("start the instrumented Git HTTP server");
+        let clone = root
+            .parent()
+            .expect("a fixture directory")
+            .join("instrumented-clone");
+        let output = Command::new(git)
+            .args(["-c", "protocol.version=2", "clone", "-q"])
+            .arg(format!("http://{}/alice/example", server.address()))
+            .arg(clone)
+            .env("PATH", driver_path)
+            .env("GIT_EXEC_PATH", git_exec_path)
+            .output()
+            .expect("run the instrumented stock Git client");
+        assert!(
+            output.status.success(),
+            "instrumented stock Git failed: {}",
+            String::from_utf8_lossy(&output.stderr)
+        );
+        server.shutdown().await.expect("stop the Git HTTP server");
+        return;
+    }
+
+    let directory = TempDir::new().expect("create an instrumentation fixture directory");
+    let repositories_root = directory.path().join("repositories");
+    create_fixture(
+        &directory.path().join("worktree"),
+        &repositories_root.join("alice/example.git"),
+        "sha1",
+    );
+    let marker = directory.path().join("git-was-invoked");
+    let sentinel_directory = directory.path().join("sentinel-path");
+    fs::create_dir(&sentinel_directory).expect("create the sentinel PATH");
+    let sentinel = sentinel_directory.join("git");
+    fs::write(
+        &sentinel,
+        format!(
+            "#!/bin/sh\nprintf invoked > '{}'\nexit 97\n",
+            marker.display()
+        ),
+    )
+    .expect("write the Git sentinel");
+    fs::set_permissions(&sentinel, fs::Permissions::from_mode(0o700))
+        .expect("make the Git sentinel executable");
+
+    let git_binary = command_output(Command::new("which").arg("git"));
+    let git_exec_path = command_output(Command::new("git").arg("--exec-path"));
+    let driver_path = std::env::var_os("PATH").expect("the test driver PATH");
+    let output = Command::new(std::env::current_exe().expect("find the test executable"))
+        .args([
+            "--exact",
+            "server_process_does_not_invoke_git",
+            "--nocapture",
+        ])
+        .env(CHILD_VARIABLE, "1")
+        .env(ROOT_VARIABLE, &repositories_root)
+        .env(GIT_VARIABLE, git_binary.trim())
+        .env(PATH_VARIABLE, driver_path)
+        .env(EXEC_PATH_VARIABLE, git_exec_path.trim())
+        .env("PATH", &sentinel_directory)
+        .output()
+        .expect("run the instrumented server process");
+    assert!(
+        output.status.success(),
+        "instrumented server process failed: {}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+    assert!(!marker.exists(), "the server process invoked Git");
+}
+
+fn command_output(command: &mut Command) -> String {
+    let output = run(command);
+    String::from_utf8(output.stdout)
+        .expect("read command output")
+        .trim()
+        .to_owned()
+}
+
+fn raw_http_request(
+    address: SocketAddr,
+    path: &str,
+    content_type: &str,
+    git_protocol: Option<&str>,
+    body: &[u8],
+) -> Vec<u8> {
+    let mut stream = std::net::TcpStream::connect(address).expect("connect to the Git HTTP server");
+    let protocol_header = git_protocol
+        .map(|value| format!("Git-Protocol: {value}\r\n"))
+        .unwrap_or_default();
+    write!(
+        stream,
+        "POST {path} HTTP/1.1\r\nHost: {address}\r\nContent-Type: {content_type}\r\n{protocol_header}Content-Length: {}\r\nConnection: close\r\n\r\n",
+        body.len()
+    )
+    .expect("write HTTP request headers");
+    stream.write_all(body).expect("write HTTP request body");
+    let mut response = Vec::new();
+    stream
+        .read_to_end(&mut response)
+        .expect("read the Git HTTP response");
+    response
+}
+
+fn raw_http_get(address: SocketAddr, path: &str) -> Vec<u8> {
+    let mut stream = std::net::TcpStream::connect(address).expect("connect to the Git HTTP server");
+    write!(
+        stream,
+        "GET {path} HTTP/1.1\r\nHost: {address}\r\nConnection: close\r\n\r\n"
+    )
+    .expect("write HTTP request");
+    let mut response = Vec::new();
+    stream
+        .read_to_end(&mut response)
+        .expect("read the Git HTTP response");
+    response
+}
+
+fn create_fixture(worktree: &Path, bare: &Path, format: &str) {
+    fs::create_dir_all(bare.parent().expect("a bare repository parent"))
+        .expect("create the repository owner directory");
+    run(Command::new("git")
+        .args(["init", "-q", "--object-format", format, "-b", "main"])
+        .arg(worktree));
+    run(Command::new("git")
+        .arg("-C")
+        .arg(worktree)
+        .args(["config", "user.name", "Tit Test"]));
+    run(Command::new("git").arg("-C").arg(worktree).args([
+        "config",
+        "user.email",
+        "tit@example.invalid",
+    ]));
+    run(Command::new("git")
+        .arg("-C")
+        .arg(worktree)
+        .args(["config", "commit.gpgsign", "false"]));
+    run(Command::new("git")
+        .arg("-C")
+        .arg(worktree)
+        .args(["config", "tag.gpgsign", "false"]));
+    fs::write(worktree.join("non-ascii-\u{00e5}.txt"), b"first payload\n")
+        .expect("write the first fixture file");
+    run(Command::new("git")
+        .arg("-C")
+        .arg(worktree)
+        .args(["add", "."]));
+    run(Command::new("git")
+        .arg("-C")
+        .arg(worktree)
+        .args(["commit", "-q", "-m", "first commit"]));
+    run(Command::new("git")
+        .arg("-C")
+        .arg(worktree)
+        .args(["tag", "-a", "v1", "-m", "version one"]));
+    let large = vec![b'a'; 2 * 1024 * 1024];
+    let mut similar = large.clone();
+    similar[1024 * 1024..1024 * 1024 + 16].copy_from_slice(b"different bytes!");
+    fs::write(worktree.join("large-original.bin"), large).expect("write a large fixture blob");
+    fs::write(worktree.join("large-copy.bin"), similar).expect("write a similar fixture blob");
+    run(Command::new("git")
+        .arg("-C")
+        .arg(worktree)
+        .args(["add", "."]));
+    run(Command::new("git").arg("-C").arg(worktree).args([
+        "commit",
+        "-q",
+        "-m",
+        "add large similar blobs",
+    ]));
+    run(Command::new("git")
+        .arg("-C")
+        .arg(worktree)
+        .args(["branch", "feature", "HEAD~1"]));
+    run(Command::new("git")
+        .args(["clone", "-q", "--bare"])
+        .arg(worktree)
+        .arg(bare));
+    run(Command::new("git")
+        .arg("--git-dir")
+        .arg(bare)
+        .args(["gc", "--aggressive", "--prune=now"]));
+    assert_has_delta(bare);
+}
+
+fn assert_has_delta(repository: &Path) {
+    let index = fs::read_dir(repository.join("objects/pack"))
+        .expect("read the pack directory")
+        .filter_map(Result::ok)
+        .map(|entry| entry.path())
+        .find(|path| path.extension().is_some_and(|extension| extension == "idx"))
+        .expect("find a pack index");
+    let output = run(Command::new("git")
+        .arg("--git-dir")
+        .arg(repository)
+        .args(["verify-pack", "-v"])
+        .arg(index));
+    assert!(
+        String::from_utf8(output.stdout)
+            .expect("read verify-pack output")
+            .lines()
+            .any(|line| line.split_whitespace().count() >= 7),
+        "the fixture pack does not contain a delta"
+    );
+}
+
+fn append_commit(worktree: &Path, filename: String) {
+    fs::write(
+        worktree.join(&filename),
+        format!("payload for {filename}\n"),
+    )
+    .expect("write a fetch fixture file");
+    run(Command::new("git")
+        .arg("-C")
+        .arg(worktree)
+        .args(["add", &filename]));
+    run(Command::new("git").arg("-C").arg(worktree).args([
+        "commit",
+        "-q",
+        "-m",
+        &format!("add {filename}"),
+    ]));
+}
+
+fn head(path: &Path) -> String {
+    rev_parse(path, "HEAD")
+}
+
+fn rev_parse(path: &Path, name: &str) -> String {
+    let output = run(Command::new("git")
+        .arg("-C")
+        .arg(path)
+        .args(["rev-parse", name]));
+    String::from_utf8(output.stdout)
+        .expect("read an object ID")
+        .trim()
+        .to_owned()
+}
+
+fn run(command: &mut Command) -> std::process::Output {
+    let output = command.output().expect("run stock Git");
+    assert!(
+        output.status.success(),
+        "stock Git failed: {}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+    output
+}

tests/git_repository.rs

Mode 100644; object 995d9701cc38

@@ -1,0 +1,232 @@
+#[path = "../src/git/repository.rs"]
+mod repository;
+
+use std::fs;
+use std::io::Write;
+use std::os::unix::fs::PermissionsExt;
+use std::path::{Path, PathBuf};
+use std::process::{Command, Stdio};
+
+use gix::hash::ObjectId;
+use repository::{GitRepository, GitRepositoryError};
+use tempfile::TempDir;
+
+#[test]
+fn opens_empty_sha1_and_sha256_bare_repositories() {
+    let directory = TempDir::new().expect("create a repository directory");
+    for format in ["sha1", "sha256"] {
+        let path = directory.path().join(format);
+        init_bare(&path, format);
+        let repository = GitRepository::open(&path).expect("open the bare repository");
+        assert_eq!(repository.object_format().to_string(), format);
+        assert!(
+            repository
+                .references()
+                .expect("read empty references")
+                .is_empty()
+        );
+    }
+
+    let non_bare = directory.path().join("worktree");
+    run(Command::new("git").args(["init", "-q"]).arg(&non_bare));
+    assert!(matches!(
+        GitRepository::open(&non_bare),
+        Err(GitRepositoryError::NotBare(_))
+    ));
+}
+
+#[test]
+fn reads_sorted_refs_and_generates_complete_packs_for_both_hashes() {
+    let directory = TempDir::new().expect("create a repository directory");
+    for format in ["sha1", "sha256"] {
+        let source_path = directory.path().join(format!("source-{format}"));
+        let first = make_fixture(&source_path, format);
+        let source = GitRepository::open(&source_path).expect("open the source repository");
+        let references = source.references().expect("read references");
+        assert_eq!(references[0].name, b"HEAD");
+        assert_eq!(
+            references[0].symbolic_target.as_deref(),
+            Some(b"refs/heads/main".as_slice())
+        );
+        assert!(
+            references
+                .windows(2)
+                .skip(1)
+                .all(|pair| pair[0].name < pair[1].name)
+        );
+        assert!(references.iter().any(|reference| {
+            reference.name == b"refs/tags/v1" && reference.peeled == Some(first)
+        }));
+
+        let pack = source
+            .make_pack(&[first], &[])
+            .expect("generate a complete pack");
+        assert!(pack.starts_with(b"PACK"));
+        let destination = directory.path().join(format!("destination-{format}"));
+        init_bare(&destination, format);
+        index_pack(&destination, &pack);
+        assert_object(&destination, first, "commit");
+
+        let second = append_commit(&source_path, first, b"second payload", "second.txt");
+        let source = GitRepository::open(&source_path).expect("open the updated repository");
+        let incremental = source
+            .make_pack(&[second], &[first])
+            .expect("generate an incremental pack");
+        index_pack(&destination, &incremental);
+        assert_object(&destination, second, "commit");
+    }
+}
+
+#[test]
+fn rejects_unadvertised_wants_and_damaged_reachable_objects() {
+    let directory = TempDir::new().expect("create a repository directory");
+    let source_path = directory.path().join("source");
+    let commit = make_fixture(&source_path, "sha1");
+    let source = GitRepository::open(&source_path).expect("open the source repository");
+    let unadvertised = ObjectId::from_hex(b"1111111111111111111111111111111111111111")
+        .expect("parse an unadvertised ID");
+    assert!(matches!(
+        source.make_pack(&[unadvertised], &[]),
+        Err(GitRepositoryError::UnadvertisedWant)
+    ));
+    let wrong_format =
+        ObjectId::from_hex(b"1111111111111111111111111111111111111111111111111111111111111111")
+            .expect("parse a SHA-256 ID");
+    assert!(matches!(
+        source.make_pack(&[commit], &[wrong_format]),
+        Err(GitRepositoryError::WrongObjectFormat)
+    ));
+    drop(source);
+
+    let object_path = loose_object_path(&source_path, commit);
+    fs::set_permissions(&object_path, fs::Permissions::from_mode(0o600))
+        .expect("make the commit object writable");
+    fs::write(&object_path, b"damaged object").expect("damage the commit object");
+    let damaged = GitRepository::open(&source_path).expect("open the damaged repository");
+    assert!(matches!(
+        damaged.make_pack(&[commit], &[]),
+        Err(GitRepositoryError::References(_)
+            | GitRepositoryError::Object { .. }
+            | GitRepositoryError::DamagedObject { .. })
+    ));
+}
+
+fn make_fixture(path: &Path, format: &str) -> ObjectId {
+    init_bare(path, format);
+    let blob = write_blob(path, b"hello from tit\n");
+    let tree = write_tree(path, blob, "non-ascii-\u{00e5}.txt");
+    let commit = write_commit(path, tree, None, "first commit");
+    update_ref(path, "refs/heads/main", commit);
+    run(git(path).args(["symbolic-ref", "HEAD", "refs/heads/main"]));
+    run(git(path).args(["tag", "-a", "v1", "-m", "version one", &commit.to_string()]));
+    commit
+}
+
+fn append_commit(path: &Path, parent: ObjectId, contents: &[u8], filename: &str) -> ObjectId {
+    let blob = write_blob(path, contents);
+    let tree = write_tree(path, blob, filename);
+    let commit = write_commit(path, tree, Some(parent), "second commit");
+    update_ref(path, "refs/heads/main", commit);
+    commit
+}
+
+fn init_bare(path: &Path, format: &str) {
+    run(Command::new("git")
+        .args(["init", "-q", "--bare", "--object-format", format])
+        .arg(path));
+}
+
+fn write_blob(path: &Path, contents: &[u8]) -> ObjectId {
+    let output = run_with_input(git(path).args(["hash-object", "-w", "--stdin"]), contents);
+    parse_id(&output)
+}
+
+fn write_tree(path: &Path, blob: ObjectId, filename: &str) -> ObjectId {
+    let record = format!("100644 blob {blob}\t{filename}\n");
+    let output = run_with_input(git(path).arg("mktree"), record.as_bytes());
+    parse_id(&output)
+}
+
+fn write_commit(path: &Path, tree: ObjectId, parent: Option<ObjectId>, message: &str) -> ObjectId {
+    let mut command = git(path);
+    command.arg("commit-tree").arg(tree.to_string());
+    if let Some(parent) = parent {
+        command.args(["-p", &parent.to_string()]);
+    }
+    command.args(["-m", message]);
+    command.envs([
+        ("GIT_AUTHOR_NAME", "Tit Test"),
+        ("GIT_AUTHOR_EMAIL", "tit@example.invalid"),
+        ("GIT_AUTHOR_DATE", "1700000000 +0000"),
+        ("GIT_COMMITTER_NAME", "Tit Test"),
+        ("GIT_COMMITTER_EMAIL", "tit@example.invalid"),
+        ("GIT_COMMITTER_DATE", "1700000000 +0000"),
+    ]);
+    parse_id(&run(&mut command).stdout)
+}
+
+fn update_ref(path: &Path, name: &str, id: ObjectId) {
+    run(git(path).args(["update-ref", name, &id.to_string()]));
+}
+
+fn index_pack(path: &Path, pack: &[u8]) {
+    let output = run_with_input(git(path).args(["index-pack", "--stdin"]), pack);
+    assert!(!output.is_empty());
+}
+
+fn assert_object(path: &Path, id: ObjectId, kind: &str) {
+    let output = run(git(path).args(["cat-file", "-t", &id.to_string()]));
+    assert_eq!(
+        String::from_utf8(output.stdout)
+            .expect("read object type")
+            .trim(),
+        kind
+    );
+}
+
+fn loose_object_path(repository: &Path, id: ObjectId) -> PathBuf {
+    let id = id.to_string();
+    repository.join("objects").join(&id[..2]).join(&id[2..])
+}
+
+fn git(path: &Path) -> Command {
+    let mut command = Command::new("git");
+    command.arg("--git-dir").arg(path);
+    command
+}
+
+fn run(command: &mut Command) -> std::process::Output {
+    let output = command.output().expect("run stock Git");
+    assert!(
+        output.status.success(),
+        "stock Git failed: {}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+    output
+}
+
+fn run_with_input(command: &mut Command, input: &[u8]) -> Vec<u8> {
+    let mut child = command
+        .stdin(Stdio::piped())
+        .stdout(Stdio::piped())
+        .stderr(Stdio::piped())
+        .spawn()
+        .expect("start stock Git");
+    child
+        .stdin
+        .take()
+        .expect("open stock Git input")
+        .write_all(input)
+        .expect("write stock Git input");
+    let output = child.wait_with_output().expect("wait for stock Git");
+    assert!(
+        output.status.success(),
+        "stock Git failed: {}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+    output.stdout
+}
+
+fn parse_id(input: &[u8]) -> ObjectId {
+    ObjectId::from_hex(String::from_utf8_lossy(input).trim().as_bytes()).expect("parse object ID")
+}

tests/git_ssh.rs

Mode 100644; object e5a4ca32938e

@@ -1,0 +1,343 @@
+#[allow(
+    dead_code,
+    reason = "the SSH Git test does not use each authentication API"
+)]
+#[path = "../src/auth.rs"]
+mod auth;
+#[allow(
+    dead_code,
+    reason = "the SSH Git test does not use each Git service API"
+)]
+#[path = "../src/git/mod.rs"]
+mod git;
+#[allow(
+    dead_code,
+    reason = "the SSH Git test does not inspect the request audit"
+)]
+#[path = "../src/ssh.rs"]
+mod ssh;
+
+use std::fs;
+use std::net::{Ipv4Addr, SocketAddr};
+use std::path::{Path, PathBuf};
+use std::process::Command;
+
+use auth::SshPublicKey;
+use git::transport::GitRepositories;
+use ssh::RunningSshServer;
+use tempfile::TempDir;
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn stock_git_clones_and_fetches_both_hash_formats_over_ssh() {
+    for format in ["sha1", "sha256"] {
+        let directory = TempDir::new().expect("create an SSH Git fixture directory");
+        let repositories_root = directory.path().join("repositories");
+        let bare = repositories_root.join("alice/example.git");
+        let worktree = directory.path().join("worktree");
+        create_fixture(&worktree, &bare, format);
+
+        let private_key = directory.path().join("id_ed25519");
+        create_key(&private_key);
+        let key = parse_key(&private_key);
+        let repositories =
+            GitRepositories::new(&repositories_root).expect("open the repository root");
+        let server = RunningSshServer::start_with_git(
+            SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
+            &[key],
+            repositories,
+        )
+        .await
+        .expect("start the SSH Git server");
+        let ssh_command = ssh_command(&private_key);
+
+        let versions: &[&str] = if format == "sha1" {
+            &["0", "1", "2"]
+        } else {
+            &["1", "2"]
+        };
+        for version in versions {
+            let clone = directory.path().join(format!("clone-v{version}"));
+            let url = format!(
+                "ssh://ignored@{}:{}/alice/example",
+                server.address().ip(),
+                server.address().port()
+            );
+            let output = Command::new("git")
+                .args(["-c", &format!("protocol.version={version}"), "clone", "-q"])
+                .arg(&url)
+                .arg(&clone)
+                .env("GIT_SSH_COMMAND", &ssh_command)
+                .env("GIT_SSH_VARIANT", "ssh")
+                .env("GIT_TRACE_PACKET", "1")
+                .output()
+                .expect("run stock Git clone");
+            assert!(
+                output.status.success(),
+                "stock Git v{version} clone failed: {}; audit: {:?}",
+                String::from_utf8_lossy(&output.stderr),
+                server.audit()
+            );
+            let cloned_head = head(&clone);
+            let source_head = head(&worktree);
+            assert_eq!(cloned_head, source_head);
+            assert_eq!(
+                rev_parse(&clone, "refs/remotes/origin/feature"),
+                rev_parse(&worktree, "refs/heads/feature")
+            );
+            assert_eq!(
+                rev_parse(&clone, "refs/tags/v1^{}"),
+                rev_parse(&worktree, "refs/tags/v1^{}")
+            );
+            assert_eq!(
+                fs::read(clone.join("non-ascii-\u{00e5}.txt")).expect("read a cloned file"),
+                b"SSH Git fixture\n"
+            );
+            assert_eq!(
+                fs::metadata(clone.join("large-copy.bin"))
+                    .expect("inspect a cloned large blob")
+                    .len(),
+                2 * 1024 * 1024
+            );
+
+            let filename = format!("ssh-fetch-v{version}.txt");
+            fs::write(
+                worktree.join(&filename),
+                format!("payload for {filename}\n"),
+            )
+            .expect("write an SSH fetch fixture file");
+            run(Command::new("git")
+                .arg("-C")
+                .arg(&worktree)
+                .args(["add", &filename]));
+            run(Command::new("git").arg("-C").arg(&worktree).args([
+                "commit",
+                "-q",
+                "-m",
+                &format!("add {filename}"),
+            ]));
+            run(Command::new("git").arg("-C").arg(&worktree).args([
+                "push",
+                "-q",
+                &bare.to_string_lossy(),
+                "main",
+            ]));
+            let output = Command::new("git")
+                .arg("-C")
+                .arg(&clone)
+                .args(["-c", &format!("protocol.version={version}"), "fetch", "-q"])
+                .env("GIT_SSH_COMMAND", &ssh_command)
+                .env("GIT_SSH_VARIANT", "ssh")
+                .env("GIT_TRACE_PACKET", "1")
+                .output()
+                .expect("run stock Git fetch");
+            assert!(
+                output.status.success(),
+                "stock Git v{version} fetch failed: {}; audit: {:?}",
+                String::from_utf8_lossy(&output.stderr),
+                server.audit()
+            );
+            assert_eq!(
+                rev_parse(&clone, "refs/remotes/origin/main"),
+                head(&worktree)
+            );
+        }
+
+        server.shutdown().await.expect("stop the SSH Git server");
+    }
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn stock_git_clones_empty_repositories_for_both_hash_formats_over_ssh() {
+    for format in ["sha1", "sha256"] {
+        let directory = TempDir::new().expect("create an empty SSH Git fixture directory");
+        let repositories_root = directory.path().join("repositories");
+        let bare = repositories_root.join("alice/empty.git");
+        fs::create_dir_all(bare.parent().expect("an empty repository parent"))
+            .expect("create an empty repository owner directory");
+        run(Command::new("git")
+            .args(["init", "-q", "--bare", "--object-format", format])
+            .arg(&bare));
+
+        let private_key = directory.path().join("id_ed25519");
+        create_key(&private_key);
+        let repositories =
+            GitRepositories::new(&repositories_root).expect("open the repository root");
+        let server = RunningSshServer::start_with_git(
+            SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
+            &[parse_key(&private_key)],
+            repositories,
+        )
+        .await
+        .expect("start the SSH Git server");
+        let clone = directory.path().join("clone");
+        let output = Command::new("git")
+            .args(["-c", "protocol.version=2", "clone", "-q"])
+            .arg(format!(
+                "ssh://ignored@{}:{}/alice/empty",
+                server.address().ip(),
+                server.address().port()
+            ))
+            .arg(&clone)
+            .env("GIT_SSH_COMMAND", ssh_command(&private_key))
+            .env("GIT_SSH_VARIANT", "ssh")
+            .output()
+            .expect("clone an empty repository with stock Git");
+        assert!(
+            output.status.success(),
+            "empty SHA-{format} clone failed: {}",
+            String::from_utf8_lossy(&output.stderr)
+        );
+        let head = Command::new("git")
+            .arg("-C")
+            .arg(&clone)
+            .args(["rev-parse", "HEAD"])
+            .output()
+            .expect("inspect the empty clone");
+        assert!(!head.status.success());
+        server.shutdown().await.expect("stop the SSH Git server");
+    }
+}
+
+fn create_fixture(worktree: &Path, bare: &Path, format: &str) {
+    fs::create_dir_all(bare.parent().expect("a bare repository parent"))
+        .expect("create the repository owner directory");
+    run(Command::new("git")
+        .args(["init", "-q", "--object-format", format, "-b", "main"])
+        .arg(worktree));
+    run(Command::new("git")
+        .arg("-C")
+        .arg(worktree)
+        .args(["config", "user.name", "Tit Test"]));
+    run(Command::new("git").arg("-C").arg(worktree).args([
+        "config",
+        "user.email",
+        "tit@example.invalid",
+    ]));
+    run(Command::new("git")
+        .arg("-C")
+        .arg(worktree)
+        .args(["config", "commit.gpgsign", "false"]));
+    run(Command::new("git")
+        .arg("-C")
+        .arg(worktree)
+        .args(["config", "tag.gpgsign", "false"]));
+    fs::write(
+        worktree.join("non-ascii-\u{00e5}.txt"),
+        b"SSH Git fixture\n",
+    )
+    .expect("write the SSH fixture file");
+    run(Command::new("git")
+        .arg("-C")
+        .arg(worktree)
+        .args(["add", "."]));
+    run(Command::new("git")
+        .arg("-C")
+        .arg(worktree)
+        .args(["commit", "-q", "-m", "first commit"]));
+    run(Command::new("git")
+        .arg("-C")
+        .arg(worktree)
+        .args(["tag", "-a", "v1", "-m", "version one"]));
+    let large = vec![b'a'; 2 * 1024 * 1024];
+    let mut similar = large.clone();
+    similar[1024 * 1024..1024 * 1024 + 16].copy_from_slice(b"different bytes!");
+    fs::write(worktree.join("large-original.bin"), large).expect("write a large fixture blob");
+    fs::write(worktree.join("large-copy.bin"), similar).expect("write a similar fixture blob");
+    run(Command::new("git")
+        .arg("-C")
+        .arg(worktree)
+        .args(["add", "."]));
+    run(Command::new("git").arg("-C").arg(worktree).args([
+        "commit",
+        "-q",
+        "-m",
+        "add large similar blobs",
+    ]));
+    run(Command::new("git")
+        .arg("-C")
+        .arg(worktree)
+        .args(["branch", "feature", "HEAD~1"]));
+    run(Command::new("git")
+        .args(["clone", "-q", "--bare"])
+        .arg(worktree)
+        .arg(bare));
+    run(Command::new("git")
+        .arg("--git-dir")
+        .arg(bare)
+        .args(["gc", "--aggressive", "--prune=now"]));
+    assert_has_delta(bare);
+}
+
+fn assert_has_delta(repository: &Path) {
+    let index = fs::read_dir(repository.join("objects/pack"))
+        .expect("read the pack directory")
+        .filter_map(Result::ok)
+        .map(|entry| entry.path())
+        .find(|path| path.extension().is_some_and(|extension| extension == "idx"))
+        .expect("find a pack index");
+    let output = run(Command::new("git")
+        .arg("--git-dir")
+        .arg(repository)
+        .args(["verify-pack", "-v"])
+        .arg(index));
+    assert!(
+        String::from_utf8(output.stdout)
+            .expect("read verify-pack output")
+            .lines()
+            .any(|line| line.split_whitespace().count() >= 7),
+        "the fixture pack does not contain a delta"
+    );
+}
+
+fn create_key(path: &Path) {
+    run(Command::new("ssh-keygen")
+        .args(["-q", "-t", "ed25519", "-N", "", "-f"])
+        .arg(path));
+}
+
+fn parse_key(private_key: &Path) -> SshPublicKey {
+    let mut public_key = private_key.as_os_str().to_owned();
+    public_key.push(".pub");
+    let encoded = fs::read_to_string(PathBuf::from(public_key)).expect("read the public key");
+    SshPublicKey::parse(&encoded).expect("parse the public key")
+}
+
+fn ssh_command(private_key: &Path) -> String {
+    format!(
+        "ssh -F /dev/null -i {} -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o PasswordAuthentication=no -o KbdInteractiveAuthentication=no",
+        private_key.display()
+    )
+}
+
+fn head(path: &Path) -> String {
+    rev_parse(path, "HEAD")
+}
+
+fn rev_parse(path: &Path, name: &str) -> String {
+    let output = Command::new("git")
+        .arg("-C")
+        .arg(path)
+        .args(["rev-parse", name])
+        .output()
+        .expect("run stock Git rev-parse");
+    assert!(
+        output.status.success(),
+        "rev-parse {name} in {} failed: {}",
+        path.display(),
+        String::from_utf8_lossy(&output.stderr)
+    );
+    String::from_utf8(output.stdout)
+        .expect("read an object ID")
+        .trim()
+        .to_owned()
+}
+
+fn run(command: &mut Command) -> std::process::Output {
+    let output = command.output().expect("run stock command");
+    assert!(
+        output.status.success(),
+        "stock command failed: {}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+    output
+}

tests/ssh.rs

Mode 100644100644; object 92555510b96dd4dff5ee4fd6

@@ -1,6 +1,16 @@
 #[allow(dead_code, reason = "the SSH test uses only the shared key boundary")]
 #[path = "../src/auth.rs"]
 mod auth;
+#[allow(
+    dead_code,
+    reason = "the SSH identity test does not use each Git service API"
+)]
+#[path = "../src/git/mod.rs"]
+mod git;
+#[allow(
+    dead_code,
+    reason = "the SSH identity test does not start a Git service"
+)]
 #[path = "../src/ssh.rs"]
 mod ssh;