michal/tit
Browse tree · Show commit · Download archive
Diff
22bffa4062f3 → f53dd2bd7646
.github/workflows/ci.yml
Mode 100644 → 100644; object 567e41ba194b → 45c626658dcd
@@ -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 100644 → 100644; object 4dd0b064da83 → 7aa3be339eb1
@@ -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 100644 → 100644; object f954d7e44109 → 5f6be756cbd1
@@ -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 100644 → 100644; object 39a1f0c8e2b5 → 1bdc87f8e963
@@ -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 100644 → 100644; object 7b80f0d03a76 → 49e475827416
@@ -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 100644 → 100644; object 4ae77ffedf38 → 48c736a69cce
@@ -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 100644 → 100644; object f3c93da72da3 → 15a2e93c109a
@@ -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 100644 → 100644; object 92555510b96d → d4dff5ee4fd6
@@ -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;