6d9aea18
Add git-collab-server: read-only web UI and SSH remote
a73x 2026-03-31 17:50
Commit message
.dockerignore
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,7 @@ | |||
| 1 | target/ | ||
| 2 | .git/ | ||
| 3 | tests/ | ||
| 4 | fuzz/ | ||
| 5 | docs/ | ||
| 6 | man/ | ||
| 7 | specs/ | ||
Cargo.lock
| Old | New | ||
|---|---|---|---|
| @@ -3,6 +3,60 @@ | |||
| 3 | version = 4 | 3 | version = 4 |
| 4 | 4 | ||
| 5 | [[package]] | 5 | [[package]] |
| 6 | name = "adler2" | ||
| 7 | version = "2.0.1" | ||
| 8 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 9 | checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" | ||
| 10 | |||
| 11 | [[package]] | ||
| 12 | name = "aead" | ||
| 13 | version = "0.5.2" | ||
| 14 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 15 | checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" | ||
| 16 | dependencies = [ | ||
| 17 | "crypto-common", | ||
| 18 | "generic-array", | ||
| 19 | ] | ||
| 20 | |||
| 21 | [[package]] | ||
| 22 | name = "aes" | ||
| 23 | version = "0.8.4" | ||
| 24 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 25 | checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" | ||
| 26 | dependencies = [ | ||
| 27 | "cfg-if", | ||
| 28 | "cipher", | ||
| 29 | "cpufeatures", | ||
| 30 | ] | ||
| 31 | |||
| 32 | [[package]] | ||
| 33 | name = "aes-gcm" | ||
| 34 | version = "0.10.3" | ||
| 35 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 36 | checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" | ||
| 37 | dependencies = [ | ||
| 38 | "aead", | ||
| 39 | "aes", | ||
| 40 | "cipher", | ||
| 41 | "ctr", | ||
| 42 | "ghash", | ||
| 43 | "subtle", | ||
| 44 | ] | ||
| 45 | |||
| 46 | [[package]] | ||
| 47 | name = "ahash" | ||
| 48 | version = "0.8.12" | ||
| 49 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 50 | checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" | ||
| 51 | dependencies = [ | ||
| 52 | "cfg-if", | ||
| 53 | "const-random", | ||
| 54 | "once_cell", | ||
| 55 | "version_check", | ||
| 56 | "zerocopy", | ||
| 57 | ] | ||
| 58 | |||
| 59 | [[package]] | ||
| 6 | name = "aho-corasick" | 60 | name = "aho-corasick" |
| 7 | version = "1.1.4" | 61 | version = "1.1.4" |
| 8 | source = "registry+https://github.com/rust-lang/crates.io-index" | 62 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -89,6 +143,93 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 89 | checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" | 143 | checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" |
| 90 | 144 | ||
| 91 | [[package]] | 145 | [[package]] |
| 146 | name = "askama" | ||
| 147 | version = "0.15.6" | ||
| 148 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 149 | checksum = "9b8246bcbf8eb97abef10c2d92166449680d41d55c0fc6978a91dec2e3619608" | ||
| 150 | dependencies = [ | ||
| 151 | "askama_macros", | ||
| 152 | "itoa", | ||
| 153 | "percent-encoding", | ||
| 154 | "serde", | ||
| 155 | "serde_json", | ||
| 156 | ] | ||
| 157 | |||
| 158 | [[package]] | ||
| 159 | name = "askama_derive" | ||
| 160 | version = "0.15.6" | ||
| 161 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 162 | checksum = "2f9670bc84a28bb3da91821ef74226949ab63f1265aff7c751634f1dd0e6f97c" | ||
| 163 | dependencies = [ | ||
| 164 | "askama_parser", | ||
| 165 | "basic-toml", | ||
| 166 | "memchr", | ||
| 167 | "proc-macro2", | ||
| 168 | "quote", | ||
| 169 | "rustc-hash", | ||
| 170 | "serde", | ||
| 171 | "serde_derive", | ||
| 172 | "syn 2.0.117", | ||
| 173 | ] | ||
| 174 | |||
| 175 | [[package]] | ||
| 176 | name = "askama_macros" | ||
| 177 | version = "0.15.6" | ||
| 178 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 179 | checksum = "f0756b45480437dded0565dfc568af62ccce146fb6cfe902e808ba86e445f44f" | ||
| 180 | dependencies = [ | ||
| 181 | "askama_derive", | ||
| 182 | ] | ||
| 183 | |||
| 184 | [[package]] | ||
| 185 | name = "askama_parser" | ||
| 186 | version = "0.15.6" | ||
| 187 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 188 | checksum = "5d0af3691ba3af77949c0b5a3925444b85cb58a0184cc7fec16c68ba2e7be868" | ||
| 189 | dependencies = [ | ||
| 190 | "rustc-hash", | ||
| 191 | "serde", | ||
| 192 | "serde_derive", | ||
| 193 | "unicode-ident", | ||
| 194 | "winnow 1.0.1", | ||
| 195 | ] | ||
| 196 | |||
| 197 | [[package]] | ||
| 198 | name = "askama_web" | ||
| 199 | version = "0.15.2" | ||
| 200 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 201 | checksum = "7eb6d818ce4fb74822f2676eb0047daf25a8b2cb88f0c9fe8ca690170a6cb6cd" | ||
| 202 | dependencies = [ | ||
| 203 | "askama", | ||
| 204 | "askama_web_derive", | ||
| 205 | "axum-core", | ||
| 206 | "bytes", | ||
| 207 | "http", | ||
| 208 | ] | ||
| 209 | |||
| 210 | [[package]] | ||
| 211 | name = "askama_web_derive" | ||
| 212 | version = "0.2.0" | ||
| 213 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 214 | checksum = "9767c17d33a63daf6da5872ffaf2ab0c289cd73ce7ed4f41d5ddf9149c004873" | ||
| 215 | dependencies = [ | ||
| 216 | "proc-macro2", | ||
| 217 | "quote", | ||
| 218 | "syn 2.0.117", | ||
| 219 | ] | ||
| 220 | |||
| 221 | [[package]] | ||
| 222 | name = "async-trait" | ||
| 223 | version = "0.1.89" | ||
| 224 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 225 | checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" | ||
| 226 | dependencies = [ | ||
| 227 | "proc-macro2", | ||
| 228 | "quote", | ||
| 229 | "syn 2.0.117", | ||
| 230 | ] | ||
| 231 | |||
| 232 | [[package]] | ||
| 92 | name = "atomic" | 233 | name = "atomic" |
| 93 | version = "0.6.1" | 234 | version = "0.6.1" |
| 94 | source = "registry+https://github.com/rust-lang/crates.io-index" | 235 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -98,12 +239,76 @@ dependencies = [ | |||
| 98 | ] | 239 | ] |
| 99 | 240 | ||
| 100 | [[package]] | 241 | [[package]] |
| 242 | name = "atomic-waker" | ||
| 243 | version = "1.1.2" | ||
| 244 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 245 | checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" | ||
| 246 | |||
| 247 | [[package]] | ||
| 101 | name = "autocfg" | 248 | name = "autocfg" |
| 102 | version = "1.5.0" | 249 | version = "1.5.0" |
| 103 | source = "registry+https://github.com/rust-lang/crates.io-index" | 250 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 104 | checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" | 251 | checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" |
| 105 | 252 | ||
| 106 | [[package]] | 253 | [[package]] |
| 254 | name = "axum" | ||
| 255 | version = "0.8.8" | ||
| 256 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 257 | checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" | ||
| 258 | dependencies = [ | ||
| 259 | "axum-core", | ||
| 260 | "bytes", | ||
| 261 | "form_urlencoded", | ||
| 262 | "futures-util", | ||
| 263 | "http", | ||
| 264 | "http-body", | ||
| 265 | "http-body-util", | ||
| 266 | "hyper", | ||
| 267 | "hyper-util", | ||
| 268 | "itoa", | ||
| 269 | "matchit", | ||
| 270 | "memchr", | ||
| 271 | "mime", | ||
| 272 | "percent-encoding", | ||
| 273 | "pin-project-lite", | ||
| 274 | "serde_core", | ||
| 275 | "serde_json", | ||
| 276 | "serde_path_to_error", | ||
| 277 | "serde_urlencoded", | ||
| 278 | "sync_wrapper", | ||
| 279 | "tokio", | ||
| 280 | "tower", | ||
| 281 | "tower-layer", | ||
| 282 | "tower-service", | ||
| 283 | "tracing", | ||
| 284 | ] | ||
| 285 | |||
| 286 | [[package]] | ||
| 287 | name = "axum-core" | ||
| 288 | version = "0.5.6" | ||
| 289 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 290 | checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" | ||
| 291 | dependencies = [ | ||
| 292 | "bytes", | ||
| 293 | "futures-core", | ||
| 294 | "http", | ||
| 295 | "http-body", | ||
| 296 | "http-body-util", | ||
| 297 | "mime", | ||
| 298 | "pin-project-lite", | ||
| 299 | "sync_wrapper", | ||
| 300 | "tower-layer", | ||
| 301 | "tower-service", | ||
| 302 | "tracing", | ||
| 303 | ] | ||
| 304 | |||
| 305 | [[package]] | ||
| 306 | name = "base16ct" | ||
| 307 | version = "0.2.0" | ||
| 308 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 309 | checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" | ||
| 310 | |||
| 311 | [[package]] | ||
| 107 | name = "base64" | 312 | name = "base64" |
| 108 | version = "0.22.1" | 313 | version = "0.22.1" |
| 109 | source = "registry+https://github.com/rust-lang/crates.io-index" | 314 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -116,6 +321,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 116 | checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" | 321 | checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" |
| 117 | 322 | ||
| 118 | [[package]] | 323 | [[package]] |
| 324 | name = "basic-toml" | ||
| 325 | version = "0.1.10" | ||
| 326 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 327 | checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" | ||
| 328 | dependencies = [ | ||
| 329 | "serde", | ||
| 330 | ] | ||
| 331 | |||
| 332 | [[package]] | ||
| 333 | name = "bcrypt-pbkdf" | ||
| 334 | version = "0.10.0" | ||
| 335 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 336 | checksum = "6aeac2e1fe888769f34f05ac343bbef98b14d1ffb292ab69d4608b3abc86f2a2" | ||
| 337 | dependencies = [ | ||
| 338 | "blowfish", | ||
| 339 | "pbkdf2", | ||
| 340 | "sha2", | ||
| 341 | ] | ||
| 342 | |||
| 343 | [[package]] | ||
| 119 | name = "bit-set" | 344 | name = "bit-set" |
| 120 | version = "0.5.3" | 345 | version = "0.5.3" |
| 121 | source = "registry+https://github.com/rust-lang/crates.io-index" | 346 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -156,6 +381,9 @@ name = "bitflags" | |||
| 156 | version = "2.11.0" | 381 | version = "2.11.0" |
| 157 | source = "registry+https://github.com/rust-lang/crates.io-index" | 382 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 158 | checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" | 383 | checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" |
| 384 | dependencies = [ | ||
| 385 | "serde_core", | ||
| 386 | ] | ||
| 159 | 387 | ||
| 160 | [[package]] | 388 | [[package]] |
| 161 | name = "block-buffer" | 389 | name = "block-buffer" |
| @@ -167,6 +395,25 @@ dependencies = [ | |||
| 167 | ] | 395 | ] |
| 168 | 396 | ||
| 169 | [[package]] | 397 | [[package]] |
| 398 | name = "block-padding" | ||
| 399 | version = "0.3.3" | ||
| 400 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 401 | checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" | ||
| 402 | dependencies = [ | ||
| 403 | "generic-array", | ||
| 404 | ] | ||
| 405 | |||
| 406 | [[package]] | ||
| 407 | name = "blowfish" | ||
| 408 | version = "0.9.1" | ||
| 409 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 410 | checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" | ||
| 411 | dependencies = [ | ||
| 412 | "byteorder", | ||
| 413 | "cipher", | ||
| 414 | ] | ||
| 415 | |||
| 416 | [[package]] | ||
| 170 | name = "bumpalo" | 417 | name = "bumpalo" |
| 171 | version = "3.20.2" | 418 | version = "3.20.2" |
| 172 | source = "registry+https://github.com/rust-lang/crates.io-index" | 419 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -179,6 +426,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 179 | checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" | 426 | checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" |
| 180 | 427 | ||
| 181 | [[package]] | 428 | [[package]] |
| 429 | name = "byteorder" | ||
| 430 | version = "1.5.0" | ||
| 431 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 432 | checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" | ||
| 433 | |||
| 434 | [[package]] | ||
| 435 | name = "bytes" | ||
| 436 | version = "1.11.1" | ||
| 437 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 438 | checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" | ||
| 439 | |||
| 440 | [[package]] | ||
| 182 | name = "cast" | 441 | name = "cast" |
| 183 | version = "0.3.0" | 442 | version = "0.3.0" |
| 184 | source = "registry+https://github.com/rust-lang/crates.io-index" | 443 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -194,6 +453,15 @@ dependencies = [ | |||
| 194 | ] | 453 | ] |
| 195 | 454 | ||
| 196 | [[package]] | 455 | [[package]] |
| 456 | name = "cbc" | ||
| 457 | version = "0.1.2" | ||
| 458 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 459 | checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" | ||
| 460 | dependencies = [ | ||
| 461 | "cipher", | ||
| 462 | ] | ||
| 463 | |||
| 464 | [[package]] | ||
| 197 | name = "cc" | 465 | name = "cc" |
| 198 | version = "1.2.57" | 466 | version = "1.2.57" |
| 199 | source = "registry+https://github.com/rust-lang/crates.io-index" | 467 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -218,6 +486,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 218 | checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" | 486 | checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" |
| 219 | 487 | ||
| 220 | [[package]] | 488 | [[package]] |
| 489 | name = "chacha20" | ||
| 490 | version = "0.9.1" | ||
| 491 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 492 | checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" | ||
| 493 | dependencies = [ | ||
| 494 | "cfg-if", | ||
| 495 | "cipher", | ||
| 496 | "cpufeatures", | ||
| 497 | ] | ||
| 498 | |||
| 499 | [[package]] | ||
| 221 | name = "chrono" | 500 | name = "chrono" |
| 222 | version = "0.4.44" | 501 | version = "0.4.44" |
| 223 | source = "registry+https://github.com/rust-lang/crates.io-index" | 502 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -259,6 +538,16 @@ dependencies = [ | |||
| 259 | ] | 538 | ] |
| 260 | 539 | ||
| 261 | [[package]] | 540 | [[package]] |
| 541 | name = "cipher" | ||
| 542 | version = "0.4.4" | ||
| 543 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 544 | checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" | ||
| 545 | dependencies = [ | ||
| 546 | "crypto-common", | ||
| 547 | "inout", | ||
| 548 | ] | ||
| 549 | |||
| 550 | [[package]] | ||
| 262 | name = "clap" | 551 | name = "clap" |
| 263 | version = "4.6.0" | 552 | version = "4.6.0" |
| 264 | source = "registry+https://github.com/rust-lang/crates.io-index" | 553 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -344,6 +633,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 344 | checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" | 633 | checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" |
| 345 | 634 | ||
| 346 | [[package]] | 635 | [[package]] |
| 636 | name = "const-random" | ||
| 637 | version = "0.1.18" | ||
| 638 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 639 | checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" | ||
| 640 | dependencies = [ | ||
| 641 | "const-random-macro", | ||
| 642 | ] | ||
| 643 | |||
| 644 | [[package]] | ||
| 645 | name = "const-random-macro" | ||
| 646 | version = "0.1.16" | ||
| 647 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 648 | checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" | ||
| 649 | dependencies = [ | ||
| 650 | "getrandom 0.2.17", | ||
| 651 | "once_cell", | ||
| 652 | "tiny-keccak", | ||
| 653 | ] | ||
| 654 | |||
| 655 | [[package]] | ||
| 347 | name = "convert_case" | 656 | name = "convert_case" |
| 348 | version = "0.10.0" | 657 | version = "0.10.0" |
| 349 | source = "registry+https://github.com/rust-lang/crates.io-index" | 658 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -368,6 +677,15 @@ dependencies = [ | |||
| 368 | ] | 677 | ] |
| 369 | 678 | ||
| 370 | [[package]] | 679 | [[package]] |
| 680 | name = "crc32fast" | ||
| 681 | version = "1.5.0" | ||
| 682 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 683 | checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" | ||
| 684 | dependencies = [ | ||
| 685 | "cfg-if", | ||
| 686 | ] | ||
| 687 | |||
| 688 | [[package]] | ||
| 371 | name = "criterion" | 689 | name = "criterion" |
| 372 | version = "0.5.1" | 690 | version = "0.5.1" |
| 373 | source = "registry+https://github.com/rust-lang/crates.io-index" | 691 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -462,12 +780,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 462 | checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" | 780 | checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" |
| 463 | 781 | ||
| 464 | [[package]] | 782 | [[package]] |
| 783 | name = "crypto-bigint" | ||
| 784 | version = "0.5.5" | ||
| 785 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 786 | checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" | ||
| 787 | dependencies = [ | ||
| 788 | "generic-array", | ||
| 789 | "rand_core 0.6.4", | ||
| 790 | "subtle", | ||
| 791 | "zeroize", | ||
| 792 | ] | ||
| 793 | |||
| 794 | [[package]] | ||
| 465 | name = "crypto-common" | 795 | name = "crypto-common" |
| 466 | version = "0.1.7" | 796 | version = "0.1.7" |
| 467 | source = "registry+https://github.com/rust-lang/crates.io-index" | 797 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 468 | checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" | 798 | checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" |
| 469 | dependencies = [ | 799 | dependencies = [ |
| 470 | "generic-array", | 800 | "generic-array", |
| 801 | "rand_core 0.6.4", | ||
| 471 | "typenum", | 802 | "typenum", |
| 472 | ] | 803 | ] |
| 473 | 804 | ||
| @@ -482,6 +813,15 @@ dependencies = [ | |||
| 482 | ] | 813 | ] |
| 483 | 814 | ||
| 484 | [[package]] | 815 | [[package]] |
| 816 | name = "ctr" | ||
| 817 | version = "0.9.2" | ||
| 818 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 819 | checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" | ||
| 820 | dependencies = [ | ||
| 821 | "cipher", | ||
| 822 | ] | ||
| 823 | |||
| 824 | [[package]] | ||
| 485 | name = "curve25519-dalek" | 825 | name = "curve25519-dalek" |
| 486 | version = "4.1.3" | 826 | version = "4.1.3" |
| 487 | source = "registry+https://github.com/rust-lang/crates.io-index" | 827 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -543,6 +883,23 @@ dependencies = [ | |||
| 543 | ] | 883 | ] |
| 544 | 884 | ||
| 545 | [[package]] | 885 | [[package]] |
| 886 | name = "data-encoding" | ||
| 887 | version = "2.10.0" | ||
| 888 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 889 | checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" | ||
| 890 | |||
| 891 | [[package]] | ||
| 892 | name = "delegate" | ||
| 893 | version = "0.13.5" | ||
| 894 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 895 | checksum = "780eb241654bf097afb00fc5f054a09b687dad862e485fdcf8399bb056565370" | ||
| 896 | dependencies = [ | ||
| 897 | "proc-macro2", | ||
| 898 | "quote", | ||
| 899 | "syn 2.0.117", | ||
| 900 | ] | ||
| 901 | |||
| 902 | [[package]] | ||
| 546 | name = "deltae" | 903 | name = "deltae" |
| 547 | version = "0.3.2" | 904 | version = "0.3.2" |
| 548 | source = "registry+https://github.com/rust-lang/crates.io-index" | 905 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -555,6 +912,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 555 | checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" | 912 | checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" |
| 556 | dependencies = [ | 913 | dependencies = [ |
| 557 | "const-oid", | 914 | "const-oid", |
| 915 | "pem-rfc7468", | ||
| 558 | "zeroize", | 916 | "zeroize", |
| 559 | ] | 917 | ] |
| 560 | 918 | ||
| @@ -590,13 +948,24 @@ dependencies = [ | |||
| 590 | ] | 948 | ] |
| 591 | 949 | ||
| 592 | [[package]] | 950 | [[package]] |
| 951 | name = "des" | ||
| 952 | version = "0.8.1" | ||
| 953 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 954 | checksum = "ffdd80ce8ce993de27e9f063a444a4d53ce8e8db4c1f00cc03af5ad5a9867a1e" | ||
| 955 | dependencies = [ | ||
| 956 | "cipher", | ||
| 957 | ] | ||
| 958 | |||
| 959 | [[package]] | ||
| 593 | name = "digest" | 960 | name = "digest" |
| 594 | version = "0.10.7" | 961 | version = "0.10.7" |
| 595 | source = "registry+https://github.com/rust-lang/crates.io-index" | 962 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 596 | checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" | 963 | checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" |
| 597 | dependencies = [ | 964 | dependencies = [ |
| 598 | "block-buffer", | 965 | "block-buffer", |
| 966 | "const-oid", | ||
| 599 | "crypto-common", | 967 | "crypto-common", |
| 968 | "subtle", | ||
| 600 | ] | 969 | ] |
| 601 | 970 | ||
| 602 | [[package]] | 971 | [[package]] |
| @@ -641,6 +1010,20 @@ dependencies = [ | |||
| 641 | ] | 1010 | ] |
| 642 | 1011 | ||
| 643 | [[package]] | 1012 | [[package]] |
| 1013 | name = "ecdsa" | ||
| 1014 | version = "0.16.9" | ||
| 1015 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1016 | checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" | ||
| 1017 | dependencies = [ | ||
| 1018 | "der", | ||
| 1019 | "digest", | ||
| 1020 | "elliptic-curve", | ||
| 1021 | "rfc6979", | ||
| 1022 | "signature", | ||
| 1023 | "spki", | ||
| 1024 | ] | ||
| 1025 | |||
| 1026 | [[package]] | ||
| 644 | name = "ed25519" | 1027 | name = "ed25519" |
| 645 | version = "2.2.3" | 1028 | version = "2.2.3" |
| 646 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1029 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -672,6 +1055,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 672 | checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" | 1055 | checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" |
| 673 | 1056 | ||
| 674 | [[package]] | 1057 | [[package]] |
| 1058 | name = "elliptic-curve" | ||
| 1059 | version = "0.13.8" | ||
| 1060 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1061 | checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" | ||
| 1062 | dependencies = [ | ||
| 1063 | "base16ct", | ||
| 1064 | "crypto-bigint", | ||
| 1065 | "digest", | ||
| 1066 | "ff", | ||
| 1067 | "generic-array", | ||
| 1068 | "group", | ||
| 1069 | "hkdf", | ||
| 1070 | "pem-rfc7468", | ||
| 1071 | "pkcs8", | ||
| 1072 | "rand_core 0.6.4", | ||
| 1073 | "sec1", | ||
| 1074 | "subtle", | ||
| 1075 | "zeroize", | ||
| 1076 | ] | ||
| 1077 | |||
| 1078 | [[package]] | ||
| 675 | name = "equivalent" | 1079 | name = "equivalent" |
| 676 | version = "1.0.2" | 1080 | version = "1.0.2" |
| 677 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1081 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -713,6 +1117,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 713 | checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" | 1117 | checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" |
| 714 | 1118 | ||
| 715 | [[package]] | 1119 | [[package]] |
| 1120 | name = "ff" | ||
| 1121 | version = "0.13.1" | ||
| 1122 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1123 | checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" | ||
| 1124 | dependencies = [ | ||
| 1125 | "rand_core 0.6.4", | ||
| 1126 | "subtle", | ||
| 1127 | ] | ||
| 1128 | |||
| 1129 | [[package]] | ||
| 716 | name = "fiat-crypto" | 1130 | name = "fiat-crypto" |
| 717 | version = "0.2.9" | 1131 | version = "0.2.9" |
| 718 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1132 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -748,14 +1162,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 748 | checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" | 1162 | checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" |
| 749 | 1163 | ||
| 750 | [[package]] | 1164 | [[package]] |
| 751 | name = "fnv" | 1165 | name = "flate2" |
| 752 | version = "1.0.7" | 1166 | version = "1.1.9" |
| 753 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1167 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 754 | checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" | 1168 | checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" |
| 1169 | dependencies = [ | ||
| 1170 | "crc32fast", | ||
| 1171 | "miniz_oxide", | ||
| 1172 | ] | ||
| 755 | 1173 | ||
| 756 | [[package]] | 1174 | [[package]] |
| 757 | name = "foldhash" | 1175 | name = "flurry" |
| 758 | version = "0.1.5" | 1176 | version = "0.5.2" |
| 1177 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1178 | checksum = "cf5efcf77a4da27927d3ab0509dec5b0954bb3bc59da5a1de9e52642ebd4cdf9" | ||
| 1179 | dependencies = [ | ||
| 1180 | "ahash", | ||
| 1181 | "num_cpus", | ||
| 1182 | "parking_lot", | ||
| 1183 | "seize", | ||
| 1184 | ] | ||
| 1185 | |||
| 1186 | [[package]] | ||
| 1187 | name = "fnv" | ||
| 1188 | version = "1.0.7" | ||
| 1189 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1190 | checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" | ||
| 1191 | |||
| 1192 | [[package]] | ||
| 1193 | name = "foldhash" | ||
| 1194 | version = "0.1.5" | ||
| 759 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1195 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 760 | checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" | 1196 | checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" |
| 761 | 1197 | ||
| @@ -775,6 +1211,94 @@ dependencies = [ | |||
| 775 | ] | 1211 | ] |
| 776 | 1212 | ||
| 777 | [[package]] | 1213 | [[package]] |
| 1214 | name = "futures" | ||
| 1215 | version = "0.3.32" | ||
| 1216 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1217 | checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" | ||
| 1218 | dependencies = [ | ||
| 1219 | "futures-channel", | ||
| 1220 | "futures-core", | ||
| 1221 | "futures-executor", | ||
| 1222 | "futures-io", | ||
| 1223 | "futures-sink", | ||
| 1224 | "futures-task", | ||
| 1225 | "futures-util", | ||
| 1226 | ] | ||
| 1227 | |||
| 1228 | [[package]] | ||
| 1229 | name = "futures-channel" | ||
| 1230 | version = "0.3.32" | ||
| 1231 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1232 | checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" | ||
| 1233 | dependencies = [ | ||
| 1234 | "futures-core", | ||
| 1235 | "futures-sink", | ||
| 1236 | ] | ||
| 1237 | |||
| 1238 | [[package]] | ||
| 1239 | name = "futures-core" | ||
| 1240 | version = "0.3.32" | ||
| 1241 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1242 | checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" | ||
| 1243 | |||
| 1244 | [[package]] | ||
| 1245 | name = "futures-executor" | ||
| 1246 | version = "0.3.32" | ||
| 1247 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1248 | checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" | ||
| 1249 | dependencies = [ | ||
| 1250 | "futures-core", | ||
| 1251 | "futures-task", | ||
| 1252 | "futures-util", | ||
| 1253 | ] | ||
| 1254 | |||
| 1255 | [[package]] | ||
| 1256 | name = "futures-io" | ||
| 1257 | version = "0.3.32" | ||
| 1258 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1259 | checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" | ||
| 1260 | |||
| 1261 | [[package]] | ||
| 1262 | name = "futures-macro" | ||
| 1263 | version = "0.3.32" | ||
| 1264 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1265 | checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" | ||
| 1266 | dependencies = [ | ||
| 1267 | "proc-macro2", | ||
| 1268 | "quote", | ||
| 1269 | "syn 2.0.117", | ||
| 1270 | ] | ||
| 1271 | |||
| 1272 | [[package]] | ||
| 1273 | name = "futures-sink" | ||
| 1274 | version = "0.3.32" | ||
| 1275 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1276 | checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" | ||
| 1277 | |||
| 1278 | [[package]] | ||
| 1279 | name = "futures-task" | ||
| 1280 | version = "0.3.32" | ||
| 1281 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1282 | checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" | ||
| 1283 | |||
| 1284 | [[package]] | ||
| 1285 | name = "futures-util" | ||
| 1286 | version = "0.3.32" | ||
| 1287 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1288 | checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" | ||
| 1289 | dependencies = [ | ||
| 1290 | "futures-channel", | ||
| 1291 | "futures-core", | ||
| 1292 | "futures-io", | ||
| 1293 | "futures-macro", | ||
| 1294 | "futures-sink", | ||
| 1295 | "futures-task", | ||
| 1296 | "memchr", | ||
| 1297 | "pin-project-lite", | ||
| 1298 | "slab", | ||
| 1299 | ] | ||
| 1300 | |||
| 1301 | [[package]] | ||
| 778 | name = "generic-array" | 1302 | name = "generic-array" |
| 779 | version = "0.14.7" | 1303 | version = "0.14.7" |
| 780 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1304 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -782,6 +1306,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" | |||
| 782 | dependencies = [ | 1306 | dependencies = [ |
| 783 | "typenum", | 1307 | "typenum", |
| 784 | "version_check", | 1308 | "version_check", |
| 1309 | "zeroize", | ||
| 785 | ] | 1310 | ] |
| 786 | 1311 | ||
| 787 | [[package]] | 1312 | [[package]] |
| @@ -791,8 +1316,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 791 | checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" | 1316 | checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" |
| 792 | dependencies = [ | 1317 | dependencies = [ |
| 793 | "cfg-if", | 1318 | "cfg-if", |
| 1319 | "js-sys", | ||
| 794 | "libc", | 1320 | "libc", |
| 795 | "wasi", | 1321 | "wasi", |
| 1322 | "wasm-bindgen", | ||
| 796 | ] | 1323 | ] |
| 797 | 1324 | ||
| 798 | [[package]] | 1325 | [[package]] |
| @@ -821,9 +1348,23 @@ dependencies = [ | |||
| 821 | ] | 1348 | ] |
| 822 | 1349 | ||
| 823 | [[package]] | 1350 | [[package]] |
| 1351 | name = "ghash" | ||
| 1352 | version = "0.5.1" | ||
| 1353 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1354 | checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" | ||
| 1355 | dependencies = [ | ||
| 1356 | "opaque-debug", | ||
| 1357 | "polyval", | ||
| 1358 | ] | ||
| 1359 | |||
| 1360 | [[package]] | ||
| 824 | name = "git-collab" | 1361 | name = "git-collab" |
| 825 | version = "0.1.0" | 1362 | version = "0.1.0" |
| 826 | dependencies = [ | 1363 | dependencies = [ |
| 1364 | "askama", | ||
| 1365 | "askama_web", | ||
| 1366 | "async-trait", | ||
| 1367 | "axum", | ||
| 827 | "base64", | 1368 | "base64", |
| 828 | "chrono", | 1369 | "chrono", |
| 829 | "clap", | 1370 | "clap", |
| @@ -837,10 +1378,16 @@ dependencies = [ | |||
| 837 | "proptest", | 1378 | "proptest", |
| 838 | "rand_core 0.6.4", | 1379 | "rand_core 0.6.4", |
| 839 | "ratatui", | 1380 | "ratatui", |
| 1381 | "russh", | ||
| 1382 | "russh-keys", | ||
| 840 | "serde", | 1383 | "serde", |
| 841 | "serde_json", | 1384 | "serde_json", |
| 842 | "tempfile", | 1385 | "tempfile", |
| 843 | "thiserror 2.0.18", | 1386 | "thiserror 2.0.18", |
| 1387 | "tokio", | ||
| 1388 | "toml_edit", | ||
| 1389 | "tracing", | ||
| 1390 | "tracing-subscriber", | ||
| 844 | ] | 1391 | ] |
| 845 | 1392 | ||
| 846 | [[package]] | 1393 | [[package]] |
| @@ -859,6 +1406,17 @@ dependencies = [ | |||
| 859 | ] | 1406 | ] |
| 860 | 1407 | ||
| 861 | [[package]] | 1408 | [[package]] |
| 1409 | name = "group" | ||
| 1410 | version = "0.13.0" | ||
| 1411 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1412 | checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" | ||
| 1413 | dependencies = [ | ||
| 1414 | "ff", | ||
| 1415 | "rand_core 0.6.4", | ||
| 1416 | "subtle", | ||
| 1417 | ] | ||
| 1418 | |||
| 1419 | [[package]] | ||
| 862 | name = "half" | 1420 | name = "half" |
| 863 | version = "2.7.1" | 1421 | version = "2.7.1" |
| 864 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1422 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -908,6 +1466,120 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 908 | checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" | 1466 | checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" |
| 909 | 1467 | ||
| 910 | [[package]] | 1468 | [[package]] |
| 1469 | name = "hex-literal" | ||
| 1470 | version = "0.4.1" | ||
| 1471 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1472 | checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" | ||
| 1473 | |||
| 1474 | [[package]] | ||
| 1475 | name = "hkdf" | ||
| 1476 | version = "0.12.4" | ||
| 1477 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1478 | checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" | ||
| 1479 | dependencies = [ | ||
| 1480 | "hmac", | ||
| 1481 | ] | ||
| 1482 | |||
| 1483 | [[package]] | ||
| 1484 | name = "hmac" | ||
| 1485 | version = "0.12.1" | ||
| 1486 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1487 | checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" | ||
| 1488 | dependencies = [ | ||
| 1489 | "digest", | ||
| 1490 | ] | ||
| 1491 | |||
| 1492 | [[package]] | ||
| 1493 | name = "home" | ||
| 1494 | version = "0.5.12" | ||
| 1495 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1496 | checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" | ||
| 1497 | dependencies = [ | ||
| 1498 | "windows-sys 0.61.2", | ||
| 1499 | ] | ||
| 1500 | |||
| 1501 | [[package]] | ||
| 1502 | name = "http" | ||
| 1503 | version = "1.4.0" | ||
| 1504 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1505 | checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" | ||
| 1506 | dependencies = [ | ||
| 1507 | "bytes", | ||
| 1508 | "itoa", | ||
| 1509 | ] | ||
| 1510 | |||
| 1511 | [[package]] | ||
| 1512 | name = "http-body" | ||
| 1513 | version = "1.0.1" | ||
| 1514 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1515 | checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" | ||
| 1516 | dependencies = [ | ||
| 1517 | "bytes", | ||
| 1518 | "http", | ||
| 1519 | ] | ||
| 1520 | |||
| 1521 | [[package]] | ||
| 1522 | name = "http-body-util" | ||
| 1523 | version = "0.1.3" | ||
| 1524 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1525 | checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" | ||
| 1526 | dependencies = [ | ||
| 1527 | "bytes", | ||
| 1528 | "futures-core", | ||
| 1529 | "http", | ||
| 1530 | "http-body", | ||
| 1531 | "pin-project-lite", | ||
| 1532 | ] | ||
| 1533 | |||
| 1534 | [[package]] | ||
| 1535 | name = "httparse" | ||
| 1536 | version = "1.10.1" | ||
| 1537 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1538 | checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" | ||
| 1539 | |||
| 1540 | [[package]] | ||
| 1541 | name = "httpdate" | ||
| 1542 | version = "1.0.3" | ||
| 1543 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1544 | checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" | ||
| 1545 | |||
| 1546 | [[package]] | ||
| 1547 | name = "hyper" | ||
| 1548 | version = "1.8.1" | ||
| 1549 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1550 | checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" | ||
| 1551 | dependencies = [ | ||
| 1552 | "atomic-waker", | ||
| 1553 | "bytes", | ||
| 1554 | "futures-channel", | ||
| 1555 | "futures-core", | ||
| 1556 | "http", | ||
| 1557 | "http-body", | ||
| 1558 | "httparse", | ||
| 1559 | "httpdate", | ||
| 1560 | "itoa", | ||
| 1561 | "pin-project-lite", | ||
| 1562 | "pin-utils", | ||
| 1563 | "smallvec", | ||
| 1564 | "tokio", | ||
| 1565 | ] | ||
| 1566 | |||
| 1567 | [[package]] | ||
| 1568 | name = "hyper-util" | ||
| 1569 | version = "0.1.20" | ||
| 1570 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1571 | checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" | ||
| 1572 | dependencies = [ | ||
| 1573 | "bytes", | ||
| 1574 | "http", | ||
| 1575 | "http-body", | ||
| 1576 | "hyper", | ||
| 1577 | "pin-project-lite", | ||
| 1578 | "tokio", | ||
| 1579 | "tower-service", | ||
| 1580 | ] | ||
| 1581 | |||
| 1582 | [[package]] | ||
| 911 | name = "iana-time-zone" | 1583 | name = "iana-time-zone" |
| 912 | version = "0.1.65" | 1584 | version = "0.1.65" |
| 913 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1585 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -919,7 +1591,7 @@ dependencies = [ | |||
| 919 | "js-sys", | 1591 | "js-sys", |
| 920 | "log", | 1592 | "log", |
| 921 | "wasm-bindgen", | 1593 | "wasm-bindgen", |
| 922 | "windows-core", | 1594 | "windows-core 0.62.2", |
| 923 | ] | 1595 | ] |
| 924 | 1596 | ||
| 925 | [[package]] | 1597 | [[package]] |
| @@ -1067,6 +1739,16 @@ dependencies = [ | |||
| 1067 | ] | 1739 | ] |
| 1068 | 1740 | ||
| 1069 | [[package]] | 1741 | [[package]] |
| 1742 | name = "inout" | ||
| 1743 | version = "0.1.4" | ||
| 1744 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1745 | checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" | ||
| 1746 | dependencies = [ | ||
| 1747 | "block-padding", | ||
| 1748 | "generic-array", | ||
| 1749 | ] | ||
| 1750 | |||
| 1751 | [[package]] | ||
| 1070 | name = "instability" | 1752 | name = "instability" |
| 1071 | version = "0.3.12" | 1753 | version = "0.3.12" |
| 1072 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1754 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -1132,10 +1814,12 @@ dependencies = [ | |||
| 1132 | 1814 | ||
| 1133 | [[package]] | 1815 | [[package]] |
| 1134 | name = "js-sys" | 1816 | name = "js-sys" |
| 1135 | version = "0.3.91" | 1817 | version = "0.3.92" |
| 1136 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1818 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1137 | checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" | 1819 | checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995" |
| 1138 | dependencies = [ | 1820 | dependencies = [ |
| 1821 | "cfg-if", | ||
| 1822 | "futures-util", | ||
| 1139 | "once_cell", | 1823 | "once_cell", |
| 1140 | "wasm-bindgen", | 1824 | "wasm-bindgen", |
| 1141 | ] | 1825 | ] |
| @@ -1162,6 +1846,9 @@ name = "lazy_static" | |||
| 1162 | version = "1.5.0" | 1846 | version = "1.5.0" |
| 1163 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1847 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1164 | checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" | 1848 | checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" |
| 1849 | dependencies = [ | ||
| 1850 | "spin", | ||
| 1851 | ] | ||
| 1165 | 1852 | ||
| 1166 | [[package]] | 1853 | [[package]] |
| 1167 | name = "leb128fmt" | 1854 | name = "leb128fmt" |
| @@ -1190,6 +1877,12 @@ dependencies = [ | |||
| 1190 | ] | 1877 | ] |
| 1191 | 1878 | ||
| 1192 | [[package]] | 1879 | [[package]] |
| 1880 | name = "libm" | ||
| 1881 | version = "0.2.16" | ||
| 1882 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1883 | checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" | ||
| 1884 | |||
| 1885 | [[package]] | ||
| 1193 | name = "libredox" | 1886 | name = "libredox" |
| 1194 | version = "0.1.14" | 1887 | version = "0.1.14" |
| 1195 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1888 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -1286,6 +1979,18 @@ dependencies = [ | |||
| 1286 | ] | 1979 | ] |
| 1287 | 1980 | ||
| 1288 | [[package]] | 1981 | [[package]] |
| 1982 | name = "matchit" | ||
| 1983 | version = "0.8.4" | ||
| 1984 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1985 | checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" | ||
| 1986 | |||
| 1987 | [[package]] | ||
| 1988 | name = "md5" | ||
| 1989 | version = "0.7.0" | ||
| 1990 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1991 | checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" | ||
| 1992 | |||
| 1993 | [[package]] | ||
| 1289 | name = "memchr" | 1994 | name = "memchr" |
| 1290 | version = "2.8.0" | 1995 | version = "2.8.0" |
| 1291 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1996 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -1307,12 +2012,28 @@ dependencies = [ | |||
| 1307 | ] | 2012 | ] |
| 1308 | 2013 | ||
| 1309 | [[package]] | 2014 | [[package]] |
| 2015 | name = "mime" | ||
| 2016 | version = "0.3.17" | ||
| 2017 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2018 | checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" | ||
| 2019 | |||
| 2020 | [[package]] | ||
| 1310 | name = "minimal-lexical" | 2021 | name = "minimal-lexical" |
| 1311 | version = "0.2.1" | 2022 | version = "0.2.1" |
| 1312 | source = "registry+https://github.com/rust-lang/crates.io-index" | 2023 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1313 | checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" | 2024 | checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" |
| 1314 | 2025 | ||
| 1315 | [[package]] | 2026 | [[package]] |
| 2027 | name = "miniz_oxide" | ||
| 2028 | version = "0.8.9" | ||
| 2029 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2030 | checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" | ||
| 2031 | dependencies = [ | ||
| 2032 | "adler2", | ||
| 2033 | "simd-adler32", | ||
| 2034 | ] | ||
| 2035 | |||
| 2036 | [[package]] | ||
| 1316 | name = "mio" | 2037 | name = "mio" |
| 1317 | version = "1.1.1" | 2038 | version = "1.1.1" |
| 1318 | source = "registry+https://github.com/rust-lang/crates.io-index" | 2039 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -1348,6 +2069,42 @@ dependencies = [ | |||
| 1348 | ] | 2069 | ] |
| 1349 | 2070 | ||
| 1350 | [[package]] | 2071 | [[package]] |
| 2072 | name = "nu-ansi-term" | ||
| 2073 | version = "0.50.3" | ||
| 2074 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2075 | checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" | ||
| 2076 | dependencies = [ | ||
| 2077 | "windows-sys 0.61.2", | ||
| 2078 | ] | ||
| 2079 | |||
| 2080 | [[package]] | ||
| 2081 | name = "num-bigint" | ||
| 2082 | version = "0.4.6" | ||
| 2083 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2084 | checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" | ||
| 2085 | dependencies = [ | ||
| 2086 | "num-integer", | ||
| 2087 | "num-traits", | ||
| 2088 | "rand 0.8.5", | ||
| 2089 | ] | ||
| 2090 | |||
| 2091 | [[package]] | ||
| 2092 | name = "num-bigint-dig" | ||
| 2093 | version = "0.8.6" | ||
| 2094 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2095 | checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" | ||
| 2096 | dependencies = [ | ||
| 2097 | "lazy_static", | ||
| 2098 | "libm", | ||
| 2099 | "num-integer", | ||
| 2100 | "num-iter", | ||
| 2101 | "num-traits", | ||
| 2102 | "rand 0.8.5", | ||
| 2103 | "smallvec", | ||
| 2104 | "zeroize", | ||
| 2105 | ] | ||
| 2106 | |||
| 2107 | [[package]] | ||
| 1351 | name = "num-conv" | 2108 | name = "num-conv" |
| 1352 | version = "0.2.0" | 2109 | version = "0.2.0" |
| 1353 | source = "registry+https://github.com/rust-lang/crates.io-index" | 2110 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -1365,12 +2122,43 @@ dependencies = [ | |||
| 1365 | ] | 2122 | ] |
| 1366 | 2123 | ||
| 1367 | [[package]] | 2124 | [[package]] |
| 2125 | name = "num-integer" | ||
| 2126 | version = "0.1.46" | ||
| 2127 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2128 | checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" | ||
| 2129 | dependencies = [ | ||
| 2130 | "num-traits", | ||
| 2131 | ] | ||
| 2132 | |||
| 2133 | [[package]] | ||
| 2134 | name = "num-iter" | ||
| 2135 | version = "0.1.45" | ||
| 2136 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2137 | checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" | ||
| 2138 | dependencies = [ | ||
| 2139 | "autocfg", | ||
| 2140 | "num-integer", | ||
| 2141 | "num-traits", | ||
| 2142 | ] | ||
| 2143 | |||
| 2144 | [[package]] | ||
| 1368 | name = "num-traits" | 2145 | name = "num-traits" |
| 1369 | version = "0.2.19" | 2146 | version = "0.2.19" |
| 1370 | source = "registry+https://github.com/rust-lang/crates.io-index" | 2147 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1371 | checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" | 2148 | checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" |
| 1372 | dependencies = [ | 2149 | dependencies = [ |
| 1373 | "autocfg", | 2150 | "autocfg", |
| 2151 | "libm", | ||
| 2152 | ] | ||
| 2153 | |||
| 2154 | [[package]] | ||
| 2155 | name = "num_cpus" | ||
| 2156 | version = "1.17.0" | ||
| 2157 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2158 | checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" | ||
| 2159 | dependencies = [ | ||
| 2160 | "hermit-abi", | ||
| 2161 | "libc", | ||
| 1374 | ] | 2162 | ] |
| 1375 | 2163 | ||
| 1376 | [[package]] | 2164 | [[package]] |
| @@ -1401,6 +2189,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 1401 | checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" | 2189 | checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" |
| 1402 | 2190 | ||
| 1403 | [[package]] | 2191 | [[package]] |
| 2192 | name = "opaque-debug" | ||
| 2193 | version = "0.3.1" | ||
| 2194 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2195 | checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" | ||
| 2196 | |||
| 2197 | [[package]] | ||
| 1404 | name = "openssl-probe" | 2198 | name = "openssl-probe" |
| 1405 | version = "0.1.6" | 2199 | version = "0.1.6" |
| 1406 | source = "registry+https://github.com/rust-lang/crates.io-index" | 2200 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -1434,6 +2228,59 @@ dependencies = [ | |||
| 1434 | ] | 2228 | ] |
| 1435 | 2229 | ||
| 1436 | [[package]] | 2230 | [[package]] |
| 2231 | name = "p256" | ||
| 2232 | version = "0.13.2" | ||
| 2233 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2234 | checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" | ||
| 2235 | dependencies = [ | ||
| 2236 | "ecdsa", | ||
| 2237 | "elliptic-curve", | ||
| 2238 | "primeorder", | ||
| 2239 | "sha2", | ||
| 2240 | ] | ||
| 2241 | |||
| 2242 | [[package]] | ||
| 2243 | name = "p384" | ||
| 2244 | version = "0.13.1" | ||
| 2245 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2246 | checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" | ||
| 2247 | dependencies = [ | ||
| 2248 | "ecdsa", | ||
| 2249 | "elliptic-curve", | ||
| 2250 | "primeorder", | ||
| 2251 | "sha2", | ||
| 2252 | ] | ||
| 2253 | |||
| 2254 | [[package]] | ||
| 2255 | name = "p521" | ||
| 2256 | version = "0.13.3" | ||
| 2257 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2258 | checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" | ||
| 2259 | dependencies = [ | ||
| 2260 | "base16ct", | ||
| 2261 | "ecdsa", | ||
| 2262 | "elliptic-curve", | ||
| 2263 | "primeorder", | ||
| 2264 | "rand_core 0.6.4", | ||
| 2265 | "sha2", | ||
| 2266 | ] | ||
| 2267 | |||
| 2268 | [[package]] | ||
| 2269 | name = "pageant" | ||
| 2270 | version = "0.0.1" | ||
| 2271 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2272 | checksum = "032d6201d2fb765158455ae0d5a510c016bb6da7232e5040e39e9c8db12b0afc" | ||
| 2273 | dependencies = [ | ||
| 2274 | "bytes", | ||
| 2275 | "delegate", | ||
| 2276 | "futures", | ||
| 2277 | "rand 0.8.5", | ||
| 2278 | "thiserror 1.0.69", | ||
| 2279 | "tokio", | ||
| 2280 | "windows", | ||
| 2281 | ] | ||
| 2282 | |||
| 2283 | [[package]] | ||
| 1437 | name = "parking_lot" | 2284 | name = "parking_lot" |
| 1438 | version = "0.12.5" | 2285 | version = "0.12.5" |
| 1439 | source = "registry+https://github.com/rust-lang/crates.io-index" | 2286 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -1457,6 +2304,25 @@ dependencies = [ | |||
| 1457 | ] | 2304 | ] |
| 1458 | 2305 | ||
| 1459 | [[package]] | 2306 | [[package]] |
| 2307 | name = "pbkdf2" | ||
| 2308 | version = "0.12.2" | ||
| 2309 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2310 | checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" | ||
| 2311 | dependencies = [ | ||
| 2312 | "digest", | ||
| 2313 | "hmac", | ||
| 2314 | ] | ||
| 2315 | |||
| 2316 | [[package]] | ||
| 2317 | name = "pem-rfc7468" | ||
| 2318 | version = "0.7.0" | ||
| 2319 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2320 | checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" | ||
| 2321 | dependencies = [ | ||
| 2322 | "base64ct", | ||
| 2323 | ] | ||
| 2324 | |||
| 2325 | [[package]] | ||
| 1460 | name = "percent-encoding" | 2326 | name = "percent-encoding" |
| 1461 | version = "2.3.2" | 2327 | version = "2.3.2" |
| 1462 | source = "registry+https://github.com/rust-lang/crates.io-index" | 2328 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -1558,12 +2424,52 @@ dependencies = [ | |||
| 1558 | ] | 2424 | ] |
| 1559 | 2425 | ||
| 1560 | [[package]] | 2426 | [[package]] |
| 2427 | name = "pin-project-lite" | ||
| 2428 | version = "0.2.17" | ||
| 2429 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2430 | checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" | ||
| 2431 | |||
| 2432 | [[package]] | ||
| 2433 | name = "pin-utils" | ||
| 2434 | version = "0.1.0" | ||
| 2435 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2436 | checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" | ||
| 2437 | |||
| 2438 | [[package]] | ||
| 2439 | name = "pkcs1" | ||
| 2440 | version = "0.7.5" | ||
| 2441 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2442 | checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" | ||
| 2443 | dependencies = [ | ||
| 2444 | "der", | ||
| 2445 | "pkcs8", | ||
| 2446 | "spki", | ||
| 2447 | ] | ||
| 2448 | |||
| 2449 | [[package]] | ||
| 2450 | name = "pkcs5" | ||
| 2451 | version = "0.7.1" | ||
| 2452 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2453 | checksum = "e847e2c91a18bfa887dd028ec33f2fe6f25db77db3619024764914affe8b69a6" | ||
| 2454 | dependencies = [ | ||
| 2455 | "aes", | ||
| 2456 | "cbc", | ||
| 2457 | "der", | ||
| 2458 | "pbkdf2", | ||
| 2459 | "scrypt", | ||
| 2460 | "sha2", | ||
| 2461 | "spki", | ||
| 2462 | ] | ||
| 2463 | |||
| 2464 | [[package]] | ||
| 1561 | name = "pkcs8" | 2465 | name = "pkcs8" |
| 1562 | version = "0.10.2" | 2466 | version = "0.10.2" |
| 1563 | source = "registry+https://github.com/rust-lang/crates.io-index" | 2467 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1564 | checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" | 2468 | checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" |
| 1565 | dependencies = [ | 2469 | dependencies = [ |
| 1566 | "der", | 2470 | "der", |
| 2471 | "pkcs5", | ||
| 2472 | "rand_core 0.6.4", | ||
| 1567 | "spki", | 2473 | "spki", |
| 1568 | ] | 2474 | ] |
| 1569 | 2475 | ||
| @@ -1602,6 +2508,29 @@ dependencies = [ | |||
| 1602 | ] | 2508 | ] |
| 1603 | 2509 | ||
| 1604 | [[package]] | 2510 | [[package]] |
| 2511 | name = "poly1305" | ||
| 2512 | version = "0.8.0" | ||
| 2513 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2514 | checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" | ||
| 2515 | dependencies = [ | ||
| 2516 | "cpufeatures", | ||
| 2517 | "opaque-debug", | ||
| 2518 | "universal-hash", | ||
| 2519 | ] | ||
| 2520 | |||
| 2521 | [[package]] | ||
| 2522 | name = "polyval" | ||
| 2523 | version = "0.6.2" | ||
| 2524 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2525 | checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" | ||
| 2526 | dependencies = [ | ||
| 2527 | "cfg-if", | ||
| 2528 | "cpufeatures", | ||
| 2529 | "opaque-debug", | ||
| 2530 | "universal-hash", | ||
| 2531 | ] | ||
| 2532 | |||
| 2533 | [[package]] | ||
| 1605 | name = "portable-atomic" | 2534 | name = "portable-atomic" |
| 1606 | version = "1.13.1" | 2535 | version = "1.13.1" |
| 1607 | source = "registry+https://github.com/rust-lang/crates.io-index" | 2536 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -1642,6 +2571,15 @@ dependencies = [ | |||
| 1642 | ] | 2571 | ] |
| 1643 | 2572 | ||
| 1644 | [[package]] | 2573 | [[package]] |
| 2574 | name = "primeorder" | ||
| 2575 | version = "0.13.6" | ||
| 2576 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2577 | checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" | ||
| 2578 | dependencies = [ | ||
| 2579 | "elliptic-curve", | ||
| 2580 | ] | ||
| 2581 | |||
| 2582 | [[package]] | ||
| 1645 | name = "proc-macro2" | 2583 | name = "proc-macro2" |
| 1646 | version = "1.0.106" | 2584 | version = "1.0.106" |
| 1647 | source = "registry+https://github.com/rust-lang/crates.io-index" | 2585 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -1661,7 +2599,7 @@ dependencies = [ | |||
| 1661 | "bitflags 2.11.0", | 2599 | "bitflags 2.11.0", |
| 1662 | "num-traits", | 2600 | "num-traits", |
| 1663 | "rand 0.9.2", | 2601 | "rand 0.9.2", |
| 1664 | "rand_chacha", | 2602 | "rand_chacha 0.9.0", |
| 1665 | "rand_xorshift", | 2603 | "rand_xorshift", |
| 1666 | "regex-syntax", | 2604 | "regex-syntax", |
| 1667 | "rusty-fork", | 2605 | "rusty-fork", |
| @@ -1702,6 +2640,8 @@ version = "0.8.5" | |||
| 1702 | source = "registry+https://github.com/rust-lang/crates.io-index" | 2640 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1703 | checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" | 2641 | checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" |
| 1704 | dependencies = [ | 2642 | dependencies = [ |
| 2643 | "libc", | ||
| 2644 | "rand_chacha 0.3.1", | ||
| 1705 | "rand_core 0.6.4", | 2645 | "rand_core 0.6.4", |
| 1706 | ] | 2646 | ] |
| 1707 | 2647 | ||
| @@ -1711,12 +2651,22 @@ version = "0.9.2" | |||
| 1711 | source = "registry+https://github.com/rust-lang/crates.io-index" | 2651 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1712 | checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" | 2652 | checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" |
| 1713 | dependencies = [ | 2653 | dependencies = [ |
| 1714 | "rand_chacha", | 2654 | "rand_chacha 0.9.0", |
| 1715 | "rand_core 0.9.5", | 2655 | "rand_core 0.9.5", |
| 1716 | ] | 2656 | ] |
| 1717 | 2657 | ||
| 1718 | [[package]] | 2658 | [[package]] |
| 1719 | name = "rand_chacha" | 2659 | name = "rand_chacha" |
| 2660 | version = "0.3.1" | ||
| 2661 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2662 | checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" | ||
| 2663 | dependencies = [ | ||
| 2664 | "ppv-lite86", | ||
| 2665 | "rand_core 0.6.4", | ||
| 2666 | ] | ||
| 2667 | |||
| 2668 | [[package]] | ||
| 2669 | name = "rand_chacha" | ||
| 1720 | version = "0.9.0" | 2670 | version = "0.9.0" |
| 1721 | source = "registry+https://github.com/rust-lang/crates.io-index" | 2671 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1722 | checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" | 2672 | checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" |
| @@ -1907,12 +2857,187 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 1907 | checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" | 2857 | checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" |
| 1908 | 2858 | ||
| 1909 | [[package]] | 2859 | [[package]] |
| 2860 | name = "rfc6979" | ||
| 2861 | version = "0.4.0" | ||
| 2862 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2863 | checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" | ||
| 2864 | dependencies = [ | ||
| 2865 | "hmac", | ||
| 2866 | "subtle", | ||
| 2867 | ] | ||
| 2868 | |||
| 2869 | [[package]] | ||
| 1910 | name = "roff" | 2870 | name = "roff" |
| 1911 | version = "1.1.0" | 2871 | version = "1.1.0" |
| 1912 | source = "registry+https://github.com/rust-lang/crates.io-index" | 2872 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1913 | checksum = "dbf2048e0e979efb2ca7b91c4f1a8d77c91853e9b987c94c555668a8994915ad" | 2873 | checksum = "dbf2048e0e979efb2ca7b91c4f1a8d77c91853e9b987c94c555668a8994915ad" |
| 1914 | 2874 | ||
| 1915 | [[package]] | 2875 | [[package]] |
| 2876 | name = "rsa" | ||
| 2877 | version = "0.9.10" | ||
| 2878 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2879 | checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" | ||
| 2880 | dependencies = [ | ||
| 2881 | "const-oid", | ||
| 2882 | "digest", | ||
| 2883 | "num-bigint-dig", | ||
| 2884 | "num-integer", | ||
| 2885 | "num-traits", | ||
| 2886 | "pkcs1", | ||
| 2887 | "pkcs8", | ||
| 2888 | "rand_core 0.6.4", | ||
| 2889 | "sha2", | ||
| 2890 | "signature", | ||
| 2891 | "spki", | ||
| 2892 | "subtle", | ||
| 2893 | "zeroize", | ||
| 2894 | ] | ||
| 2895 | |||
| 2896 | [[package]] | ||
| 2897 | name = "russh" | ||
| 2898 | version = "0.46.0" | ||
| 2899 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2900 | checksum = "c536b90d8e2468d8dedc8de2369383c101325e23fffa3a30de713032862a11d4" | ||
| 2901 | dependencies = [ | ||
| 2902 | "aes", | ||
| 2903 | "aes-gcm", | ||
| 2904 | "async-trait", | ||
| 2905 | "bitflags 2.11.0", | ||
| 2906 | "byteorder", | ||
| 2907 | "cbc", | ||
| 2908 | "chacha20", | ||
| 2909 | "ctr", | ||
| 2910 | "curve25519-dalek", | ||
| 2911 | "des", | ||
| 2912 | "digest", | ||
| 2913 | "elliptic-curve", | ||
| 2914 | "flate2", | ||
| 2915 | "futures", | ||
| 2916 | "generic-array", | ||
| 2917 | "hex-literal", | ||
| 2918 | "hmac", | ||
| 2919 | "log", | ||
| 2920 | "num-bigint", | ||
| 2921 | "once_cell", | ||
| 2922 | "p256", | ||
| 2923 | "p384", | ||
| 2924 | "p521", | ||
| 2925 | "poly1305", | ||
| 2926 | "rand 0.8.5", | ||
| 2927 | "rand_core 0.6.4", | ||
| 2928 | "russh-cryptovec", | ||
| 2929 | "russh-keys", | ||
| 2930 | "russh-sftp", | ||
| 2931 | "russh-util", | ||
| 2932 | "sha1", | ||
| 2933 | "sha2", | ||
| 2934 | "ssh-encoding", | ||
| 2935 | "ssh-key", | ||
| 2936 | "subtle", | ||
| 2937 | "thiserror 1.0.69", | ||
| 2938 | "tokio", | ||
| 2939 | ] | ||
| 2940 | |||
| 2941 | [[package]] | ||
| 2942 | name = "russh-cryptovec" | ||
| 2943 | version = "0.7.3" | ||
| 2944 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2945 | checksum = "fadd2c0ab350e21c66556f94ee06f766d8bdae3213857ba7610bfd8e10e51880" | ||
| 2946 | dependencies = [ | ||
| 2947 | "libc", | ||
| 2948 | "winapi", | ||
| 2949 | ] | ||
| 2950 | |||
| 2951 | [[package]] | ||
| 2952 | name = "russh-keys" | ||
| 2953 | version = "0.46.0" | ||
| 2954 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2955 | checksum = "6e3db166c8678c824627c2c46f619ed5ce4ae33f38a35403c62f6ab8f3985867" | ||
| 2956 | dependencies = [ | ||
| 2957 | "aes", | ||
| 2958 | "async-trait", | ||
| 2959 | "bcrypt-pbkdf", | ||
| 2960 | "block-padding", | ||
| 2961 | "byteorder", | ||
| 2962 | "cbc", | ||
| 2963 | "ctr", | ||
| 2964 | "data-encoding", | ||
| 2965 | "der", | ||
| 2966 | "digest", | ||
| 2967 | "ecdsa", | ||
| 2968 | "ed25519-dalek", | ||
| 2969 | "elliptic-curve", | ||
| 2970 | "futures", | ||
| 2971 | "getrandom 0.2.17", | ||
| 2972 | "hmac", | ||
| 2973 | "home", | ||
| 2974 | "inout", | ||
| 2975 | "log", | ||
| 2976 | "md5", | ||
| 2977 | "num-integer", | ||
| 2978 | "p256", | ||
| 2979 | "p384", | ||
| 2980 | "p521", | ||
| 2981 | "pageant", | ||
| 2982 | "pbkdf2", | ||
| 2983 | "pkcs1", | ||
| 2984 | "pkcs5", | ||
| 2985 | "pkcs8", | ||
| 2986 | "rand 0.8.5", | ||
| 2987 | "rand_core 0.6.4", | ||
| 2988 | "rsa", | ||
| 2989 | "russh-cryptovec", | ||
| 2990 | "russh-util", | ||
| 2991 | "sec1", | ||
| 2992 | "serde", | ||
| 2993 | "sha1", | ||
| 2994 | "sha2", | ||
| 2995 | "spki", | ||
| 2996 | "ssh-encoding", | ||
| 2997 | "ssh-key", | ||
| 2998 | "thiserror 1.0.69", | ||
| 2999 | "tokio", | ||
| 3000 | "tokio-stream", | ||
| 3001 | "typenum", | ||
| 3002 | "zeroize", | ||
| 3003 | ] | ||
| 3004 | |||
| 3005 | [[package]] | ||
| 3006 | name = "russh-sftp" | ||
| 3007 | version = "2.1.1" | ||
| 3008 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3009 | checksum = "3bb94393cafad0530145b8f626d8687f1ee1dedb93d7ba7740d6ae81868b13b5" | ||
| 3010 | dependencies = [ | ||
| 3011 | "bitflags 2.11.0", | ||
| 3012 | "bytes", | ||
| 3013 | "chrono", | ||
| 3014 | "flurry", | ||
| 3015 | "log", | ||
| 3016 | "serde", | ||
| 3017 | "thiserror 2.0.18", | ||
| 3018 | "tokio", | ||
| 3019 | "tokio-util", | ||
| 3020 | ] | ||
| 3021 | |||
| 3022 | [[package]] | ||
| 3023 | name = "russh-util" | ||
| 3024 | version = "0.46.0" | ||
| 3025 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3026 | checksum = "63aeb9d2b74f8f38befdc0c5172d5ffcf58f3d2ffcb423f3b6cdfe2c2d747b80" | ||
| 3027 | dependencies = [ | ||
| 3028 | "chrono", | ||
| 3029 | "tokio", | ||
| 3030 | "wasm-bindgen", | ||
| 3031 | "wasm-bindgen-futures", | ||
| 3032 | ] | ||
| 3033 | |||
| 3034 | [[package]] | ||
| 3035 | name = "rustc-hash" | ||
| 3036 | version = "2.1.2" | ||
| 3037 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3038 | checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" | ||
| 3039 | |||
| 3040 | [[package]] | ||
| 1916 | name = "rustc_version" | 3041 | name = "rustc_version" |
| 1917 | version = "0.4.1" | 3042 | version = "0.4.1" |
| 1918 | source = "registry+https://github.com/rust-lang/crates.io-index" | 3043 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -1959,6 +3084,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 1959 | checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" | 3084 | checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" |
| 1960 | 3085 | ||
| 1961 | [[package]] | 3086 | [[package]] |
| 3087 | name = "salsa20" | ||
| 3088 | version = "0.10.2" | ||
| 3089 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3090 | checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" | ||
| 3091 | dependencies = [ | ||
| 3092 | "cipher", | ||
| 3093 | ] | ||
| 3094 | |||
| 3095 | [[package]] | ||
| 1962 | name = "same-file" | 3096 | name = "same-file" |
| 1963 | version = "1.0.6" | 3097 | version = "1.0.6" |
| 1964 | source = "registry+https://github.com/rust-lang/crates.io-index" | 3098 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -1974,6 +3108,37 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 1974 | checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" | 3108 | checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" |
| 1975 | 3109 | ||
| 1976 | [[package]] | 3110 | [[package]] |
| 3111 | name = "scrypt" | ||
| 3112 | version = "0.11.0" | ||
| 3113 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3114 | checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" | ||
| 3115 | dependencies = [ | ||
| 3116 | "pbkdf2", | ||
| 3117 | "salsa20", | ||
| 3118 | "sha2", | ||
| 3119 | ] | ||
| 3120 | |||
| 3121 | [[package]] | ||
| 3122 | name = "sec1" | ||
| 3123 | version = "0.7.3" | ||
| 3124 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3125 | checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" | ||
| 3126 | dependencies = [ | ||
| 3127 | "base16ct", | ||
| 3128 | "der", | ||
| 3129 | "generic-array", | ||
| 3130 | "pkcs8", | ||
| 3131 | "subtle", | ||
| 3132 | "zeroize", | ||
| 3133 | ] | ||
| 3134 | |||
| 3135 | [[package]] | ||
| 3136 | name = "seize" | ||
| 3137 | version = "0.3.3" | ||
| 3138 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3139 | checksum = "689224d06523904ebcc9b482c6a3f4f7fb396096645c4cd10c0d2ff7371a34d3" | ||
| 3140 | |||
| 3141 | [[package]] | ||
| 1977 | name = "semver" | 3142 | name = "semver" |
| 1978 | version = "1.0.27" | 3143 | version = "1.0.27" |
| 1979 | source = "registry+https://github.com/rust-lang/crates.io-index" | 3144 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -2023,6 +3188,49 @@ dependencies = [ | |||
| 2023 | ] | 3188 | ] |
| 2024 | 3189 | ||
| 2025 | [[package]] | 3190 | [[package]] |
| 3191 | name = "serde_path_to_error" | ||
| 3192 | version = "0.1.20" | ||
| 3193 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3194 | checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" | ||
| 3195 | dependencies = [ | ||
| 3196 | "itoa", | ||
| 3197 | "serde", | ||
| 3198 | "serde_core", | ||
| 3199 | ] | ||
| 3200 | |||
| 3201 | [[package]] | ||
| 3202 | name = "serde_spanned" | ||
| 3203 | version = "0.6.9" | ||
| 3204 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3205 | checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" | ||
| 3206 | dependencies = [ | ||
| 3207 | "serde", | ||
| 3208 | ] | ||
| 3209 | |||
| 3210 | [[package]] | ||
| 3211 | name = "serde_urlencoded" | ||
| 3212 | version = "0.7.1" | ||
| 3213 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3214 | checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" | ||
| 3215 | dependencies = [ | ||
| 3216 | "form_urlencoded", | ||
| 3217 | "itoa", | ||
| 3218 | "ryu", | ||
| 3219 | "serde", | ||
| 3220 | ] | ||
| 3221 | |||
| 3222 | [[package]] | ||
| 3223 | name = "sha1" | ||
| 3224 | version = "0.10.6" | ||
| 3225 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3226 | checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" | ||
| 3227 | dependencies = [ | ||
| 3228 | "cfg-if", | ||
| 3229 | "cpufeatures", | ||
| 3230 | "digest", | ||
| 3231 | ] | ||
| 3232 | |||
| 3233 | [[package]] | ||
| 2026 | name = "sha2" | 3234 | name = "sha2" |
| 2027 | version = "0.10.9" | 3235 | version = "0.10.9" |
| 2028 | source = "registry+https://github.com/rust-lang/crates.io-index" | 3236 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -2034,6 +3242,15 @@ dependencies = [ | |||
| 2034 | ] | 3242 | ] |
| 2035 | 3243 | ||
| 2036 | [[package]] | 3244 | [[package]] |
| 3245 | name = "sharded-slab" | ||
| 3246 | version = "0.1.7" | ||
| 3247 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3248 | checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" | ||
| 3249 | dependencies = [ | ||
| 3250 | "lazy_static", | ||
| 3251 | ] | ||
| 3252 | |||
| 3253 | [[package]] | ||
| 2037 | name = "shlex" | 3254 | name = "shlex" |
| 2038 | version = "1.3.0" | 3255 | version = "1.3.0" |
| 2039 | source = "registry+https://github.com/rust-lang/crates.io-index" | 3256 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -2076,22 +3293,51 @@ version = "2.2.0" | |||
| 2076 | source = "registry+https://github.com/rust-lang/crates.io-index" | 3293 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 2077 | checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" | 3294 | checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" |
| 2078 | dependencies = [ | 3295 | dependencies = [ |
| 3296 | "digest", | ||
| 2079 | "rand_core 0.6.4", | 3297 | "rand_core 0.6.4", |
| 2080 | ] | 3298 | ] |
| 2081 | 3299 | ||
| 2082 | [[package]] | 3300 | [[package]] |
| 3301 | name = "simd-adler32" | ||
| 3302 | version = "0.3.9" | ||
| 3303 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3304 | checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" | ||
| 3305 | |||
| 3306 | [[package]] | ||
| 2083 | name = "siphasher" | 3307 | name = "siphasher" |
| 2084 | version = "1.0.2" | 3308 | version = "1.0.2" |
| 2085 | source = "registry+https://github.com/rust-lang/crates.io-index" | 3309 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 2086 | checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" | 3310 | checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" |
| 2087 | 3311 | ||
| 2088 | [[package]] | 3312 | [[package]] |
| 3313 | name = "slab" | ||
| 3314 | version = "0.4.12" | ||
| 3315 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3316 | checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" | ||
| 3317 | |||
| 3318 | [[package]] | ||
| 2089 | name = "smallvec" | 3319 | name = "smallvec" |
| 2090 | version = "1.15.1" | 3320 | version = "1.15.1" |
| 2091 | source = "registry+https://github.com/rust-lang/crates.io-index" | 3321 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 2092 | checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" | 3322 | checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" |
| 2093 | 3323 | ||
| 2094 | [[package]] | 3324 | [[package]] |
| 3325 | name = "socket2" | ||
| 3326 | version = "0.6.3" | ||
| 3327 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3328 | checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" | ||
| 3329 | dependencies = [ | ||
| 3330 | "libc", | ||
| 3331 | "windows-sys 0.61.2", | ||
| 3332 | ] | ||
| 3333 | |||
| 3334 | [[package]] | ||
| 3335 | name = "spin" | ||
| 3336 | version = "0.9.8" | ||
| 3337 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3338 | checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" | ||
| 3339 | |||
| 3340 | [[package]] | ||
| 2095 | name = "spki" | 3341 | name = "spki" |
| 2096 | version = "0.7.3" | 3342 | version = "0.7.3" |
| 2097 | source = "registry+https://github.com/rust-lang/crates.io-index" | 3343 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -2102,6 +3348,57 @@ dependencies = [ | |||
| 2102 | ] | 3348 | ] |
| 2103 | 3349 | ||
| 2104 | [[package]] | 3350 | [[package]] |
| 3351 | name = "ssh-cipher" | ||
| 3352 | version = "0.2.0" | ||
| 3353 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3354 | checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" | ||
| 3355 | dependencies = [ | ||
| 3356 | "aes", | ||
| 3357 | "aes-gcm", | ||
| 3358 | "cbc", | ||
| 3359 | "chacha20", | ||
| 3360 | "cipher", | ||
| 3361 | "ctr", | ||
| 3362 | "poly1305", | ||
| 3363 | "ssh-encoding", | ||
| 3364 | "subtle", | ||
| 3365 | ] | ||
| 3366 | |||
| 3367 | [[package]] | ||
| 3368 | name = "ssh-encoding" | ||
| 3369 | version = "0.2.0" | ||
| 3370 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3371 | checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15" | ||
| 3372 | dependencies = [ | ||
| 3373 | "base64ct", | ||
| 3374 | "pem-rfc7468", | ||
| 3375 | "sha2", | ||
| 3376 | ] | ||
| 3377 | |||
| 3378 | [[package]] | ||
| 3379 | name = "ssh-key" | ||
| 3380 | version = "0.6.7" | ||
| 3381 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3382 | checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3" | ||
| 3383 | dependencies = [ | ||
| 3384 | "bcrypt-pbkdf", | ||
| 3385 | "ed25519-dalek", | ||
| 3386 | "num-bigint-dig", | ||
| 3387 | "p256", | ||
| 3388 | "p384", | ||
| 3389 | "p521", | ||
| 3390 | "rand_core 0.6.4", | ||
| 3391 | "rsa", | ||
| 3392 | "sec1", | ||
| 3393 | "sha2", | ||
| 3394 | "signature", | ||
| 3395 | "ssh-cipher", | ||
| 3396 | "ssh-encoding", | ||
| 3397 | "subtle", | ||
| 3398 | "zeroize", | ||
| 3399 | ] | ||
| 3400 | |||
| 3401 | [[package]] | ||
| 2105 | name = "stable_deref_trait" | 3402 | name = "stable_deref_trait" |
| 2106 | version = "1.2.1" | 3403 | version = "1.2.1" |
| 2107 | source = "registry+https://github.com/rust-lang/crates.io-index" | 3404 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -2169,6 +3466,12 @@ dependencies = [ | |||
| 2169 | ] | 3466 | ] |
| 2170 | 3467 | ||
| 2171 | [[package]] | 3468 | [[package]] |
| 3469 | name = "sync_wrapper" | ||
| 3470 | version = "1.0.2" | ||
| 3471 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3472 | checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" | ||
| 3473 | |||
| 3474 | [[package]] | ||
| 2172 | name = "synstructure" | 3475 | name = "synstructure" |
| 2173 | version = "0.13.2" | 3476 | version = "0.13.2" |
| 2174 | source = "registry+https://github.com/rust-lang/crates.io-index" | 3477 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -2296,6 +3599,15 @@ dependencies = [ | |||
| 2296 | ] | 3599 | ] |
| 2297 | 3600 | ||
| 2298 | [[package]] | 3601 | [[package]] |
| 3602 | name = "thread_local" | ||
| 3603 | version = "1.1.9" | ||
| 3604 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3605 | checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" | ||
| 3606 | dependencies = [ | ||
| 3607 | "cfg-if", | ||
| 3608 | ] | ||
| 3609 | |||
| 3610 | [[package]] | ||
| 2299 | name = "time" | 3611 | name = "time" |
| 2300 | version = "0.3.47" | 3612 | version = "0.3.47" |
| 2301 | source = "registry+https://github.com/rust-lang/crates.io-index" | 3613 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -2317,6 +3629,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 2317 | checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" | 3629 | checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" |
| 2318 | 3630 | ||
| 2319 | [[package]] | 3631 | [[package]] |
| 3632 | name = "tiny-keccak" | ||
| 3633 | version = "2.0.2" | ||
| 3634 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3635 | checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" | ||
| 3636 | dependencies = [ | ||
| 3637 | "crunchy", | ||
| 3638 | ] | ||
| 3639 | |||
| 3640 | [[package]] | ||
| 2320 | name = "tinystr" | 3641 | name = "tinystr" |
| 2321 | version = "0.8.2" | 3642 | version = "0.8.2" |
| 2322 | source = "registry+https://github.com/rust-lang/crates.io-index" | 3643 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -2337,6 +3658,174 @@ dependencies = [ | |||
| 2337 | ] | 3658 | ] |
| 2338 | 3659 | ||
| 2339 | [[package]] | 3660 | [[package]] |
| 3661 | name = "tokio" | ||
| 3662 | version = "1.50.0" | ||
| 3663 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3664 | checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" | ||
| 3665 | dependencies = [ | ||
| 3666 | "bytes", | ||
| 3667 | "libc", | ||
| 3668 | "mio", | ||
| 3669 | "parking_lot", | ||
| 3670 | "pin-project-lite", | ||
| 3671 | "signal-hook-registry", | ||
| 3672 | "socket2", | ||
| 3673 | "tokio-macros", | ||
| 3674 | "windows-sys 0.61.2", | ||
| 3675 | ] | ||
| 3676 | |||
| 3677 | [[package]] | ||
| 3678 | name = "tokio-macros" | ||
| 3679 | version = "2.6.1" | ||
| 3680 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3681 | checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" | ||
| 3682 | dependencies = [ | ||
| 3683 | "proc-macro2", | ||
| 3684 | "quote", | ||
| 3685 | "syn 2.0.117", | ||
| 3686 | ] | ||
| 3687 | |||
| 3688 | [[package]] | ||
| 3689 | name = "tokio-stream" | ||
| 3690 | version = "0.1.18" | ||
| 3691 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3692 | checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" | ||
| 3693 | dependencies = [ | ||
| 3694 | "futures-core", | ||
| 3695 | "pin-project-lite", | ||
| 3696 | "tokio", | ||
| 3697 | "tokio-util", | ||
| 3698 | ] | ||
| 3699 | |||
| 3700 | [[package]] | ||
| 3701 | name = "tokio-util" | ||
| 3702 | version = "0.7.18" | ||
| 3703 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3704 | checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" | ||
| 3705 | dependencies = [ | ||
| 3706 | "bytes", | ||
| 3707 | "futures-core", | ||
| 3708 | "futures-sink", | ||
| 3709 | "pin-project-lite", | ||
| 3710 | "tokio", | ||
| 3711 | ] | ||
| 3712 | |||
| 3713 | [[package]] | ||
| 3714 | name = "toml_datetime" | ||
| 3715 | version = "0.6.11" | ||
| 3716 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3717 | checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" | ||
| 3718 | dependencies = [ | ||
| 3719 | "serde", | ||
| 3720 | ] | ||
| 3721 | |||
| 3722 | [[package]] | ||
| 3723 | name = "toml_edit" | ||
| 3724 | version = "0.22.27" | ||
| 3725 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3726 | checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" | ||
| 3727 | dependencies = [ | ||
| 3728 | "indexmap", | ||
| 3729 | "serde", | ||
| 3730 | "serde_spanned", | ||
| 3731 | "toml_datetime", | ||
| 3732 | "toml_write", | ||
| 3733 | "winnow 0.7.15", | ||
| 3734 | ] | ||
| 3735 | |||
| 3736 | [[package]] | ||
| 3737 | name = "toml_write" | ||
| 3738 | version = "0.1.2" | ||
| 3739 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3740 | checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" | ||
| 3741 | |||
| 3742 | [[package]] | ||
| 3743 | name = "tower" | ||
| 3744 | version = "0.5.3" | ||
| 3745 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3746 | checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" | ||
| 3747 | dependencies = [ | ||
| 3748 | "futures-core", | ||
| 3749 | "futures-util", | ||
| 3750 | "pin-project-lite", | ||
| 3751 | "sync_wrapper", | ||
| 3752 | "tokio", | ||
| 3753 | "tower-layer", | ||
| 3754 | "tower-service", | ||
| 3755 | "tracing", | ||
| 3756 | ] | ||
| 3757 | |||
| 3758 | [[package]] | ||
| 3759 | name = "tower-layer" | ||
| 3760 | version = "0.3.3" | ||
| 3761 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3762 | checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" | ||
| 3763 | |||
| 3764 | [[package]] | ||
| 3765 | name = "tower-service" | ||
| 3766 | version = "0.3.3" | ||
| 3767 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3768 | checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" | ||
| 3769 | |||
| 3770 | [[package]] | ||
| 3771 | name = "tracing" | ||
| 3772 | version = "0.1.44" | ||
| 3773 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3774 | checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" | ||
| 3775 | dependencies = [ | ||
| 3776 | "log", | ||
| 3777 | "pin-project-lite", | ||
| 3778 | "tracing-attributes", | ||
| 3779 | "tracing-core", | ||
| 3780 | ] | ||
| 3781 | |||
| 3782 | [[package]] | ||
| 3783 | name = "tracing-attributes" | ||
| 3784 | version = "0.1.31" | ||
| 3785 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3786 | checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" | ||
| 3787 | dependencies = [ | ||
| 3788 | "proc-macro2", | ||
| 3789 | "quote", | ||
| 3790 | "syn 2.0.117", | ||
| 3791 | ] | ||
| 3792 | |||
| 3793 | [[package]] | ||
| 3794 | name = "tracing-core" | ||
| 3795 | version = "0.1.36" | ||
| 3796 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3797 | checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" | ||
| 3798 | dependencies = [ | ||
| 3799 | "once_cell", | ||
| 3800 | "valuable", | ||
| 3801 | ] | ||
| 3802 | |||
| 3803 | [[package]] | ||
| 3804 | name = "tracing-log" | ||
| 3805 | version = "0.2.0" | ||
| 3806 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3807 | checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" | ||
| 3808 | dependencies = [ | ||
| 3809 | "log", | ||
| 3810 | "once_cell", | ||
| 3811 | "tracing-core", | ||
| 3812 | ] | ||
| 3813 | |||
| 3814 | [[package]] | ||
| 3815 | name = "tracing-subscriber" | ||
| 3816 | version = "0.3.23" | ||
| 3817 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3818 | checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" | ||
| 3819 | dependencies = [ | ||
| 3820 | "nu-ansi-term", | ||
| 3821 | "sharded-slab", | ||
| 3822 | "smallvec", | ||
| 3823 | "thread_local", | ||
| 3824 | "tracing-core", | ||
| 3825 | "tracing-log", | ||
| 3826 | ] | ||
| 3827 | |||
| 3828 | [[package]] | ||
| 2340 | name = "typenum" | 3829 | name = "typenum" |
| 2341 | version = "1.19.0" | 3830 | version = "1.19.0" |
| 2342 | source = "registry+https://github.com/rust-lang/crates.io-index" | 3831 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -2390,6 +3879,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 2390 | checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" | 3879 | checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" |
| 2391 | 3880 | ||
| 2392 | [[package]] | 3881 | [[package]] |
| 3882 | name = "universal-hash" | ||
| 3883 | version = "0.5.1" | ||
| 3884 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3885 | checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" | ||
| 3886 | dependencies = [ | ||
| 3887 | "crypto-common", | ||
| 3888 | "subtle", | ||
| 3889 | ] | ||
| 3890 | |||
| 3891 | [[package]] | ||
| 2393 | name = "url" | 3892 | name = "url" |
| 2394 | version = "2.5.8" | 3893 | version = "2.5.8" |
| 2395 | source = "registry+https://github.com/rust-lang/crates.io-index" | 3894 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -2426,6 +3925,12 @@ dependencies = [ | |||
| 2426 | ] | 3925 | ] |
| 2427 | 3926 | ||
| 2428 | [[package]] | 3927 | [[package]] |
| 3928 | name = "valuable" | ||
| 3929 | version = "0.1.1" | ||
| 3930 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3931 | checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" | ||
| 3932 | |||
| 3933 | [[package]] | ||
| 2429 | name = "vcpkg" | 3934 | name = "vcpkg" |
| 2430 | version = "0.2.15" | 3935 | version = "0.2.15" |
| 2431 | source = "registry+https://github.com/rust-lang/crates.io-index" | 3936 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -2491,9 +3996,9 @@ dependencies = [ | |||
| 2491 | 3996 | ||
| 2492 | [[package]] | 3997 | [[package]] |
| 2493 | name = "wasm-bindgen" | 3998 | name = "wasm-bindgen" |
| 2494 | version = "0.2.114" | 3999 | version = "0.2.115" |
| 2495 | source = "registry+https://github.com/rust-lang/crates.io-index" | 4000 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 2496 | checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" | 4001 | checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a" |
| 2497 | dependencies = [ | 4002 | dependencies = [ |
| 2498 | "cfg-if", | 4003 | "cfg-if", |
| 2499 | "once_cell", | 4004 | "once_cell", |
| @@ -2503,10 +4008,20 @@ dependencies = [ | |||
| 2503 | ] | 4008 | ] |
| 2504 | 4009 | ||
| 2505 | [[package]] | 4010 | [[package]] |
| 4011 | name = "wasm-bindgen-futures" | ||
| 4012 | version = "0.4.65" | ||
| 4013 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4014 | checksum = "2d1faf851e778dfa54db7cd438b70758eba9755cb47403f3496edd7c8fc212f0" | ||
| 4015 | dependencies = [ | ||
| 4016 | "js-sys", | ||
| 4017 | "wasm-bindgen", | ||
| 4018 | ] | ||
| 4019 | |||
| 4020 | [[package]] | ||
| 2506 | name = "wasm-bindgen-macro" | 4021 | name = "wasm-bindgen-macro" |
| 2507 | version = "0.2.114" | 4022 | version = "0.2.115" |
| 2508 | source = "registry+https://github.com/rust-lang/crates.io-index" | 4023 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 2509 | checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" | 4024 | checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67" |
| 2510 | dependencies = [ | 4025 | dependencies = [ |
| 2511 | "quote", | 4026 | "quote", |
| 2512 | "wasm-bindgen-macro-support", | 4027 | "wasm-bindgen-macro-support", |
| @@ -2514,9 +4029,9 @@ dependencies = [ | |||
| 2514 | 4029 | ||
| 2515 | [[package]] | 4030 | [[package]] |
| 2516 | name = "wasm-bindgen-macro-support" | 4031 | name = "wasm-bindgen-macro-support" |
| 2517 | version = "0.2.114" | 4032 | version = "0.2.115" |
| 2518 | source = "registry+https://github.com/rust-lang/crates.io-index" | 4033 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 2519 | checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" | 4034 | checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf" |
| 2520 | dependencies = [ | 4035 | dependencies = [ |
| 2521 | "bumpalo", | 4036 | "bumpalo", |
| 2522 | "proc-macro2", | 4037 | "proc-macro2", |
| @@ -2527,9 +4042,9 @@ dependencies = [ | |||
| 2527 | 4042 | ||
| 2528 | [[package]] | 4043 | [[package]] |
| 2529 | name = "wasm-bindgen-shared" | 4044 | name = "wasm-bindgen-shared" |
| 2530 | version = "0.2.114" | 4045 | version = "0.2.115" |
| 2531 | source = "registry+https://github.com/rust-lang/crates.io-index" | 4046 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 2532 | checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" | 4047 | checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93" |
| 2533 | dependencies = [ | 4048 | dependencies = [ |
| 2534 | "unicode-ident", | 4049 | "unicode-ident", |
| 2535 | ] | 4050 | ] |
| @@ -2570,9 +4085,9 @@ dependencies = [ | |||
| 2570 | 4085 | ||
| 2571 | [[package]] | 4086 | [[package]] |
| 2572 | name = "web-sys" | 4087 | name = "web-sys" |
| 2573 | version = "0.3.91" | 4088 | version = "0.3.92" |
| 2574 | source = "registry+https://github.com/rust-lang/crates.io-index" | 4089 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 2575 | checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" | 4090 | checksum = "84cde8507f4d7cfcb1185b8cb5890c494ffea65edbe1ba82cfd63661c805ed94" |
| 2576 | dependencies = [ | 4091 | dependencies = [ |
| 2577 | "js-sys", | 4092 | "js-sys", |
| 2578 | "wasm-bindgen", | 4093 | "wasm-bindgen", |
| @@ -2682,16 +4197,50 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 2682 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" | 4197 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" |
| 2683 | 4198 | ||
| 2684 | [[package]] | 4199 | [[package]] |
| 4200 | name = "windows" | ||
| 4201 | version = "0.58.0" | ||
| 4202 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4203 | checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" | ||
| 4204 | dependencies = [ | ||
| 4205 | "windows-core 0.58.0", | ||
| 4206 | "windows-targets 0.52.6", | ||
| 4207 | ] | ||
| 4208 | |||
| 4209 | [[package]] | ||
| 4210 | name = "windows-core" | ||
| 4211 | version = "0.58.0" | ||
| 4212 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4213 | checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" | ||
| 4214 | dependencies = [ | ||
| 4215 | "windows-implement 0.58.0", | ||
| 4216 | "windows-interface 0.58.0", | ||
| 4217 | "windows-result 0.2.0", | ||
| 4218 | "windows-strings 0.1.0", | ||
| 4219 | "windows-targets 0.52.6", | ||
| 4220 | ] | ||
| 4221 | |||
| 4222 | [[package]] | ||
| 2685 | name = "windows-core" | 4223 | name = "windows-core" |
| 2686 | version = "0.62.2" | 4224 | version = "0.62.2" |
| 2687 | source = "registry+https://github.com/rust-lang/crates.io-index" | 4225 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 2688 | checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" | 4226 | checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" |
| 2689 | dependencies = [ | 4227 | dependencies = [ |
| 2690 | "windows-implement", | 4228 | "windows-implement 0.60.2", |
| 2691 | "windows-interface", | 4229 | "windows-interface 0.59.3", |
| 2692 | "windows-link", | 4230 | "windows-link", |
| 2693 | "windows-result", | 4231 | "windows-result 0.4.1", |
| 2694 | "windows-strings", | 4232 | "windows-strings 0.5.1", |
| 4233 | ] | ||
| 4234 | |||
| 4235 | [[package]] | ||
| 4236 | name = "windows-implement" | ||
| 4237 | version = "0.58.0" | ||
| 4238 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4239 | checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" | ||
| 4240 | dependencies = [ | ||
| 4241 | "proc-macro2", | ||
| 4242 | "quote", | ||
| 4243 | "syn 2.0.117", | ||
| 2695 | ] | 4244 | ] |
| 2696 | 4245 | ||
| 2697 | [[package]] | 4246 | [[package]] |
| @@ -2707,6 +4256,17 @@ dependencies = [ | |||
| 2707 | 4256 | ||
| 2708 | [[package]] | 4257 | [[package]] |
| 2709 | name = "windows-interface" | 4258 | name = "windows-interface" |
| 4259 | version = "0.58.0" | ||
| 4260 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4261 | checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" | ||
| 4262 | dependencies = [ | ||
| 4263 | "proc-macro2", | ||
| 4264 | "quote", | ||
| 4265 | "syn 2.0.117", | ||
| 4266 | ] | ||
| 4267 | |||
| 4268 | [[package]] | ||
| 4269 | name = "windows-interface" | ||
| 2710 | version = "0.59.3" | 4270 | version = "0.59.3" |
| 2711 | source = "registry+https://github.com/rust-lang/crates.io-index" | 4271 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 2712 | checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" | 4272 | checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" |
| @@ -2724,6 +4284,15 @@ checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" | |||
| 2724 | 4284 | ||
| 2725 | [[package]] | 4285 | [[package]] |
| 2726 | name = "windows-result" | 4286 | name = "windows-result" |
| 4287 | version = "0.2.0" | ||
| 4288 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4289 | checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" | ||
| 4290 | dependencies = [ | ||
| 4291 | "windows-targets 0.52.6", | ||
| 4292 | ] | ||
| 4293 | |||
| 4294 | [[package]] | ||
| 4295 | name = "windows-result" | ||
| 2727 | version = "0.4.1" | 4296 | version = "0.4.1" |
| 2728 | source = "registry+https://github.com/rust-lang/crates.io-index" | 4297 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 2729 | checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" | 4298 | checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" |
| @@ -2733,6 +4302,16 @@ dependencies = [ | |||
| 2733 | 4302 | ||
| 2734 | [[package]] | 4303 | [[package]] |
| 2735 | name = "windows-strings" | 4304 | name = "windows-strings" |
| 4305 | version = "0.1.0" | ||
| 4306 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4307 | checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" | ||
| 4308 | dependencies = [ | ||
| 4309 | "windows-result 0.2.0", | ||
| 4310 | "windows-targets 0.52.6", | ||
| 4311 | ] | ||
| 4312 | |||
| 4313 | [[package]] | ||
| 4314 | name = "windows-strings" | ||
| 2736 | version = "0.5.1" | 4315 | version = "0.5.1" |
| 2737 | source = "registry+https://github.com/rust-lang/crates.io-index" | 4316 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 2738 | checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" | 4317 | checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" |
| @@ -2746,7 +4325,7 @@ version = "0.48.0" | |||
| 2746 | source = "registry+https://github.com/rust-lang/crates.io-index" | 4325 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 2747 | checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" | 4326 | checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" |
| 2748 | dependencies = [ | 4327 | dependencies = [ |
| 2749 | "windows-targets", | 4328 | "windows-targets 0.48.5", |
| 2750 | ] | 4329 | ] |
| 2751 | 4330 | ||
| 2752 | [[package]] | 4331 | [[package]] |
| @@ -2764,13 +4343,29 @@ version = "0.48.5" | |||
| 2764 | source = "registry+https://github.com/rust-lang/crates.io-index" | 4343 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 2765 | checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" | 4344 | checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" |
| 2766 | dependencies = [ | 4345 | dependencies = [ |
| 2767 | "windows_aarch64_gnullvm", | 4346 | "windows_aarch64_gnullvm 0.48.5", |
| 2768 | "windows_aarch64_msvc", | 4347 | "windows_aarch64_msvc 0.48.5", |
| 2769 | "windows_i686_gnu", | 4348 | "windows_i686_gnu 0.48.5", |
| 2770 | "windows_i686_msvc", | 4349 | "windows_i686_msvc 0.48.5", |
| 2771 | "windows_x86_64_gnu", | 4350 | "windows_x86_64_gnu 0.48.5", |
| 2772 | "windows_x86_64_gnullvm", | 4351 | "windows_x86_64_gnullvm 0.48.5", |
| 2773 | "windows_x86_64_msvc", | 4352 | "windows_x86_64_msvc 0.48.5", |
| 4353 | ] | ||
| 4354 | |||
| 4355 | [[package]] | ||
| 4356 | name = "windows-targets" | ||
| 4357 | version = "0.52.6" | ||
| 4358 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4359 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" | ||
| 4360 | dependencies = [ | ||
| 4361 | "windows_aarch64_gnullvm 0.52.6", | ||
| 4362 | "windows_aarch64_msvc 0.52.6", | ||
| 4363 | "windows_i686_gnu 0.52.6", | ||
| 4364 | "windows_i686_gnullvm", | ||
| 4365 | "windows_i686_msvc 0.52.6", | ||
| 4366 | "windows_x86_64_gnu 0.52.6", | ||
| 4367 | "windows_x86_64_gnullvm 0.52.6", | ||
| 4368 | "windows_x86_64_msvc 0.52.6", | ||
| 2774 | ] | 4369 | ] |
| 2775 | 4370 | ||
| 2776 | [[package]] | 4371 | [[package]] |
| @@ -2780,42 +4375,108 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 2780 | checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" | 4375 | checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" |
| 2781 | 4376 | ||
| 2782 | [[package]] | 4377 | [[package]] |
| 4378 | name = "windows_aarch64_gnullvm" | ||
| 4379 | version = "0.52.6" | ||
| 4380 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4381 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" | ||
| 4382 | |||
| 4383 | [[package]] | ||
| 2783 | name = "windows_aarch64_msvc" | 4384 | name = "windows_aarch64_msvc" |
| 2784 | version = "0.48.5" | 4385 | version = "0.48.5" |
| 2785 | source = "registry+https://github.com/rust-lang/crates.io-index" | 4386 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 2786 | checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" | 4387 | checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" |
| 2787 | 4388 | ||
| 2788 | [[package]] | 4389 | [[package]] |
| 4390 | name = "windows_aarch64_msvc" | ||
| 4391 | version = "0.52.6" | ||
| 4392 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4393 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" | ||
| 4394 | |||
| 4395 | [[package]] | ||
| 2789 | name = "windows_i686_gnu" | 4396 | name = "windows_i686_gnu" |
| 2790 | version = "0.48.5" | 4397 | version = "0.48.5" |
| 2791 | source = "registry+https://github.com/rust-lang/crates.io-index" | 4398 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 2792 | checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" | 4399 | checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" |
| 2793 | 4400 | ||
| 2794 | [[package]] | 4401 | [[package]] |
| 4402 | name = "windows_i686_gnu" | ||
| 4403 | version = "0.52.6" | ||
| 4404 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4405 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" | ||
| 4406 | |||
| 4407 | [[package]] | ||
| 4408 | name = "windows_i686_gnullvm" | ||
| 4409 | version = "0.52.6" | ||
| 4410 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4411 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" | ||
| 4412 | |||
| 4413 | [[package]] | ||
| 2795 | name = "windows_i686_msvc" | 4414 | name = "windows_i686_msvc" |
| 2796 | version = "0.48.5" | 4415 | version = "0.48.5" |
| 2797 | source = "registry+https://github.com/rust-lang/crates.io-index" | 4416 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 2798 | checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" | 4417 | checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" |
| 2799 | 4418 | ||
| 2800 | [[package]] | 4419 | [[package]] |
| 4420 | name = "windows_i686_msvc" | ||
| 4421 | version = "0.52.6" | ||
| 4422 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4423 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" | ||
| 4424 | |||
| 4425 | [[package]] | ||
| 2801 | name = "windows_x86_64_gnu" | 4426 | name = "windows_x86_64_gnu" |
| 2802 | version = "0.48.5" | 4427 | version = "0.48.5" |
| 2803 | source = "registry+https://github.com/rust-lang/crates.io-index" | 4428 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 2804 | checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" | 4429 | checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" |
| 2805 | 4430 | ||
| 2806 | [[package]] | 4431 | [[package]] |
| 4432 | name = "windows_x86_64_gnu" | ||
| 4433 | version = "0.52.6" | ||
| 4434 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4435 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" | ||
| 4436 | |||
| 4437 | [[package]] | ||
| 2807 | name = "windows_x86_64_gnullvm" | 4438 | name = "windows_x86_64_gnullvm" |
| 2808 | version = "0.48.5" | 4439 | version = "0.48.5" |
| 2809 | source = "registry+https://github.com/rust-lang/crates.io-index" | 4440 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 2810 | checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" | 4441 | checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" |
| 2811 | 4442 | ||
| 2812 | [[package]] | 4443 | [[package]] |
| 4444 | name = "windows_x86_64_gnullvm" | ||
| 4445 | version = "0.52.6" | ||
| 4446 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4447 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" | ||
| 4448 | |||
| 4449 | [[package]] | ||
| 2813 | name = "windows_x86_64_msvc" | 4450 | name = "windows_x86_64_msvc" |
| 2814 | version = "0.48.5" | 4451 | version = "0.48.5" |
| 2815 | source = "registry+https://github.com/rust-lang/crates.io-index" | 4452 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 2816 | checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" | 4453 | checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" |
| 2817 | 4454 | ||
| 2818 | [[package]] | 4455 | [[package]] |
| 4456 | name = "windows_x86_64_msvc" | ||
| 4457 | version = "0.52.6" | ||
| 4458 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4459 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" | ||
| 4460 | |||
| 4461 | [[package]] | ||
| 4462 | name = "winnow" | ||
| 4463 | version = "0.7.15" | ||
| 4464 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4465 | checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" | ||
| 4466 | dependencies = [ | ||
| 4467 | "memchr", | ||
| 4468 | ] | ||
| 4469 | |||
| 4470 | [[package]] | ||
| 4471 | name = "winnow" | ||
| 4472 | version = "1.0.1" | ||
| 4473 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 4474 | checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" | ||
| 4475 | dependencies = [ | ||
| 4476 | "memchr", | ||
| 4477 | ] | ||
| 4478 | |||
| 4479 | [[package]] | ||
| 2819 | name = "wit-bindgen" | 4480 | name = "wit-bindgen" |
| 2820 | version = "0.51.0" | 4481 | version = "0.51.0" |
| 2821 | source = "registry+https://github.com/rust-lang/crates.io-index" | 4482 | source = "registry+https://github.com/rust-lang/crates.io-index" |
Cargo.toml
| Old | New | ||
|---|---|---|---|
| @@ -3,6 +3,14 @@ name = "git-collab" | |||
| 3 | version = "0.1.0" | 3 | version = "0.1.0" |
| 4 | edition = "2021" | 4 | edition = "2021" |
| 5 | 5 | ||
| 6 | [[bin]] | ||
| 7 | name = "git-collab" | ||
| 8 | path = "src/main.rs" | ||
| 9 | |||
| 10 | [[bin]] | ||
| 11 | name = "git-collab-server" | ||
| 12 | path = "src/server/main.rs" | ||
| 13 | |||
| 6 | [dependencies] | 14 | [dependencies] |
| 7 | git2 = "0.19" | 15 | git2 = "0.19" |
| 8 | clap = { version = "4", features = ["derive"] } | 16 | clap = { version = "4", features = ["derive"] } |
| @@ -17,6 +25,16 @@ ed25519-dalek = { version = "2", features = ["rand_core"] } | |||
| 17 | rand_core = { version = "0.6", features = ["getrandom"] } | 25 | rand_core = { version = "0.6", features = ["getrandom"] } |
| 18 | base64 = "0.22" | 26 | base64 = "0.22" |
| 19 | dirs = "5" | 27 | dirs = "5" |
| 28 | axum = "0.8" | ||
| 29 | tokio = { version = "1", features = ["full"] } | ||
| 30 | askama = "0.15" | ||
| 31 | askama_web = { version = "0.15", features = ["axum-0.8"] } | ||
| 32 | russh = "0.46" | ||
| 33 | russh-keys = "0.46" | ||
| 34 | toml_edit = { version = "0.22", features = ["serde"] } | ||
| 35 | tracing = "0.1" | ||
| 36 | tracing-subscriber = "0.3" | ||
| 37 | async-trait = "0.1" | ||
| 20 | 38 | ||
| 21 | [build-dependencies] | 39 | [build-dependencies] |
| 22 | clap = { version = "4", features = ["derive"] } | 40 | clap = { version = "4", features = ["derive"] } |
Dockerfile
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,10 @@ | |||
| 1 | FROM rust:1.88-bookworm AS builder | ||
| 2 | WORKDIR /build | ||
| 3 | COPY . . | ||
| 4 | RUN cargo build --release --bin git-collab-server | ||
| 5 | |||
| 6 | FROM debian:bookworm-slim | ||
| 7 | RUN apt-get update && apt-get install -y git libssl3 ca-certificates && rm -rf /var/lib/apt/lists/* | ||
| 8 | COPY --from=builder /build/target/release/git-collab-server /usr/local/bin/ | ||
| 9 | EXPOSE 8080 22 | ||
| 10 | ENTRYPOINT ["git-collab-server"] | ||
Makefile
| Old | New | ||
|---|---|---|---|
| @@ -1,7 +1,8 @@ | |||
| 1 | CARGO := cargo | 1 | CARGO := cargo |
| 2 | PROFILE ?= debug | 2 | PROFILE ?= debug |
| 3 | BIN := git-collab | 3 | BIN := git-collab |
| 4 | PREFIX ?= /usr/local | 4 | SERVER_BIN := git-collab-server |
| 5 | PREFIX ?= /usr/local | ||
| 5 | 6 | ||
| 6 | # --- Building blocks --- | 7 | # --- Building blocks --- |
| 7 | .PHONY: fmt check clippy test build install clean | 8 | .PHONY: fmt check clippy test build install clean |
| @@ -32,9 +33,10 @@ ci: lint test build | |||
| 32 | install: PROFILE := release | 33 | install: PROFILE := release |
| 33 | install: build install-man | 34 | install: build install-man |
| 34 | install -Dm755 target/release/$(BIN) $(PREFIX)/bin/$(BIN) | 35 | install -Dm755 target/release/$(BIN) $(PREFIX)/bin/$(BIN) |
| 36 | install -Dm755 target/release/$(SERVER_BIN) $(PREFIX)/bin/$(SERVER_BIN) | ||
| 35 | 37 | ||
| 36 | uninstall: | 38 | uninstall: |
| 37 | rm -f $(PREFIX)/bin/$(BIN) | 39 | rm -f $(PREFIX)/bin/$(BIN) $(PREFIX)/bin/$(SERVER_BIN) |
| 38 | 40 | ||
| 39 | clean: clean-man | 41 | clean: clean-man |
| 40 | $(CARGO) clean | 42 | $(CARGO) clean |
| @@ -56,11 +58,14 @@ clean-man: | |||
| 56 | rm -rf man/ | 58 | rm -rf man/ |
| 57 | 59 | ||
| 58 | # --- Dev helpers --- | 60 | # --- Dev helpers --- |
| 59 | .PHONY: run dev dashboard | 61 | .PHONY: run serve dev dashboard |
| 60 | 62 | ||
| 61 | run: | 63 | run: |
| 62 | $(CARGO) run -- $(ARGS) | 64 | $(CARGO) run -- $(ARGS) |
| 63 | 65 | ||
| 66 | serve: | ||
| 67 | $(CARGO) run --bin $(SERVER_BIN) -- $(ARGS) | ||
| 68 | |||
| 64 | dev: fmt | 69 | dev: fmt |
| 65 | $(CARGO) run -- $(ARGS) | 70 | $(CARGO) run -- $(ARGS) |
| 66 | 71 | ||
askama.toml
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,2 @@ | |||
| 1 | [general] | ||
| 2 | dirs = ["src/server/http/templates"] | ||
docs/superpowers/plans/2026-03-30-git-collab-server.md
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,3118 @@ | |||
| 1 | # git-collab-server Implementation Plan | ||
| 2 | |||
| 3 | > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | ||
| 4 | |||
| 5 | **Goal:** Build a minimal, self-contained git hosting server binary that serves a directory of repos with a read-only web UI, HTTPS clone, and SSH push/pull. | ||
| 6 | |||
| 7 | **Architecture:** Second `[[bin]]` in the existing workspace sharing the `git-collab` lib crate. axum for HTTP (web UI + git smart HTTP), russh for embedded SSH, askama for compiled HTML templates. Config via TOML file. | ||
| 8 | |||
| 9 | **Tech Stack:** Rust 2021, axum 0.8, tokio 1, askama 0.12, russh 0.44, russh-keys 0.44, toml 0.8, tracing 0.1, git2 0.19 | ||
| 10 | |||
| 11 | --- | ||
| 12 | |||
| 13 | ### Task 1: Project scaffolding — Cargo.toml + minimal server binary | ||
| 14 | |||
| 15 | **Files:** | ||
| 16 | - Modify: `Cargo.toml` | ||
| 17 | - Create: `src/server/main.rs` | ||
| 18 | |||
| 19 | - [ ] **Step 1: Add `[[bin]]` entries and new dependencies to Cargo.toml** | ||
| 20 | |||
| 21 | Add explicit `[[bin]]` for the existing binary and the new server binary. Add server dependencies. | ||
| 22 | |||
| 23 | ```toml | ||
| 24 | [[bin]] | ||
| 25 | name = "git-collab" | ||
| 26 | path = "src/main.rs" | ||
| 27 | |||
| 28 | [[bin]] | ||
| 29 | name = "git-collab-server" | ||
| 30 | path = "src/server/main.rs" | ||
| 31 | ``` | ||
| 32 | |||
| 33 | Add to `[dependencies]`: | ||
| 34 | |||
| 35 | ```toml | ||
| 36 | axum = "0.8" | ||
| 37 | tokio = { version = "1", features = ["full"] } | ||
| 38 | askama = "0.12" | ||
| 39 | askama_axum = "0.12" | ||
| 40 | russh = "0.46" | ||
| 41 | russh-keys = "0.46" | ||
| 42 | toml_edit = "0.22" | ||
| 43 | tracing = "0.1" | ||
| 44 | tracing-subscriber = "0.3" | ||
| 45 | async-trait = "0.1" | ||
| 46 | ``` | ||
| 47 | |||
| 48 | Note: Use `toml_edit` instead of `toml` — lighter for just deserialization via serde. Use `russh` 0.46 (latest stable as of early 2026). Add `askama_axum` for the axum `IntoResponse` integration. Add `async-trait` since russh handler traits require it. | ||
| 49 | |||
| 50 | - [ ] **Step 2: Create minimal server main.rs** | ||
| 51 | |||
| 52 | ```rust | ||
| 53 | // src/server/main.rs | ||
| 54 | fn main() { | ||
| 55 | println!("git-collab-server: not yet implemented"); | ||
| 56 | } | ||
| 57 | ``` | ||
| 58 | |||
| 59 | - [ ] **Step 3: Verify both binaries compile** | ||
| 60 | |||
| 61 | Run: `cargo build` | ||
| 62 | Expected: Both `git-collab` and `git-collab-server` compile without errors. | ||
| 63 | |||
| 64 | - [ ] **Step 4: Verify existing tests still pass** | ||
| 65 | |||
| 66 | Run: `cargo test` | ||
| 67 | Expected: All existing tests pass. No regressions from adding the second binary. | ||
| 68 | |||
| 69 | - [ ] **Step 5: Commit** | ||
| 70 | |||
| 71 | ```bash | ||
| 72 | git add Cargo.toml Cargo.lock src/server/main.rs | ||
| 73 | git commit -m "scaffold git-collab-server binary with new dependencies" | ||
| 74 | ``` | ||
| 75 | |||
| 76 | --- | ||
| 77 | |||
| 78 | ### Task 2: Config module — parse collab-server.toml | ||
| 79 | |||
| 80 | **Files:** | ||
| 81 | - Create: `src/server/config.rs` | ||
| 82 | - Create: `src/server/mod.rs` | ||
| 83 | - Test: inline `#[cfg(test)]` module | ||
| 84 | |||
| 85 | - [ ] **Step 1: Write tests for config parsing** | ||
| 86 | |||
| 87 | Create `src/server/mod.rs`: | ||
| 88 | |||
| 89 | ```rust | ||
| 90 | pub mod config; | ||
| 91 | ``` | ||
| 92 | |||
| 93 | Create `src/server/config.rs`: | ||
| 94 | |||
| 95 | ```rust | ||
| 96 | use std::net::SocketAddr; | ||
| 97 | use std::path::PathBuf; | ||
| 98 | |||
| 99 | use serde::Deserialize; | ||
| 100 | |||
| 101 | #[derive(Debug, Clone, Deserialize)] | ||
| 102 | pub struct ServerConfig { | ||
| 103 | pub repos_dir: PathBuf, | ||
| 104 | #[serde(default = "default_http_bind")] | ||
| 105 | pub http_bind: SocketAddr, | ||
| 106 | #[serde(default = "default_ssh_bind")] | ||
| 107 | pub ssh_bind: SocketAddr, | ||
| 108 | pub authorized_keys: PathBuf, | ||
| 109 | #[serde(default = "default_site_title")] | ||
| 110 | pub site_title: String, | ||
| 111 | } | ||
| 112 | |||
| 113 | fn default_http_bind() -> SocketAddr { | ||
| 114 | "0.0.0.0:8080".parse().unwrap() | ||
| 115 | } | ||
| 116 | |||
| 117 | fn default_ssh_bind() -> SocketAddr { | ||
| 118 | "0.0.0.0:2222".parse().unwrap() | ||
| 119 | } | ||
| 120 | |||
| 121 | fn default_site_title() -> String { | ||
| 122 | "git-collab".to_string() | ||
| 123 | } | ||
| 124 | |||
| 125 | impl ServerConfig { | ||
| 126 | pub fn from_toml(content: &str) -> Result<Self, toml_edit::de::Error> { | ||
| 127 | toml_edit::de::from_str(content) | ||
| 128 | } | ||
| 129 | |||
| 130 | pub fn from_file(path: &std::path::Path) -> Result<Self, Box<dyn std::error::Error>> { | ||
| 131 | let content = std::fs::read_to_string(path)?; | ||
| 132 | Ok(Self::from_toml(&content)?) | ||
| 133 | } | ||
| 134 | } | ||
| 135 | |||
| 136 | #[cfg(test)] | ||
| 137 | mod tests { | ||
| 138 | use super::*; | ||
| 139 | |||
| 140 | #[test] | ||
| 141 | fn parse_full_config() { | ||
| 142 | let toml = r#" | ||
| 143 | repos_dir = "/srv/git" | ||
| 144 | http_bind = "127.0.0.1:3000" | ||
| 145 | ssh_bind = "127.0.0.1:2222" | ||
| 146 | authorized_keys = "/etc/git-collab-server/authorized_keys" | ||
| 147 | site_title = "my repos" | ||
| 148 | "#; | ||
| 149 | let config = ServerConfig::from_toml(toml).unwrap(); | ||
| 150 | assert_eq!(config.repos_dir, PathBuf::from("/srv/git")); | ||
| 151 | assert_eq!(config.http_bind, "127.0.0.1:3000".parse::<SocketAddr>().unwrap()); | ||
| 152 | assert_eq!(config.ssh_bind, "127.0.0.1:2222".parse::<SocketAddr>().unwrap()); | ||
| 153 | assert_eq!(config.authorized_keys, PathBuf::from("/etc/git-collab-server/authorized_keys")); | ||
| 154 | assert_eq!(config.site_title, "my repos"); | ||
| 155 | } | ||
| 156 | |||
| 157 | #[test] | ||
| 158 | fn parse_minimal_config_uses_defaults() { | ||
| 159 | let toml = r#" | ||
| 160 | repos_dir = "/srv/git" | ||
| 161 | authorized_keys = "/keys" | ||
| 162 | "#; | ||
| 163 | let config = ServerConfig::from_toml(toml).unwrap(); | ||
| 164 | assert_eq!(config.repos_dir, PathBuf::from("/srv/git")); | ||
| 165 | assert_eq!(config.http_bind, "0.0.0.0:8080".parse::<SocketAddr>().unwrap()); | ||
| 166 | assert_eq!(config.ssh_bind, "0.0.0.0:2222".parse::<SocketAddr>().unwrap()); | ||
| 167 | assert_eq!(config.site_title, "git-collab"); | ||
| 168 | } | ||
| 169 | |||
| 170 | #[test] | ||
| 171 | fn parse_missing_required_field_fails() { | ||
| 172 | let toml = r#" | ||
| 173 | authorized_keys = "/keys" | ||
| 174 | "#; | ||
| 175 | assert!(ServerConfig::from_toml(toml).is_err()); | ||
| 176 | } | ||
| 177 | } | ||
| 178 | ``` | ||
| 179 | |||
| 180 | - [ ] **Step 2: Run tests to verify they pass** | ||
| 181 | |||
| 182 | Run: `cargo test --lib server::config` | ||
| 183 | Expected: 3 tests pass. | ||
| 184 | |||
| 185 | - [ ] **Step 3: Commit** | ||
| 186 | |||
| 187 | ```bash | ||
| 188 | git add src/server/mod.rs src/server/config.rs | ||
| 189 | git commit -m "add server config module with TOML parsing" | ||
| 190 | ``` | ||
| 191 | |||
| 192 | --- | ||
| 193 | |||
| 194 | ### Task 3: Repo discovery — scan repos_dir for git repos | ||
| 195 | |||
| 196 | **Files:** | ||
| 197 | - Create: `src/server/repos.rs` | ||
| 198 | - Modify: `src/server/mod.rs` | ||
| 199 | |||
| 200 | - [ ] **Step 1: Write tests for repo discovery** | ||
| 201 | |||
| 202 | Add `pub mod repos;` to `src/server/mod.rs`. | ||
| 203 | |||
| 204 | Create `src/server/repos.rs`: | ||
| 205 | |||
| 206 | ```rust | ||
| 207 | use std::path::{Path, PathBuf}; | ||
| 208 | |||
| 209 | /// A discovered git repository on disk. | ||
| 210 | #[derive(Debug, Clone)] | ||
| 211 | pub struct RepoEntry { | ||
| 212 | /// Display name (directory name minus .git suffix). | ||
| 213 | pub name: String, | ||
| 214 | /// Absolute path to the repo (the .git dir for bare repos, or the workdir for non-bare). | ||
| 215 | pub path: PathBuf, | ||
| 216 | /// Whether this is a bare repository. | ||
| 217 | pub bare: bool, | ||
| 218 | } | ||
| 219 | |||
| 220 | /// Scan a directory for git repositories. | ||
| 221 | /// A directory is a repo if it contains a HEAD file (bare) or a .git subdirectory (non-bare). | ||
| 222 | pub fn discover(repos_dir: &Path) -> Result<Vec<RepoEntry>, std::io::Error> { | ||
| 223 | let mut entries = Vec::new(); | ||
| 224 | let read_dir = std::fs::read_dir(repos_dir)?; | ||
| 225 | |||
| 226 | for entry in read_dir { | ||
| 227 | let entry = entry?; | ||
| 228 | let path = entry.path(); | ||
| 229 | if !path.is_dir() { | ||
| 230 | continue; | ||
| 231 | } | ||
| 232 | |||
| 233 | let dir_name = entry.file_name().to_string_lossy().to_string(); | ||
| 234 | |||
| 235 | if path.join("HEAD").is_file() { | ||
| 236 | // Bare repo (or looks like one) | ||
| 237 | let name = dir_name.strip_suffix(".git").unwrap_or(&dir_name).to_string(); | ||
| 238 | entries.push(RepoEntry { | ||
| 239 | name, | ||
| 240 | path, | ||
| 241 | bare: true, | ||
| 242 | }); | ||
| 243 | } else if path.join(".git").is_dir() { | ||
| 244 | // Non-bare repo | ||
| 245 | entries.push(RepoEntry { | ||
| 246 | name: dir_name, | ||
| 247 | path, | ||
| 248 | bare: false, | ||
| 249 | }); | ||
| 250 | } | ||
| 251 | } | ||
| 252 | |||
| 253 | entries.sort_by(|a, b| a.name.cmp(&b.name)); | ||
| 254 | Ok(entries) | ||
| 255 | } | ||
| 256 | |||
| 257 | /// Resolve a repo name from a URL path segment to its on-disk path. | ||
| 258 | /// Returns None if the name doesn't match any discovered repo. | ||
| 259 | pub fn resolve(repos_dir: &Path, name: &str) -> Option<RepoEntry> { | ||
| 260 | let entries = discover(repos_dir).ok()?; | ||
| 261 | entries.into_iter().find(|e| e.name == name) | ||
| 262 | } | ||
| 263 | |||
| 264 | /// Open a git2::Repository from a RepoEntry. | ||
| 265 | pub fn open(entry: &RepoEntry) -> Result<git2::Repository, git2::Error> { | ||
| 266 | if entry.bare { | ||
| 267 | git2::Repository::open_bare(&entry.path) | ||
| 268 | } else { | ||
| 269 | git2::Repository::open(&entry.path) | ||
| 270 | } | ||
| 271 | } | ||
| 272 | |||
| 273 | #[cfg(test)] | ||
| 274 | mod tests { | ||
| 275 | use super::*; | ||
| 276 | use tempfile::TempDir; | ||
| 277 | use std::process::Command; | ||
| 278 | |||
| 279 | fn init_bare(parent: &Path, name: &str) { | ||
| 280 | Command::new("git") | ||
| 281 | .args(["init", "--bare", name]) | ||
| 282 | .current_dir(parent) | ||
| 283 | .output() | ||
| 284 | .expect("git init --bare failed"); | ||
| 285 | } | ||
| 286 | |||
| 287 | fn init_non_bare(parent: &Path, name: &str) { | ||
| 288 | Command::new("git") | ||
| 289 | .args(["init", name]) | ||
| 290 | .current_dir(parent) | ||
| 291 | .output() | ||
| 292 | .expect("git init failed"); | ||
| 293 | } | ||
| 294 | |||
| 295 | #[test] | ||
| 296 | fn discover_bare_repos() { | ||
| 297 | let tmp = TempDir::new().unwrap(); | ||
| 298 | init_bare(tmp.path(), "alpha.git"); | ||
| 299 | init_bare(tmp.path(), "beta.git"); | ||
| 300 | |||
| 301 | let repos = discover(tmp.path()).unwrap(); | ||
| 302 | assert_eq!(repos.len(), 2); | ||
| 303 | assert_eq!(repos[0].name, "alpha"); | ||
| 304 | assert_eq!(repos[1].name, "beta"); | ||
| 305 | assert!(repos[0].bare); | ||
| 306 | } | ||
| 307 | |||
| 308 | #[test] | ||
| 309 | fn discover_non_bare_repos() { | ||
| 310 | let tmp = TempDir::new().unwrap(); | ||
| 311 | init_non_bare(tmp.path(), "myproject"); | ||
| 312 | |||
| 313 | let repos = discover(tmp.path()).unwrap(); | ||
| 314 | assert_eq!(repos.len(), 1); | ||
| 315 | assert_eq!(repos[0].name, "myproject"); | ||
| 316 | assert!(!repos[0].bare); | ||
| 317 | } | ||
| 318 | |||
| 319 | #[test] | ||
| 320 | fn discover_skips_non_repo_dirs() { | ||
| 321 | let tmp = TempDir::new().unwrap(); | ||
| 322 | std::fs::create_dir(tmp.path().join("not-a-repo")).unwrap(); | ||
| 323 | init_bare(tmp.path(), "real.git"); | ||
| 324 | |||
| 325 | let repos = discover(tmp.path()).unwrap(); | ||
| 326 | assert_eq!(repos.len(), 1); | ||
| 327 | assert_eq!(repos[0].name, "real"); | ||
| 328 | } | ||
| 329 | |||
| 330 | #[test] | ||
| 331 | fn resolve_finds_repo_by_name() { | ||
| 332 | let tmp = TempDir::new().unwrap(); | ||
| 333 | init_bare(tmp.path(), "myrepo.git"); | ||
| 334 | |||
| 335 | let entry = resolve(tmp.path(), "myrepo").unwrap(); | ||
| 336 | assert_eq!(entry.name, "myrepo"); | ||
| 337 | } | ||
| 338 | |||
| 339 | #[test] | ||
| 340 | fn resolve_returns_none_for_unknown() { | ||
| 341 | let tmp = TempDir::new().unwrap(); | ||
| 342 | assert!(resolve(tmp.path(), "nope").is_none()); | ||
| 343 | } | ||
| 344 | |||
| 345 | #[test] | ||
| 346 | fn open_bare_repo() { | ||
| 347 | let tmp = TempDir::new().unwrap(); | ||
| 348 | init_bare(tmp.path(), "test.git"); | ||
| 349 | let entry = resolve(tmp.path(), "test").unwrap(); | ||
| 350 | let repo = open(&entry).unwrap(); | ||
| 351 | assert!(repo.is_bare()); | ||
| 352 | } | ||
| 353 | } | ||
| 354 | ``` | ||
| 355 | |||
| 356 | - [ ] **Step 2: Run tests** | ||
| 357 | |||
| 358 | Run: `cargo test --lib server::repos` | ||
| 359 | Expected: 6 tests pass. | ||
| 360 | |||
| 361 | - [ ] **Step 3: Commit** | ||
| 362 | |||
| 363 | ```bash | ||
| 364 | git add src/server/mod.rs src/server/repos.rs | ||
| 365 | git commit -m "add repo discovery module for scanning repos directory" | ||
| 366 | ``` | ||
| 367 | |||
| 368 | --- | ||
| 369 | |||
| 370 | ### Task 4: SSH authorized_keys parser | ||
| 371 | |||
| 372 | **Files:** | ||
| 373 | - Create: `src/server/ssh/mod.rs` | ||
| 374 | - Create: `src/server/ssh/auth.rs` | ||
| 375 | - Modify: `src/server/mod.rs` | ||
| 376 | |||
| 377 | - [ ] **Step 1: Write the authorized_keys parser with tests** | ||
| 378 | |||
| 379 | Add `pub mod ssh;` to `src/server/mod.rs`. | ||
| 380 | |||
| 381 | Create `src/server/ssh/mod.rs`: | ||
| 382 | |||
| 383 | ```rust | ||
| 384 | pub mod auth; | ||
| 385 | ``` | ||
| 386 | |||
| 387 | Create `src/server/ssh/auth.rs`: | ||
| 388 | |||
| 389 | ```rust | ||
| 390 | use std::path::Path; | ||
| 391 | |||
| 392 | /// A parsed entry from the authorized_keys file. | ||
| 393 | #[derive(Debug, Clone)] | ||
| 394 | pub struct AuthorizedKey { | ||
| 395 | /// The key type (e.g., "ssh-ed25519"). | ||
| 396 | pub key_type: String, | ||
| 397 | /// Base64-encoded public key data. | ||
| 398 | pub key_data: String, | ||
| 399 | /// Optional comment (e.g., "user@host"). | ||
| 400 | pub comment: Option<String>, | ||
| 401 | } | ||
| 402 | |||
| 403 | /// Parse an authorized_keys file content into a list of keys. | ||
| 404 | /// Ignores blank lines and lines starting with #. | ||
| 405 | pub fn parse_authorized_keys(content: &str) -> Vec<AuthorizedKey> { | ||
| 406 | content | ||
| 407 | .lines() | ||
| 408 | .filter_map(|line| { | ||
| 409 | let line = line.trim(); | ||
| 410 | if line.is_empty() || line.starts_with('#') { | ||
| 411 | return None; | ||
| 412 | } | ||
| 413 | let mut parts = line.splitn(3, ' '); | ||
| 414 | let key_type = parts.next()?.to_string(); | ||
| 415 | let key_data = parts.next()?.to_string(); | ||
| 416 | let comment = parts.next().map(|s| s.to_string()); | ||
| 417 | Some(AuthorizedKey { | ||
| 418 | key_type, | ||
| 419 | key_data, | ||
| 420 | comment, | ||
| 421 | }) | ||
| 422 | }) | ||
| 423 | .collect() | ||
| 424 | } | ||
| 425 | |||
| 426 | /// Load authorized keys from a file on disk. | ||
| 427 | pub fn load_authorized_keys(path: &Path) -> Result<Vec<AuthorizedKey>, std::io::Error> { | ||
| 428 | let content = std::fs::read_to_string(path)?; | ||
| 429 | Ok(parse_authorized_keys(&content)) | ||
| 430 | } | ||
| 431 | |||
| 432 | /// Check whether a given public key (type + base64 data) is in the authorized list. | ||
| 433 | pub fn is_authorized(keys: &[AuthorizedKey], key_type: &str, key_data: &str) -> bool { | ||
| 434 | keys.iter().any(|k| k.key_type == key_type && k.key_data == key_data) | ||
| 435 | } | ||
| 436 | |||
| 437 | #[cfg(test)] | ||
| 438 | mod tests { | ||
| 439 | use super::*; | ||
| 440 | |||
| 441 | #[test] | ||
| 442 | fn parse_single_key() { | ||
| 443 | let content = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITest user@host\n"; | ||
| 444 | let keys = parse_authorized_keys(content); | ||
| 445 | assert_eq!(keys.len(), 1); | ||
| 446 | assert_eq!(keys[0].key_type, "ssh-ed25519"); | ||
| 447 | assert_eq!(keys[0].key_data, "AAAAC3NzaC1lZDI1NTE5AAAAITest"); | ||
| 448 | assert_eq!(keys[0].comment.as_deref(), Some("user@host")); | ||
| 449 | } | ||
| 450 | |||
| 451 | #[test] | ||
| 452 | fn parse_multiple_keys() { | ||
| 453 | let content = "\ | ||
| 454 | # alice | ||
| 455 | ssh-ed25519 AAAA1111 alice@example.com | ||
| 456 | |||
| 457 | # bob | ||
| 458 | ssh-ed25519 AAAA2222 bob@work | ||
| 459 | "; | ||
| 460 | let keys = parse_authorized_keys(content); | ||
| 461 | assert_eq!(keys.len(), 2); | ||
| 462 | assert_eq!(keys[0].key_data, "AAAA1111"); | ||
| 463 | assert_eq!(keys[1].key_data, "AAAA2222"); | ||
| 464 | } | ||
| 465 | |||
| 466 | #[test] | ||
| 467 | fn skip_comments_and_blanks() { | ||
| 468 | let content = "\ | ||
| 469 | # this is a comment | ||
| 470 | |||
| 471 | # indented comment | ||
| 472 | |||
| 473 | ssh-ed25519 AAAAkey user | ||
| 474 | "; | ||
| 475 | let keys = parse_authorized_keys(content); | ||
| 476 | assert_eq!(keys.len(), 1); | ||
| 477 | } | ||
| 478 | |||
| 479 | #[test] | ||
| 480 | fn key_without_comment() { | ||
| 481 | let content = "ssh-ed25519 AAAAnocomment\n"; | ||
| 482 | let keys = parse_authorized_keys(content); | ||
| 483 | assert_eq!(keys.len(), 1); | ||
| 484 | assert!(keys[0].comment.is_none()); | ||
| 485 | } | ||
| 486 | |||
| 487 | #[test] | ||
| 488 | fn is_authorized_matches() { | ||
| 489 | let keys = parse_authorized_keys("ssh-ed25519 AAAA1111 alice\nssh-ed25519 AAAA2222 bob\n"); | ||
| 490 | assert!(is_authorized(&keys, "ssh-ed25519", "AAAA1111")); | ||
| 491 | assert!(is_authorized(&keys, "ssh-ed25519", "AAAA2222")); | ||
| 492 | assert!(!is_authorized(&keys, "ssh-ed25519", "AAAA9999")); | ||
| 493 | assert!(!is_authorized(&keys, "ssh-rsa", "AAAA1111")); | ||
| 494 | } | ||
| 495 | } | ||
| 496 | ``` | ||
| 497 | |||
| 498 | - [ ] **Step 2: Run tests** | ||
| 499 | |||
| 500 | Run: `cargo test --lib server::ssh::auth` | ||
| 501 | Expected: 5 tests pass. | ||
| 502 | |||
| 503 | - [ ] **Step 3: Commit** | ||
| 504 | |||
| 505 | ```bash | ||
| 506 | git add src/server/ssh/mod.rs src/server/ssh/auth.rs src/server/mod.rs | ||
| 507 | git commit -m "add SSH authorized_keys parser" | ||
| 508 | ``` | ||
| 509 | |||
| 510 | --- | ||
| 511 | |||
| 512 | ### Task 5: Askama base template + repo list template | ||
| 513 | |||
| 514 | **Files:** | ||
| 515 | - Create: `src/server/http/templates/base.html` | ||
| 516 | - Create: `src/server/http/templates/repo_list.html` | ||
| 517 | - Create: `src/server/http/templates/error.html` | ||
| 518 | |||
| 519 | - [ ] **Step 1: Create the base template with sidebar layout** | ||
| 520 | |||
| 521 | Create `src/server/http/templates/base.html`: | ||
| 522 | |||
| 523 | ```html | ||
| 524 | <!DOCTYPE html> | ||
| 525 | <html lang="en"> | ||
| 526 | <head> | ||
| 527 | <meta charset="utf-8"> | ||
| 528 | <meta name="viewport" content="width=device-width, initial-scale=1"> | ||
| 529 | <title>{% block title %}{{ site_title }}{% endblock %}</title> | ||
| 530 | <style> | ||
| 531 | :root { | ||
| 532 | --bg: #0d1117; | ||
| 533 | --bg-secondary: #161b22; | ||
| 534 | --bg-tertiary: #21262d; | ||
| 535 | --border: #30363d; | ||
| 536 | --text: #e6edf3; | ||
| 537 | --text-muted: #8b949e; | ||
| 538 | --text-link: #58a6ff; | ||
| 539 | --green: #3fb950; | ||
| 540 | --red: #f85149; | ||
| 541 | --yellow: #d29922; | ||
| 542 | --font-mono: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace; | ||
| 543 | --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; | ||
| 544 | } | ||
| 545 | * { margin: 0; padding: 0; box-sizing: border-box; } | ||
| 546 | body { background: var(--bg); color: var(--text); font-family: var(--font-sans); font-size: 14px; line-height: 1.5; } | ||
| 547 | a { color: var(--text-link); text-decoration: none; } | ||
| 548 | a:hover { text-decoration: underline; } | ||
| 549 | |||
| 550 | .header { background: var(--bg-secondary); border-bottom: 1px solid var(--border); padding: 8px 16px; display: flex; align-items: center; justify-content: space-between; } | ||
| 551 | .header .site-title { font-size: 16px; font-weight: 600; color: var(--text); } | ||
| 552 | .header .repo-name { font-family: var(--font-mono); color: var(--text-muted); } | ||
| 553 | |||
| 554 | .layout { display: flex; min-height: calc(100vh - 45px - 32px); } | ||
| 555 | |||
| 556 | .sidebar { width: 180px; background: var(--bg-secondary); border-right: 1px solid var(--border); padding: 12px 0; flex-shrink: 0; } | ||
| 557 | .sidebar a { display: block; padding: 6px 16px; color: var(--text-muted); font-size: 13px; } | ||
| 558 | .sidebar a:hover { background: var(--bg-tertiary); color: var(--text); text-decoration: none; } | ||
| 559 | .sidebar a.active { color: var(--text); background: var(--bg-tertiary); border-left: 2px solid var(--text-link); } | ||
| 560 | .sidebar .divider { border-top: 1px solid var(--border); margin: 8px 0; } | ||
| 561 | .sidebar .badge { background: var(--bg-tertiary); border: 1px solid var(--border); border-radius: 10px; padding: 0 6px; font-size: 11px; float: right; } | ||
| 562 | |||
| 563 | .content { flex: 1; padding: 16px 24px; min-width: 0; } | ||
| 564 | |||
| 565 | .footer { background: var(--bg-secondary); border-top: 1px solid var(--border); padding: 8px 16px; text-align: center; color: var(--text-muted); font-size: 12px; } | ||
| 566 | |||
| 567 | pre { font-family: var(--font-mono); font-size: 13px; overflow-x: auto; } | ||
| 568 | code { font-family: var(--font-mono); font-size: 13px; } | ||
| 569 | .mono { font-family: var(--font-mono); } | ||
| 570 | |||
| 571 | table { border-collapse: collapse; width: 100%; } | ||
| 572 | th, td { padding: 6px 12px; text-align: left; border-bottom: 1px solid var(--border); } | ||
| 573 | th { color: var(--text-muted); font-weight: 600; font-size: 12px; text-transform: uppercase; } | ||
| 574 | |||
| 575 | .status-open { color: var(--green); } | ||
| 576 | .status-closed { color: var(--red); } | ||
| 577 | .status-merged { color: var(--yellow); } | ||
| 578 | |||
| 579 | .diff-add { color: var(--green); } | ||
| 580 | .diff-del { color: var(--red); } | ||
| 581 | .diff-hunk { color: var(--text-link); } | ||
| 582 | .diff-file { color: var(--text); font-weight: bold; } | ||
| 583 | </style> | ||
| 584 | </head> | ||
| 585 | <body> | ||
| 586 | <div class="header"> | ||
| 587 | <a href="/" class="site-title">{{ site_title }}</a> | ||
| 588 | {% block header_right %}{% endblock %} | ||
| 589 | </div> | ||
| 590 | {% block body %}{% endblock %} | ||
| 591 | <div class="footer">git-collab-server</div> | ||
| 592 | </body> | ||
| 593 | </html> | ||
| 594 | ``` | ||
| 595 | |||
| 596 | - [ ] **Step 2: Create the repo list template** | ||
| 597 | |||
| 598 | Create `src/server/http/templates/repo_list.html`: | ||
| 599 | |||
| 600 | ```html | ||
| 601 | {% extends "base.html" %} | ||
| 602 | |||
| 603 | {% block body %} | ||
| 604 | <div class="content" style="max-width: 800px; margin: 0 auto; padding-top: 24px;"> | ||
| 605 | <h2 style="margin-bottom: 16px; font-size: 20px;">Repositories</h2> | ||
| 606 | {% if repos.is_empty() %} | ||
| 607 | <p style="color: var(--text-muted);">No repositories found.</p> | ||
| 608 | {% else %} | ||
| 609 | <table> | ||
| 610 | <thead><tr><th>Name</th><th>Description</th><th>Last Commit</th></tr></thead> | ||
| 611 | <tbody> | ||
| 612 | {% for repo in repos %} | ||
| 613 | <tr> | ||
| 614 | <td><a href="/{{ repo.name }}" class="mono">{{ repo.name }}</a></td> | ||
| 615 | <td style="color: var(--text-muted);">{{ repo.description }}</td> | ||
| 616 | <td class="mono" style="color: var(--text-muted); white-space: nowrap;">{{ repo.last_commit }}</td> | ||
| 617 | </tr> | ||
| 618 | {% endfor %} | ||
| 619 | </tbody> | ||
| 620 | </table> | ||
| 621 | {% endif %} | ||
| 622 | </div> | ||
| 623 | {% endblock %} | ||
| 624 | ``` | ||
| 625 | |||
| 626 | - [ ] **Step 3: Create the error template** | ||
| 627 | |||
| 628 | Create `src/server/http/templates/error.html`: | ||
| 629 | |||
| 630 | ```html | ||
| 631 | {% extends "base.html" %} | ||
| 632 | |||
| 633 | {% block title %}{{ status_code }} — {{ site_title }}{% endblock %} | ||
| 634 | |||
| 635 | {% block body %} | ||
| 636 | <div class="content" style="max-width: 600px; margin: 40px auto; text-align: center;"> | ||
| 637 | <h2 style="font-size: 48px; color: var(--text-muted);">{{ status_code }}</h2> | ||
| 638 | <p style="color: var(--text-muted); margin-top: 8px;">{{ message }}</p> | ||
| 639 | </div> | ||
| 640 | {% endblock %} | ||
| 641 | ``` | ||
| 642 | |||
| 643 | - [ ] **Step 4: Commit** | ||
| 644 | |||
| 645 | ```bash | ||
| 646 | git add src/server/http/templates/ | ||
| 647 | git commit -m "add askama base template, repo list, and error templates" | ||
| 648 | ``` | ||
| 649 | |||
| 650 | --- | ||
| 651 | |||
| 652 | ### Task 6: HTTP skeleton — axum router + repo list handler | ||
| 653 | |||
| 654 | **Files:** | ||
| 655 | - Create: `src/server/http/mod.rs` | ||
| 656 | - Create: `src/server/http/repo_list.rs` | ||
| 657 | - Modify: `src/server/mod.rs` | ||
| 658 | - Modify: `src/server/main.rs` | ||
| 659 | |||
| 660 | - [ ] **Step 1: Create the shared app state and router** | ||
| 661 | |||
| 662 | Add `pub mod http;` to `src/server/mod.rs`. | ||
| 663 | |||
| 664 | Create `src/server/http/mod.rs`: | ||
| 665 | |||
| 666 | ```rust | ||
| 667 | pub mod repo_list; | ||
| 668 | |||
| 669 | use std::path::PathBuf; | ||
| 670 | use std::sync::Arc; | ||
| 671 | |||
| 672 | use axum::Router; | ||
| 673 | |||
| 674 | /// Shared state available to all HTTP handlers. | ||
| 675 | #[derive(Debug, Clone)] | ||
| 676 | pub struct AppState { | ||
| 677 | pub repos_dir: PathBuf, | ||
| 678 | pub site_title: String, | ||
| 679 | } | ||
| 680 | |||
| 681 | pub fn router(state: AppState) -> Router { | ||
| 682 | let shared = Arc::new(state); | ||
| 683 | Router::new() | ||
| 684 | .route("/", axum::routing::get(repo_list::handler)) | ||
| 685 | .with_state(shared) | ||
| 686 | } | ||
| 687 | ``` | ||
| 688 | |||
| 689 | - [ ] **Step 2: Create the repo list handler** | ||
| 690 | |||
| 691 | Create `src/server/http/repo_list.rs`: | ||
| 692 | |||
| 693 | ```rust | ||
| 694 | use std::sync::Arc; | ||
| 695 | |||
| 696 | use askama::Template; | ||
| 697 | use axum::extract::State; | ||
| 698 | use axum::response::IntoResponse; | ||
| 699 | |||
| 700 | use super::AppState; | ||
| 701 | use crate::server::repos; | ||
| 702 | |||
| 703 | #[derive(Debug)] | ||
| 704 | pub struct RepoListItem { | ||
| 705 | pub name: String, | ||
| 706 | pub description: String, | ||
| 707 | pub last_commit: String, | ||
| 708 | } | ||
| 709 | |||
| 710 | #[derive(Template)] | ||
| 711 | #[template(path = "repo_list.html")] | ||
| 712 | struct RepoListTemplate { | ||
| 713 | site_title: String, | ||
| 714 | repos: Vec<RepoListItem>, | ||
| 715 | } | ||
| 716 | |||
| 717 | fn repo_description(entry: &repos::RepoEntry) -> String { | ||
| 718 | let desc_path = if entry.bare { | ||
| 719 | entry.path.join("description") | ||
| 720 | } else { | ||
| 721 | entry.path.join(".git").join("description") | ||
| 722 | }; | ||
| 723 | std::fs::read_to_string(desc_path) | ||
| 724 | .ok() | ||
| 725 | .map(|s| s.trim().to_string()) | ||
| 726 | .filter(|s| !s.starts_with("Unnamed repository")) | ||
| 727 | .unwrap_or_default() | ||
| 728 | } | ||
| 729 | |||
| 730 | fn last_commit_date(entry: &repos::RepoEntry) -> String { | ||
| 731 | let repo = match repos::open(entry) { | ||
| 732 | Ok(r) => r, | ||
| 733 | Err(_) => return String::new(), | ||
| 734 | }; | ||
| 735 | let head = match repo.head() { | ||
| 736 | Ok(h) => h, | ||
| 737 | Err(_) => return String::new(), | ||
| 738 | }; | ||
| 739 | let commit = match head.peel_to_commit() { | ||
| 740 | Ok(c) => c, | ||
| 741 | Err(_) => return String::new(), | ||
| 742 | }; | ||
| 743 | let time = commit.time(); | ||
| 744 | let secs = time.seconds(); | ||
| 745 | let dt = chrono::DateTime::from_timestamp(secs, 0); | ||
| 746 | dt.map(|d| d.format("%Y-%m-%d").to_string()) | ||
| 747 | .unwrap_or_default() | ||
| 748 | } | ||
| 749 | |||
| 750 | pub async fn handler(State(state): State<Arc<AppState>>) -> impl IntoResponse { | ||
| 751 | let entries = repos::discover(&state.repos_dir).unwrap_or_default(); | ||
| 752 | let repos: Vec<RepoListItem> = entries | ||
| 753 | .iter() | ||
| 754 | .map(|e| RepoListItem { | ||
| 755 | name: e.name.clone(), | ||
| 756 | description: repo_description(e), | ||
| 757 | last_commit: last_commit_date(e), | ||
| 758 | }) | ||
| 759 | .collect(); | ||
| 760 | |||
| 761 | RepoListTemplate { | ||
| 762 | site_title: state.site_title.clone(), | ||
| 763 | repos, | ||
| 764 | } | ||
| 765 | } | ||
| 766 | ``` | ||
| 767 | |||
| 768 | - [ ] **Step 3: Update main.rs to start the HTTP server** | ||
| 769 | |||
| 770 | Replace `src/server/main.rs`: | ||
| 771 | |||
| 772 | ```rust | ||
| 773 | use std::path::PathBuf; | ||
| 774 | |||
| 775 | use clap::Parser; | ||
| 776 | |||
| 777 | mod config; | ||
| 778 | mod http; | ||
| 779 | mod repos; | ||
| 780 | mod ssh; | ||
| 781 | |||
| 782 | #[derive(Parser)] | ||
| 783 | #[command(name = "git-collab-server", about = "Minimal git hosting server")] | ||
| 784 | struct Args { | ||
| 785 | /// Path to config file | ||
| 786 | #[arg(short, long)] | ||
| 787 | config: PathBuf, | ||
| 788 | } | ||
| 789 | |||
| 790 | #[tokio::main] | ||
| 791 | async fn main() { | ||
| 792 | tracing_subscriber::fmt::init(); | ||
| 793 | |||
| 794 | let args = Args::parse(); | ||
| 795 | let config = match config::ServerConfig::from_file(&args.config) { | ||
| 796 | Ok(c) => c, | ||
| 797 | Err(e) => { | ||
| 798 | eprintln!("error: failed to load config {:?}: {}", args.config, e); | ||
| 799 | std::process::exit(1); | ||
| 800 | } | ||
| 801 | }; | ||
| 802 | |||
| 803 | if !config.repos_dir.is_dir() { | ||
| 804 | eprintln!("error: repos_dir {:?} is not a directory", config.repos_dir); | ||
| 805 | std::process::exit(1); | ||
| 806 | } | ||
| 807 | |||
| 808 | let repo_count = repos::discover(&config.repos_dir) | ||
| 809 | .map(|r| r.len()) | ||
| 810 | .unwrap_or(0); | ||
| 811 | |||
| 812 | tracing::info!( | ||
| 813 | "HTTP listening on {}, serving {} repos from {:?}", | ||
| 814 | config.http_bind, | ||
| 815 | repo_count, | ||
| 816 | config.repos_dir | ||
| 817 | ); | ||
| 818 | |||
| 819 | let app = http::router(http::AppState { | ||
| 820 | repos_dir: config.repos_dir.clone(), | ||
| 821 | site_title: config.site_title.clone(), | ||
| 822 | }); | ||
| 823 | |||
| 824 | let listener = tokio::net::TcpListener::bind(config.http_bind) | ||
| 825 | .await | ||
| 826 | .expect("failed to bind HTTP listener"); | ||
| 827 | |||
| 828 | axum::serve(listener, app).await.expect("HTTP server error"); | ||
| 829 | } | ||
| 830 | ``` | ||
| 831 | |||
| 832 | Note: This `main.rs` uses local `mod` declarations rather than referencing `git_collab::server` because it is the entry point for a separate binary. The modules live in `src/server/` adjacent to `main.rs`. | ||
| 833 | |||
| 834 | - [ ] **Step 4: Set askama config for template directory** | ||
| 835 | |||
| 836 | Create `askama.toml` in the project root (this is where askama looks for config): | ||
| 837 | |||
| 838 | ```toml | ||
| 839 | [general] | ||
| 840 | dirs = ["src/server/http/templates"] | ||
| 841 | ``` | ||
| 842 | |||
| 843 | - [ ] **Step 5: Verify compilation** | ||
| 844 | |||
| 845 | Run: `cargo build` | ||
| 846 | Expected: Compiles. The template path resolution works via askama.toml. | ||
| 847 | |||
| 848 | - [ ] **Step 6: Smoke test — start server with a test config and curl the repo list** | ||
| 849 | |||
| 850 | Create a temporary config and test manually: | ||
| 851 | |||
| 852 | ```bash | ||
| 853 | # Create test setup | ||
| 854 | mkdir -p /tmp/test-repos | ||
| 855 | git init --bare /tmp/test-repos/hello.git | ||
| 856 | echo '[repos]' > /tmp/test-server.toml | ||
| 857 | cat > /tmp/test-server.toml <<'EOF' | ||
| 858 | repos_dir = "/tmp/test-repos" | ||
| 859 | http_bind = "127.0.0.1:18080" | ||
| 860 | ssh_bind = "127.0.0.1:12222" | ||
| 861 | authorized_keys = "/dev/null" | ||
| 862 | EOF | ||
| 863 | |||
| 864 | # Start server in background, curl, then kill | ||
| 865 | cargo run --bin git-collab-server -- --config /tmp/test-server.toml & | ||
| 866 | SERVER_PID=$! | ||
| 867 | sleep 1 | ||
| 868 | curl -s http://127.0.0.1:18080/ | grep -q "hello" && echo "PASS: repo list works" || echo "FAIL" | ||
| 869 | kill $SERVER_PID | ||
| 870 | ``` | ||
| 871 | |||
| 872 | Expected: The HTML response contains "hello". | ||
| 873 | |||
| 874 | - [ ] **Step 7: Commit** | ||
| 875 | |||
| 876 | ```bash | ||
| 877 | git add src/server/ askama.toml | ||
| 878 | git commit -m "add HTTP skeleton with axum router and repo list page" | ||
| 879 | ``` | ||
| 880 | |||
| 881 | --- | ||
| 882 | |||
| 883 | ### Task 7: Repo sidebar template + repo overview page | ||
| 884 | |||
| 885 | **Files:** | ||
| 886 | - Create: `src/server/http/templates/repo_base.html` | ||
| 887 | - Create: `src/server/http/templates/repo_overview.html` | ||
| 888 | - Create: `src/server/http/repo.rs` | ||
| 889 | - Modify: `src/server/http/mod.rs` | ||
| 890 | |||
| 891 | - [ ] **Step 1: Create the repo base template (sidebar layout)** | ||
| 892 | |||
| 893 | Create `src/server/http/templates/repo_base.html`: | ||
| 894 | |||
| 895 | ```html | ||
| 896 | {% extends "base.html" %} | ||
| 897 | |||
| 898 | {% block header_right %} | ||
| 899 | <span class="repo-name">{{ repo_name }}</span> | ||
| 900 | {% endblock %} | ||
| 901 | |||
| 902 | {% block body %} | ||
| 903 | <div class="layout"> | ||
| 904 | <nav class="sidebar"> | ||
| 905 | <a href="/{{ repo_name }}/commits"{% if active_section == "commits" %} class="active"{% endif %}>commits</a> | ||
| 906 | <a href="/{{ repo_name }}/tree"{% if active_section == "tree" %} class="active"{% endif %}>tree</a> | ||
| 907 | <div class="divider"></div> | ||
| 908 | <a href="/{{ repo_name }}/patches"{% if active_section == "patches" %} class="active"{% endif %}>patches <span class="badge">{{ open_patches }}</span></a> | ||
| 909 | <a href="/{{ repo_name }}/issues"{% if active_section == "issues" %} class="active"{% endif %}>issues <span class="badge">{{ open_issues }}</span></a> | ||
| 910 | </nav> | ||
| 911 | <div class="content"> | ||
| 912 | {% block content %}{% endblock %} | ||
| 913 | </div> | ||
| 914 | </div> | ||
| 915 | {% endblock %} | ||
| 916 | ``` | ||
| 917 | |||
| 918 | - [ ] **Step 2: Create the repo overview template** | ||
| 919 | |||
| 920 | Create `src/server/http/templates/repo_overview.html`: | ||
| 921 | |||
| 922 | ```html | ||
| 923 | {% extends "repo_base.html" %} | ||
| 924 | |||
| 925 | {% block content %} | ||
| 926 | <h2 style="margin-bottom: 16px;">{{ repo_name }}</h2> | ||
| 927 | |||
| 928 | <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 16px;"> | ||
| 929 | <div> | ||
| 930 | <h3 style="font-size: 14px; color: var(--text-muted); margin-bottom: 8px; text-transform: uppercase;">Open Patches</h3> | ||
| 931 | {% if patches.is_empty() %} | ||
| 932 | <p style="color: var(--text-muted); font-size: 13px;">None</p> | ||
| 933 | {% else %} | ||
| 934 | {% for p in patches %} | ||
| 935 | <div style="padding: 4px 0; border-bottom: 1px solid var(--border);"> | ||
| 936 | <a href="/{{ repo_name }}/patches/{{ p.id }}">{{ p.title }}</a> | ||
| 937 | <span style="color: var(--text-muted); font-size: 12px;"> by {{ p.author }}</span> | ||
| 938 | </div> | ||
| 939 | {% endfor %} | ||
| 940 | {% endif %} | ||
| 941 | </div> | ||
| 942 | |||
| 943 | <div> | ||
| 944 | <h3 style="font-size: 14px; color: var(--text-muted); margin-bottom: 8px; text-transform: uppercase;">Open Issues</h3> | ||
| 945 | {% if issues.is_empty() %} | ||
| 946 | <p style="color: var(--text-muted); font-size: 13px;">None</p> | ||
| 947 | {% else %} | ||
| 948 | {% for i in issues %} | ||
| 949 | <div style="padding: 4px 0; border-bottom: 1px solid var(--border);"> | ||
| 950 | <a href="/{{ repo_name }}/issues/{{ i.id }}">{{ i.title }}</a> | ||
| 951 | <span style="color: var(--text-muted); font-size: 12px;"> by {{ i.author }}</span> | ||
| 952 | </div> | ||
| 953 | {% endfor %} | ||
| 954 | {% endif %} | ||
| 955 | </div> | ||
| 956 | </div> | ||
| 957 | |||
| 958 | <h3 style="font-size: 14px; color: var(--text-muted); margin-bottom: 8px; text-transform: uppercase;">Recent Commits</h3> | ||
| 959 | {% for c in commits %} | ||
| 960 | <div style="padding: 4px 0; border-bottom: 1px solid var(--border); display: flex; gap: 12px;"> | ||
| 961 | <span class="mono" style="color: var(--text-muted); flex-shrink: 0;">{{ c.short_id }}</span> | ||
| 962 | <span style="flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"> | ||
| 963 | <a href="/{{ repo_name }}/diff/{{ c.id }}">{{ c.summary }}</a> | ||
| 964 | </span> | ||
| 965 | <span style="color: var(--text-muted); font-size: 12px; flex-shrink: 0;">{{ c.author }}</span> | ||
| 966 | <span class="mono" style="color: var(--text-muted); font-size: 12px; flex-shrink: 0;">{{ c.date }}</span> | ||
| 967 | </div> | ||
| 968 | {% endfor %} | ||
| 969 | {% endblock %} | ||
| 970 | ``` | ||
| 971 | |||
| 972 | - [ ] **Step 3: Create the repo handler module** | ||
| 973 | |||
| 974 | Create `src/server/http/repo.rs`: | ||
| 975 | |||
| 976 | ```rust | ||
| 977 | use std::sync::Arc; | ||
| 978 | |||
| 979 | use askama::Template; | ||
| 980 | use axum::extract::{Path, State}; | ||
| 981 | use axum::http::StatusCode; | ||
| 982 | use axum::response::{Html, IntoResponse, Response}; | ||
| 983 | |||
| 984 | use super::AppState; | ||
| 985 | use crate::server::repos; | ||
| 986 | |||
| 987 | // --- Shared helpers --- | ||
| 988 | |||
| 989 | pub fn not_found(site_title: &str) -> Response { | ||
| 990 | let t = ErrorTemplate { | ||
| 991 | site_title: site_title.to_string(), | ||
| 992 | status_code: 404, | ||
| 993 | message: "Repository not found".to_string(), | ||
| 994 | }; | ||
| 995 | (StatusCode::NOT_FOUND, t.into_response()).into_response() | ||
| 996 | } | ||
| 997 | |||
| 998 | pub fn internal_error(site_title: &str, msg: &str) -> Response { | ||
| 999 | let t = ErrorTemplate { | ||
| 1000 | site_title: site_title.to_string(), | ||
| 1001 | status_code: 500, | ||
| 1002 | message: msg.to_string(), | ||
| 1003 | }; | ||
| 1004 | (StatusCode::INTERNAL_SERVER_ERROR, t.into_response()).into_response() | ||
| 1005 | } | ||
| 1006 | |||
| 1007 | #[derive(Template)] | ||
| 1008 | #[template(path = "error.html")] | ||
| 1009 | struct ErrorTemplate { | ||
| 1010 | site_title: String, | ||
| 1011 | status_code: u16, | ||
| 1012 | message: String, | ||
| 1013 | } | ||
| 1014 | |||
| 1015 | // --- Overview structs --- | ||
| 1016 | |||
| 1017 | pub struct OverviewCommit { | ||
| 1018 | pub id: String, | ||
| 1019 | pub short_id: String, | ||
| 1020 | pub summary: String, | ||
| 1021 | pub author: String, | ||
| 1022 | pub date: String, | ||
| 1023 | } | ||
| 1024 | |||
| 1025 | pub struct OverviewPatch { | ||
| 1026 | pub id: String, | ||
| 1027 | pub title: String, | ||
| 1028 | pub author: String, | ||
| 1029 | } | ||
| 1030 | |||
| 1031 | pub struct OverviewIssue { | ||
| 1032 | pub id: String, | ||
| 1033 | pub title: String, | ||
| 1034 | pub author: String, | ||
| 1035 | } | ||
| 1036 | |||
| 1037 | #[derive(Template)] | ||
| 1038 | #[template(path = "repo_overview.html")] | ||
| 1039 | struct OverviewTemplate { | ||
| 1040 | site_title: String, | ||
| 1041 | repo_name: String, | ||
| 1042 | active_section: String, | ||
| 1043 | open_patches: usize, | ||
| 1044 | open_issues: usize, | ||
| 1045 | commits: Vec<OverviewCommit>, | ||
| 1046 | patches: Vec<OverviewPatch>, | ||
| 1047 | issues: Vec<OverviewIssue>, | ||
| 1048 | } | ||
| 1049 | |||
| 1050 | fn recent_commits(repo: &git2::Repository, limit: usize) -> Vec<OverviewCommit> { | ||
| 1051 | let head = match repo.head() { | ||
| 1052 | Ok(h) => h, | ||
| 1053 | Err(_) => return Vec::new(), | ||
| 1054 | }; | ||
| 1055 | let oid = match head.target() { | ||
| 1056 | Some(o) => o, | ||
| 1057 | None => return Vec::new(), | ||
| 1058 | }; | ||
| 1059 | let mut revwalk = match repo.revwalk() { | ||
| 1060 | Ok(r) => r, | ||
| 1061 | Err(_) => return Vec::new(), | ||
| 1062 | }; | ||
| 1063 | let _ = revwalk.push(oid); | ||
| 1064 | revwalk.set_sorting(git2::Sort::TIME).ok(); | ||
| 1065 | |||
| 1066 | revwalk | ||
| 1067 | .take(limit) | ||
| 1068 | .filter_map(|r| r.ok()) | ||
| 1069 | .filter_map(|oid| { | ||
| 1070 | let commit = repo.find_commit(oid).ok()?; | ||
| 1071 | let id = oid.to_string(); | ||
| 1072 | let short_id = id[..8].to_string(); | ||
| 1073 | let summary = commit.summary().unwrap_or("").to_string(); | ||
| 1074 | let author = commit.author().name().unwrap_or("").to_string(); | ||
| 1075 | let time = commit.time(); | ||
| 1076 | let dt = chrono::DateTime::from_timestamp(time.seconds(), 0); | ||
| 1077 | let date = dt.map(|d| d.format("%Y-%m-%d").to_string()).unwrap_or_default(); | ||
| 1078 | Some(OverviewCommit { id, short_id, summary, author, date }) | ||
| 1079 | }) | ||
| 1080 | .collect() | ||
| 1081 | } | ||
| 1082 | |||
| 1083 | pub async fn overview( | ||
| 1084 | Path(repo_name): Path<String>, | ||
| 1085 | State(state): State<Arc<AppState>>, | ||
| 1086 | ) -> Response { | ||
| 1087 | let entry = match repos::resolve(&state.repos_dir, &repo_name) { | ||
| 1088 | Some(e) => e, | ||
| 1089 | None => return not_found(&state.site_title), | ||
| 1090 | }; | ||
| 1091 | let repo = match repos::open(&entry) { | ||
| 1092 | Ok(r) => r, | ||
| 1093 | Err(e) => return internal_error(&state.site_title, &e.to_string()), | ||
| 1094 | }; | ||
| 1095 | |||
| 1096 | let commits = recent_commits(&repo, 20); | ||
| 1097 | |||
| 1098 | // Load collab data | ||
| 1099 | let all_patches = git_collab::state::list_patches(&repo).unwrap_or_default(); | ||
| 1100 | let open_patch_count = all_patches.iter().filter(|p| p.status == git_collab::state::PatchStatus::Open).count(); | ||
| 1101 | let patches: Vec<OverviewPatch> = all_patches | ||
| 1102 | .iter() | ||
| 1103 | .filter(|p| p.status == git_collab::state::PatchStatus::Open) | ||
| 1104 | .take(10) | ||
| 1105 | .map(|p| OverviewPatch { | ||
| 1106 | id: p.id[..8].to_string(), | ||
| 1107 | title: p.title.clone(), | ||
| 1108 | author: p.author.name.clone(), | ||
| 1109 | }) | ||
| 1110 | .collect(); | ||
| 1111 | |||
| 1112 | let all_issues = git_collab::state::list_issues(&repo).unwrap_or_default(); | ||
| 1113 | let open_issue_count = all_issues.iter().filter(|i| i.status == git_collab::state::IssueStatus::Open).count(); | ||
| 1114 | let issues: Vec<OverviewIssue> = all_issues | ||
| 1115 | .iter() | ||
| 1116 | .filter(|i| i.status == git_collab::state::IssueStatus::Open) | ||
| 1117 | .take(10) | ||
| 1118 | .map(|i| OverviewIssue { | ||
| 1119 | id: i.id[..8].to_string(), | ||
| 1120 | title: i.title.clone(), | ||
| 1121 | author: i.author.name.clone(), | ||
| 1122 | }) | ||
| 1123 | .collect(); | ||
| 1124 | |||
| 1125 | OverviewTemplate { | ||
| 1126 | site_title: state.site_title.clone(), | ||
| 1127 | repo_name, | ||
| 1128 | active_section: "overview".to_string(), | ||
| 1129 | open_patches: open_patch_count, | ||
| 1130 | open_issues: open_issue_count, | ||
| 1131 | commits, | ||
| 1132 | patches, | ||
| 1133 | issues, | ||
| 1134 | } | ||
| 1135 | .into_response() | ||
| 1136 | } | ||
| 1137 | ``` | ||
| 1138 | |||
| 1139 | - [ ] **Step 4: Add the overview route to the router** | ||
| 1140 | |||
| 1141 | Update `src/server/http/mod.rs` — add `repo` module and route: | ||
| 1142 | |||
| 1143 | ```rust | ||
| 1144 | pub mod repo; | ||
| 1145 | pub mod repo_list; | ||
| 1146 | |||
| 1147 | use std::path::PathBuf; | ||
| 1148 | use std::sync::Arc; | ||
| 1149 | |||
| 1150 | use axum::Router; | ||
| 1151 | |||
| 1152 | #[derive(Debug, Clone)] | ||
| 1153 | pub struct AppState { | ||
| 1154 | pub repos_dir: PathBuf, | ||
| 1155 | pub site_title: String, | ||
| 1156 | } | ||
| 1157 | |||
| 1158 | pub fn router(state: AppState) -> Router { | ||
| 1159 | let shared = Arc::new(state); | ||
| 1160 | Router::new() | ||
| 1161 | .route("/", axum::routing::get(repo_list::handler)) | ||
| 1162 | .route("/{repo_name}", axum::routing::get(repo::overview)) | ||
| 1163 | .with_state(shared) | ||
| 1164 | } | ||
| 1165 | ``` | ||
| 1166 | |||
| 1167 | - [ ] **Step 5: Verify compilation and smoke test** | ||
| 1168 | |||
| 1169 | Run: `cargo build` | ||
| 1170 | Expected: Compiles. Templates are validated at build time by askama. | ||
| 1171 | |||
| 1172 | - [ ] **Step 6: Commit** | ||
| 1173 | |||
| 1174 | ```bash | ||
| 1175 | git add src/server/http/ | ||
| 1176 | git commit -m "add repo overview page with sidebar navigation" | ||
| 1177 | ``` | ||
| 1178 | |||
| 1179 | --- | ||
| 1180 | |||
| 1181 | ### Task 8: Commits page | ||
| 1182 | |||
| 1183 | **Files:** | ||
| 1184 | - Create: `src/server/http/templates/commits.html` | ||
| 1185 | - Modify: `src/server/http/repo.rs` | ||
| 1186 | - Modify: `src/server/http/mod.rs` | ||
| 1187 | |||
| 1188 | - [ ] **Step 1: Create the commits template** | ||
| 1189 | |||
| 1190 | Create `src/server/http/templates/commits.html`: | ||
| 1191 | |||
| 1192 | ```html | ||
| 1193 | {% extends "repo_base.html" %} | ||
| 1194 | |||
| 1195 | {% block content %} | ||
| 1196 | <h2 style="margin-bottom: 16px;">Commits</h2> | ||
| 1197 | <table> | ||
| 1198 | <thead><tr><th>Hash</th><th>Message</th><th>Author</th><th>Date</th></tr></thead> | ||
| 1199 | <tbody> | ||
| 1200 | {% for c in commits %} | ||
| 1201 | <tr> | ||
| 1202 | <td class="mono"><a href="/{{ repo_name }}/diff/{{ c.id }}">{{ c.short_id }}</a></td> | ||
| 1203 | <td>{{ c.summary }}</td> | ||
| 1204 | <td style="color: var(--text-muted);">{{ c.author }}</td> | ||
| 1205 | <td class="mono" style="color: var(--text-muted); white-space: nowrap;">{{ c.date }}</td> | ||
| 1206 | </tr> | ||
| 1207 | {% endfor %} | ||
| 1208 | </tbody> | ||
| 1209 | </table> | ||
| 1210 | {% endblock %} | ||
| 1211 | ``` | ||
| 1212 | |||
| 1213 | - [ ] **Step 2: Add commits handler to repo.rs** | ||
| 1214 | |||
| 1215 | Append to `src/server/http/repo.rs`: | ||
| 1216 | |||
| 1217 | ```rust | ||
| 1218 | #[derive(Template)] | ||
| 1219 | #[template(path = "commits.html")] | ||
| 1220 | struct CommitsTemplate { | ||
| 1221 | site_title: String, | ||
| 1222 | repo_name: String, | ||
| 1223 | active_section: String, | ||
| 1224 | open_patches: usize, | ||
| 1225 | open_issues: usize, | ||
| 1226 | commits: Vec<OverviewCommit>, | ||
| 1227 | } | ||
| 1228 | |||
| 1229 | pub async fn commits( | ||
| 1230 | Path(repo_name): Path<String>, | ||
| 1231 | State(state): State<Arc<AppState>>, | ||
| 1232 | ) -> Response { | ||
| 1233 | let entry = match repos::resolve(&state.repos_dir, &repo_name) { | ||
| 1234 | Some(e) => e, | ||
| 1235 | None => return not_found(&state.site_title), | ||
| 1236 | }; | ||
| 1237 | let repo = match repos::open(&entry) { | ||
| 1238 | Ok(r) => r, | ||
| 1239 | Err(e) => return internal_error(&state.site_title, &e.to_string()), | ||
| 1240 | }; | ||
| 1241 | |||
| 1242 | let commits = recent_commits(&repo, 200); | ||
| 1243 | let open_patches = git_collab::state::list_patches(&repo).unwrap_or_default() | ||
| 1244 | .iter().filter(|p| p.status == git_collab::state::PatchStatus::Open).count(); | ||
| 1245 | let open_issues = git_collab::state::list_issues(&repo).unwrap_or_default() | ||
| 1246 | .iter().filter(|i| i.status == git_collab::state::IssueStatus::Open).count(); | ||
| 1247 | |||
| 1248 | CommitsTemplate { | ||
| 1249 | site_title: state.site_title.clone(), | ||
| 1250 | repo_name, | ||
| 1251 | active_section: "commits".to_string(), | ||
| 1252 | open_patches, | ||
| 1253 | open_issues, | ||
| 1254 | commits, | ||
| 1255 | } | ||
| 1256 | .into_response() | ||
| 1257 | } | ||
| 1258 | ``` | ||
| 1259 | |||
| 1260 | - [ ] **Step 3: Add route** | ||
| 1261 | |||
| 1262 | Add to `src/server/http/mod.rs` router: | ||
| 1263 | |||
| 1264 | ```rust | ||
| 1265 | .route("/{repo_name}/commits", axum::routing::get(repo::commits)) | ||
| 1266 | ``` | ||
| 1267 | |||
| 1268 | - [ ] **Step 4: Verify compilation** | ||
| 1269 | |||
| 1270 | Run: `cargo build` | ||
| 1271 | Expected: Compiles. | ||
| 1272 | |||
| 1273 | - [ ] **Step 5: Commit** | ||
| 1274 | |||
| 1275 | ```bash | ||
| 1276 | git add src/server/http/ | ||
| 1277 | git commit -m "add commits page" | ||
| 1278 | ``` | ||
| 1279 | |||
| 1280 | --- | ||
| 1281 | |||
| 1282 | ### Task 9: Tree and blob pages (file browser) | ||
| 1283 | |||
| 1284 | **Files:** | ||
| 1285 | - Create: `src/server/http/templates/tree.html` | ||
| 1286 | - Create: `src/server/http/templates/blob.html` | ||
| 1287 | - Modify: `src/server/http/repo.rs` | ||
| 1288 | - Modify: `src/server/http/mod.rs` | ||
| 1289 | |||
| 1290 | - [ ] **Step 1: Create tree template** | ||
| 1291 | |||
| 1292 | Create `src/server/http/templates/tree.html`: | ||
| 1293 | |||
| 1294 | ```html | ||
| 1295 | {% extends "repo_base.html" %} | ||
| 1296 | |||
| 1297 | {% block content %} | ||
| 1298 | <h2 style="margin-bottom: 4px;">{{ path_display }}</h2> | ||
| 1299 | <p style="color: var(--text-muted); margin-bottom: 16px; font-size: 12px;">ref: {{ ref_name }}</p> | ||
| 1300 | <table> | ||
| 1301 | <thead><tr><th>Name</th><th>Type</th></tr></thead> | ||
| 1302 | <tbody> | ||
| 1303 | {% if show_parent %} | ||
| 1304 | <tr> | ||
| 1305 | <td><a href="/{{ repo_name }}/tree/{{ ref_name }}/{{ parent_path }}">..</a></td> | ||
| 1306 | <td style="color: var(--text-muted);">directory</td> | ||
| 1307 | </tr> | ||
| 1308 | {% endif %} | ||
| 1309 | {% for entry in entries %} | ||
| 1310 | <tr> | ||
| 1311 | <td> | ||
| 1312 | {% if entry.is_dir %} | ||
| 1313 | <a href="/{{ repo_name }}/tree/{{ ref_name }}/{{ entry.full_path }}">{{ entry.name }}/</a> | ||
| 1314 | {% else %} | ||
| 1315 | <a href="/{{ repo_name }}/blob/{{ ref_name }}/{{ entry.full_path }}">{{ entry.name }}</a> | ||
| 1316 | {% endif %} | ||
| 1317 | </td> | ||
| 1318 | <td style="color: var(--text-muted);">{% if entry.is_dir %}directory{% else %}file{% endif %}</td> | ||
| 1319 | </tr> | ||
| 1320 | {% endfor %} | ||
| 1321 | </tbody> | ||
| 1322 | </table> | ||
| 1323 | {% endblock %} | ||
| 1324 | ``` | ||
| 1325 | |||
| 1326 | - [ ] **Step 2: Create blob template** | ||
| 1327 | |||
| 1328 | Create `src/server/http/templates/blob.html`: | ||
| 1329 | |||
| 1330 | ```html | ||
| 1331 | {% extends "repo_base.html" %} | ||
| 1332 | |||
| 1333 | {% block content %} | ||
| 1334 | <h2 style="margin-bottom: 4px;">{{ file_path }}</h2> | ||
| 1335 | <p style="color: var(--text-muted); margin-bottom: 16px; font-size: 12px;"> | ||
| 1336 | ref: {{ ref_name }} · {{ size_display }} | ||
| 1337 | </p> | ||
| 1338 | <div style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 4px; padding: 12px; overflow-x: auto;"> | ||
| 1339 | {% if is_binary %} | ||
| 1340 | <p style="color: var(--text-muted);">Binary file ({{ size_display }})</p> | ||
| 1341 | {% else %} | ||
| 1342 | <pre>{{ content }}</pre> | ||
| 1343 | {% endif %} | ||
| 1344 | </div> | ||
| 1345 | {% endblock %} | ||
| 1346 | ``` | ||
| 1347 | |||
| 1348 | - [ ] **Step 3: Add tree and blob handlers to repo.rs** | ||
| 1349 | |||
| 1350 | Append to `src/server/http/repo.rs`: | ||
| 1351 | |||
| 1352 | ```rust | ||
| 1353 | pub struct TreeEntry { | ||
| 1354 | pub name: String, | ||
| 1355 | pub full_path: String, | ||
| 1356 | pub is_dir: bool, | ||
| 1357 | } | ||
| 1358 | |||
| 1359 | #[derive(Template)] | ||
| 1360 | #[template(path = "tree.html")] | ||
| 1361 | struct TreeTemplate { | ||
| 1362 | site_title: String, | ||
| 1363 | repo_name: String, | ||
| 1364 | active_section: String, | ||
| 1365 | open_patches: usize, | ||
| 1366 | open_issues: usize, | ||
| 1367 | ref_name: String, | ||
| 1368 | path_display: String, | ||
| 1369 | show_parent: bool, | ||
| 1370 | parent_path: String, | ||
| 1371 | entries: Vec<TreeEntry>, | ||
| 1372 | } | ||
| 1373 | |||
| 1374 | #[derive(Template)] | ||
| 1375 | #[template(path = "blob.html")] | ||
| 1376 | struct BlobTemplate { | ||
| 1377 | site_title: String, | ||
| 1378 | repo_name: String, | ||
| 1379 | active_section: String, | ||
| 1380 | open_patches: usize, | ||
| 1381 | open_issues: usize, | ||
| 1382 | ref_name: String, | ||
| 1383 | file_path: String, | ||
| 1384 | content: String, | ||
| 1385 | is_binary: bool, | ||
| 1386 | size_display: String, | ||
| 1387 | } | ||
| 1388 | |||
| 1389 | fn collab_counts(repo: &git2::Repository) -> (usize, usize) { | ||
| 1390 | let open_patches = git_collab::state::list_patches(repo).unwrap_or_default() | ||
| 1391 | .iter().filter(|p| p.status == git_collab::state::PatchStatus::Open).count(); | ||
| 1392 | let open_issues = git_collab::state::list_issues(repo).unwrap_or_default() | ||
| 1393 | .iter().filter(|i| i.status == git_collab::state::IssueStatus::Open).count(); | ||
| 1394 | (open_patches, open_issues) | ||
| 1395 | } | ||
| 1396 | |||
| 1397 | fn resolve_tree_at_path( | ||
| 1398 | repo: &git2::Repository, | ||
| 1399 | ref_name: &str, | ||
| 1400 | path: &str, | ||
| 1401 | ) -> Result<git2::Tree<'_>, git2::Error> { | ||
| 1402 | let obj = repo.revparse_single(ref_name)?; | ||
| 1403 | let commit = obj.peel_to_commit()?; | ||
| 1404 | let root_tree = commit.tree()?; | ||
| 1405 | |||
| 1406 | if path.is_empty() { | ||
| 1407 | Ok(root_tree) | ||
| 1408 | } else { | ||
| 1409 | let entry = root_tree.get_path(std::path::Path::new(path))?; | ||
| 1410 | let obj = entry.to_object(repo)?; | ||
| 1411 | obj.peel_to_tree().map_err(|_| git2::Error::from_str("not a directory")) | ||
| 1412 | } | ||
| 1413 | } | ||
| 1414 | |||
| 1415 | pub async fn tree( | ||
| 1416 | Path((repo_name, rest)): Path<(String, String)>, | ||
| 1417 | State(state): State<Arc<AppState>>, | ||
| 1418 | ) -> Response { | ||
| 1419 | let entry = match repos::resolve(&state.repos_dir, &repo_name) { | ||
| 1420 | Some(e) => e, | ||
| 1421 | None => return not_found(&state.site_title), | ||
| 1422 | }; | ||
| 1423 | let repo = match repos::open(&entry) { | ||
| 1424 | Ok(r) => r, | ||
| 1425 | Err(e) => return internal_error(&state.site_title, &e.to_string()), | ||
| 1426 | }; | ||
| 1427 | |||
| 1428 | // Parse ref/path — first segment is the ref, rest is the path | ||
| 1429 | let (ref_name, path) = split_ref_path(&rest); | ||
| 1430 | let (open_patches, open_issues) = collab_counts(&repo); | ||
| 1431 | |||
| 1432 | let tree = match resolve_tree_at_path(&repo, &ref_name, &path) { | ||
| 1433 | Ok(t) => t, | ||
| 1434 | Err(e) => return internal_error(&state.site_title, &e.to_string()), | ||
| 1435 | }; | ||
| 1436 | |||
| 1437 | let mut entries: Vec<TreeEntry> = tree | ||
| 1438 | .iter() | ||
| 1439 | .map(|e| { | ||
| 1440 | let name = e.name().unwrap_or("").to_string(); | ||
| 1441 | let full_path = if path.is_empty() { | ||
| 1442 | name.clone() | ||
| 1443 | } else { | ||
| 1444 | format!("{}/{}", path, name) | ||
| 1445 | }; | ||
| 1446 | let is_dir = e.kind() == Some(git2::ObjectType::Tree); | ||
| 1447 | TreeEntry { name, full_path, is_dir } | ||
| 1448 | }) | ||
| 1449 | .collect(); | ||
| 1450 | entries.sort_by(|a, b| match (a.is_dir, b.is_dir) { | ||
| 1451 | (true, false) => std::cmp::Ordering::Less, | ||
| 1452 | (false, true) => std::cmp::Ordering::Greater, | ||
| 1453 | _ => a.name.cmp(&b.name), | ||
| 1454 | }); | ||
| 1455 | |||
| 1456 | let path_display = if path.is_empty() { "/".to_string() } else { format!("/{}", path) }; | ||
| 1457 | let show_parent = !path.is_empty(); | ||
| 1458 | let parent_path = path.rsplit_once('/').map(|(p, _)| p.to_string()).unwrap_or_default(); | ||
| 1459 | |||
| 1460 | TreeTemplate { | ||
| 1461 | site_title: state.site_title.clone(), | ||
| 1462 | repo_name, | ||
| 1463 | active_section: "tree".to_string(), | ||
| 1464 | open_patches, | ||
| 1465 | open_issues, | ||
| 1466 | ref_name, | ||
| 1467 | path_display, | ||
| 1468 | show_parent, | ||
| 1469 | parent_path, | ||
| 1470 | entries, | ||
| 1471 | } | ||
| 1472 | .into_response() | ||
| 1473 | } | ||
| 1474 | |||
| 1475 | pub async fn tree_root( | ||
| 1476 | Path(repo_name): Path<String>, | ||
| 1477 | State(state): State<Arc<AppState>>, | ||
| 1478 | ) -> Response { | ||
| 1479 | tree(Path((repo_name, "HEAD".to_string())), State(state)).await | ||
| 1480 | } | ||
| 1481 | |||
| 1482 | pub async fn blob( | ||
| 1483 | Path((repo_name, rest)): Path<(String, String)>, | ||
| 1484 | State(state): State<Arc<AppState>>, | ||
| 1485 | ) -> Response { | ||
| 1486 | let entry = match repos::resolve(&state.repos_dir, &repo_name) { | ||
| 1487 | Some(e) => e, | ||
| 1488 | None => return not_found(&state.site_title), | ||
| 1489 | }; | ||
| 1490 | let repo = match repos::open(&entry) { | ||
| 1491 | Ok(r) => r, | ||
| 1492 | Err(e) => return internal_error(&state.site_title, &e.to_string()), | ||
| 1493 | }; | ||
| 1494 | |||
| 1495 | let (ref_name, file_path) = split_ref_path(&rest); | ||
| 1496 | if file_path.is_empty() { | ||
| 1497 | return not_found(&state.site_title); | ||
| 1498 | } | ||
| 1499 | |||
| 1500 | let (open_patches, open_issues) = collab_counts(&repo); | ||
| 1501 | |||
| 1502 | let obj = match repo.revparse_single(&ref_name) { | ||
| 1503 | Ok(o) => o, | ||
| 1504 | Err(e) => return internal_error(&state.site_title, &e.to_string()), | ||
| 1505 | }; | ||
| 1506 | let commit = match obj.peel_to_commit() { | ||
| 1507 | Ok(c) => c, | ||
| 1508 | Err(e) => return internal_error(&state.site_title, &e.to_string()), | ||
| 1509 | }; | ||
| 1510 | let root_tree = match commit.tree() { | ||
| 1511 | Ok(t) => t, | ||
| 1512 | Err(e) => return internal_error(&state.site_title, &e.to_string()), | ||
| 1513 | }; | ||
| 1514 | let tree_entry = match root_tree.get_path(std::path::Path::new(&file_path)) { | ||
| 1515 | Ok(e) => e, | ||
| 1516 | Err(_) => return not_found(&state.site_title), | ||
| 1517 | }; | ||
| 1518 | let blob_obj = match tree_entry.to_object(&repo) { | ||
| 1519 | Ok(o) => o, | ||
| 1520 | Err(e) => return internal_error(&state.site_title, &e.to_string()), | ||
| 1521 | }; | ||
| 1522 | let blob = match blob_obj.as_blob() { | ||
| 1523 | Some(b) => b, | ||
| 1524 | None => return not_found(&state.site_title), | ||
| 1525 | }; | ||
| 1526 | |||
| 1527 | let is_binary = blob.is_binary(); | ||
| 1528 | let size = blob.size(); | ||
| 1529 | let size_display = if size < 1024 { | ||
| 1530 | format!("{} B", size) | ||
| 1531 | } else if size < 1024 * 1024 { | ||
| 1532 | format!("{:.1} KB", size as f64 / 1024.0) | ||
| 1533 | } else { | ||
| 1534 | format!("{:.1} MB", size as f64 / (1024.0 * 1024.0)) | ||
| 1535 | }; | ||
| 1536 | |||
| 1537 | let content = if is_binary { | ||
| 1538 | String::new() | ||
| 1539 | } else { | ||
| 1540 | String::from_utf8_lossy(blob.content()).to_string() | ||
| 1541 | }; | ||
| 1542 | |||
| 1543 | BlobTemplate { | ||
| 1544 | site_title: state.site_title.clone(), | ||
| 1545 | repo_name, | ||
| 1546 | active_section: "tree".to_string(), | ||
| 1547 | open_patches, | ||
| 1548 | open_issues, | ||
| 1549 | ref_name, | ||
| 1550 | file_path, | ||
| 1551 | content, | ||
| 1552 | is_binary, | ||
| 1553 | size_display, | ||
| 1554 | } | ||
| 1555 | .into_response() | ||
| 1556 | } | ||
| 1557 | |||
| 1558 | /// Split "ref/path/to/file" into (ref, path). | ||
| 1559 | /// Tries to find a valid ref by progressively consuming segments. | ||
| 1560 | /// Falls back to treating the first segment as the ref. | ||
| 1561 | fn split_ref_path(input: &str) -> (String, String) { | ||
| 1562 | let input = input.trim_start_matches('/'); | ||
| 1563 | if input.is_empty() { | ||
| 1564 | return ("HEAD".to_string(), String::new()); | ||
| 1565 | } | ||
| 1566 | |||
| 1567 | // Simple split: first segment is ref, rest is path | ||
| 1568 | if let Some((first, rest)) = input.split_once('/') { | ||
| 1569 | (first.to_string(), rest.to_string()) | ||
| 1570 | } else { | ||
| 1571 | (input.to_string(), String::new()) | ||
| 1572 | } | ||
| 1573 | } | ||
| 1574 | ``` | ||
| 1575 | |||
| 1576 | - [ ] **Step 4: Add routes** | ||
| 1577 | |||
| 1578 | Add to `src/server/http/mod.rs` router: | ||
| 1579 | |||
| 1580 | ```rust | ||
| 1581 | .route("/{repo_name}/tree", axum::routing::get(repo::tree_root)) | ||
| 1582 | .route("/{repo_name}/tree/{*rest}", axum::routing::get(repo::tree)) | ||
| 1583 | .route("/{repo_name}/blob/{*rest}", axum::routing::get(repo::blob)) | ||
| 1584 | ``` | ||
| 1585 | |||
| 1586 | - [ ] **Step 5: Verify compilation** | ||
| 1587 | |||
| 1588 | Run: `cargo build` | ||
| 1589 | Expected: Compiles. | ||
| 1590 | |||
| 1591 | - [ ] **Step 6: Commit** | ||
| 1592 | |||
| 1593 | ```bash | ||
| 1594 | git add src/server/http/ | ||
| 1595 | git commit -m "add tree and blob file browser pages" | ||
| 1596 | ``` | ||
| 1597 | |||
| 1598 | --- | ||
| 1599 | |||
| 1600 | ### Task 10: Diff page (single commit diff) | ||
| 1601 | |||
| 1602 | **Files:** | ||
| 1603 | - Create: `src/server/http/templates/diff.html` | ||
| 1604 | - Modify: `src/server/http/repo.rs` | ||
| 1605 | - Modify: `src/server/http/mod.rs` | ||
| 1606 | |||
| 1607 | - [ ] **Step 1: Create diff template** | ||
| 1608 | |||
| 1609 | Create `src/server/http/templates/diff.html`: | ||
| 1610 | |||
| 1611 | ```html | ||
| 1612 | {% extends "repo_base.html" %} | ||
| 1613 | |||
| 1614 | {% block content %} | ||
| 1615 | <h2 style="margin-bottom: 4px; font-family: var(--font-mono);">{{ short_id }}</h2> | ||
| 1616 | <p style="margin-bottom: 4px;">{{ summary }}</p> | ||
| 1617 | <p style="color: var(--text-muted); font-size: 12px; margin-bottom: 16px;"> | ||
| 1618 | {{ author }} · {{ date }} | ||
| 1619 | </p> | ||
| 1620 | {% if !body.is_empty() %} | ||
| 1621 | <div style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 4px; padding: 12px; margin-bottom: 16px;"> | ||
| 1622 | <pre>{{ body }}</pre> | ||
| 1623 | </div> | ||
| 1624 | {% endif %} | ||
| 1625 | <div style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 4px; padding: 12px; overflow-x: auto;"> | ||
| 1626 | <pre>{% for line in diff_lines %}{% if line.kind == "add" %}<span class="diff-add">{{ line.text }}</span> | ||
| 1627 | {% else if line.kind == "del" %}<span class="diff-del">{{ line.text }}</span> | ||
| 1628 | {% else if line.kind == "hunk" %}<span class="diff-hunk">{{ line.text }}</span> | ||
| 1629 | {% else if line.kind == "file" %}<span class="diff-file">{{ line.text }}</span> | ||
| 1630 | {% else %}{{ line.text }} | ||
| 1631 | {% endif %}{% endfor %}</pre> | ||
| 1632 | </div> | ||
| 1633 | {% endblock %} | ||
| 1634 | ``` | ||
| 1635 | |||
| 1636 | - [ ] **Step 2: Add diff handler to repo.rs** | ||
| 1637 | |||
| 1638 | Append to `src/server/http/repo.rs`: | ||
| 1639 | |||
| 1640 | ```rust | ||
| 1641 | pub struct DiffLine { | ||
| 1642 | pub kind: String, | ||
| 1643 | pub text: String, | ||
| 1644 | } | ||
| 1645 | |||
| 1646 | #[derive(Template)] | ||
| 1647 | #[template(path = "diff.html")] | ||
| 1648 | struct DiffTemplate { | ||
| 1649 | site_title: String, | ||
| 1650 | repo_name: String, | ||
| 1651 | active_section: String, | ||
| 1652 | open_patches: usize, | ||
| 1653 | open_issues: usize, | ||
| 1654 | short_id: String, | ||
| 1655 | summary: String, | ||
| 1656 | body: String, | ||
| 1657 | author: String, | ||
| 1658 | date: String, | ||
| 1659 | diff_lines: Vec<DiffLine>, | ||
| 1660 | } | ||
| 1661 | |||
| 1662 | fn compute_diff_lines(repo: &git2::Repository, commit: &git2::Commit) -> Vec<DiffLine> { | ||
| 1663 | let tree = match commit.tree() { | ||
| 1664 | Ok(t) => t, | ||
| 1665 | Err(_) => return Vec::new(), | ||
| 1666 | }; | ||
| 1667 | let parent_tree = commit | ||
| 1668 | .parent(0) | ||
| 1669 | .ok() | ||
| 1670 | .and_then(|p| p.tree().ok()); | ||
| 1671 | |||
| 1672 | let diff = match repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), None) { | ||
| 1673 | Ok(d) => d, | ||
| 1674 | Err(_) => return Vec::new(), | ||
| 1675 | }; | ||
| 1676 | |||
| 1677 | let mut lines = Vec::new(); | ||
| 1678 | let _ = diff.print(git2::DiffFormat::Patch, |delta, _hunk, line| { | ||
| 1679 | let text = String::from_utf8_lossy(line.content()).to_string(); | ||
| 1680 | let text = text.trim_end_matches('\n').to_string(); | ||
| 1681 | let kind = match line.origin() { | ||
| 1682 | '+' => "add", | ||
| 1683 | '-' => "del", | ||
| 1684 | 'H' => "hunk", | ||
| 1685 | 'F' => "file", | ||
| 1686 | _ => "ctx", | ||
| 1687 | }; | ||
| 1688 | // For file header lines, include the delta path | ||
| 1689 | if line.origin() == 'F' || line.origin() == 'H' { | ||
| 1690 | lines.push(DiffLine { kind: kind.to_string(), text }); | ||
| 1691 | } else { | ||
| 1692 | let prefix = if line.origin() == '+' || line.origin() == '-' { | ||
| 1693 | line.origin().to_string() | ||
| 1694 | } else { | ||
| 1695 | " ".to_string() | ||
| 1696 | }; | ||
| 1697 | lines.push(DiffLine { kind: kind.to_string(), text: format!("{}{}", prefix, text) }); | ||
| 1698 | } | ||
| 1699 | true | ||
| 1700 | }); | ||
| 1701 | lines | ||
| 1702 | } | ||
| 1703 | |||
| 1704 | pub async fn diff( | ||
| 1705 | Path((repo_name, oid_str)): Path<(String, String)>, | ||
| 1706 | State(state): State<Arc<AppState>>, | ||
| 1707 | ) -> Response { | ||
| 1708 | let entry = match repos::resolve(&state.repos_dir, &repo_name) { | ||
| 1709 | Some(e) => e, | ||
| 1710 | None => return not_found(&state.site_title), | ||
| 1711 | }; | ||
| 1712 | let repo = match repos::open(&entry) { | ||
| 1713 | Ok(r) => r, | ||
| 1714 | Err(e) => return internal_error(&state.site_title, &e.to_string()), | ||
| 1715 | }; | ||
| 1716 | |||
| 1717 | let oid = match git2::Oid::from_str(&oid_str) { | ||
| 1718 | Ok(o) => o, | ||
| 1719 | Err(_) => return not_found(&state.site_title), | ||
| 1720 | }; | ||
| 1721 | let commit = match repo.find_commit(oid) { | ||
| 1722 | Ok(c) => c, | ||
| 1723 | Err(_) => return not_found(&state.site_title), | ||
| 1724 | }; | ||
| 1725 | |||
| 1726 | let (open_patches, open_issues) = collab_counts(&repo); | ||
| 1727 | let id = oid.to_string(); | ||
| 1728 | let short_id = id[..8].to_string(); | ||
| 1729 | let summary = commit.summary().unwrap_or("").to_string(); | ||
| 1730 | let body = commit.body().unwrap_or("").to_string(); | ||
| 1731 | let author = commit.author().name().unwrap_or("").to_string(); | ||
| 1732 | let time = commit.time(); | ||
| 1733 | let dt = chrono::DateTime::from_timestamp(time.seconds(), 0); | ||
| 1734 | let date = dt.map(|d| d.format("%Y-%m-%d %H:%M").to_string()).unwrap_or_default(); | ||
| 1735 | let diff_lines = compute_diff_lines(&repo, &commit); | ||
| 1736 | |||
| 1737 | DiffTemplate { | ||
| 1738 | site_title: state.site_title.clone(), | ||
| 1739 | repo_name, | ||
| 1740 | active_section: "commits".to_string(), | ||
| 1741 | open_patches, | ||
| 1742 | open_issues, | ||
| 1743 | short_id, | ||
| 1744 | summary, | ||
| 1745 | body, | ||
| 1746 | author, | ||
| 1747 | date, | ||
| 1748 | diff_lines, | ||
| 1749 | } | ||
| 1750 | .into_response() | ||
| 1751 | } | ||
| 1752 | ``` | ||
| 1753 | |||
| 1754 | - [ ] **Step 3: Add route** | ||
| 1755 | |||
| 1756 | Add to router: | ||
| 1757 | |||
| 1758 | ```rust | ||
| 1759 | .route("/{repo_name}/diff/{oid}", axum::routing::get(repo::diff)) | ||
| 1760 | ``` | ||
| 1761 | |||
| 1762 | - [ ] **Step 4: Verify compilation** | ||
| 1763 | |||
| 1764 | Run: `cargo build` | ||
| 1765 | Expected: Compiles. | ||
| 1766 | |||
| 1767 | - [ ] **Step 5: Commit** | ||
| 1768 | |||
| 1769 | ```bash | ||
| 1770 | git add src/server/http/ | ||
| 1771 | git commit -m "add commit diff page" | ||
| 1772 | ``` | ||
| 1773 | |||
| 1774 | --- | ||
| 1775 | |||
| 1776 | ### Task 11: Patches list and detail pages | ||
| 1777 | |||
| 1778 | **Files:** | ||
| 1779 | - Create: `src/server/http/templates/patches.html` | ||
| 1780 | - Create: `src/server/http/templates/patch_detail.html` | ||
| 1781 | - Modify: `src/server/http/repo.rs` | ||
| 1782 | - Modify: `src/server/http/mod.rs` | ||
| 1783 | |||
| 1784 | - [ ] **Step 1: Create patches list template** | ||
| 1785 | |||
| 1786 | Create `src/server/http/templates/patches.html`: | ||
| 1787 | |||
| 1788 | ```html | ||
| 1789 | {% extends "repo_base.html" %} | ||
| 1790 | |||
| 1791 | {% block content %} | ||
| 1792 | <h2 style="margin-bottom: 16px;">Patches</h2> | ||
| 1793 | {% if patches.is_empty() %} | ||
| 1794 | <p style="color: var(--text-muted);">No patches found.</p> | ||
| 1795 | {% else %} | ||
| 1796 | <table> | ||
| 1797 | <thead><tr><th>ID</th><th>Status</th><th>Title</th><th>Author</th><th>Branch</th><th>Updated</th></tr></thead> | ||
| 1798 | <tbody> | ||
| 1799 | {% for p in patches %} | ||
| 1800 | <tr> | ||
| 1801 | <td class="mono"><a href="/{{ repo_name }}/patches/{{ p.id }}">{{ p.short_id }}</a></td> | ||
| 1802 | <td><span class="status-{{ p.status }}">{{ p.status }}</span></td> | ||
| 1803 | <td>{{ p.title }}</td> | ||
| 1804 | <td style="color: var(--text-muted);">{{ p.author }}</td> | ||
| 1805 | <td class="mono" style="color: var(--text-muted);">{{ p.branch }}</td> | ||
| 1806 | <td class="mono" style="color: var(--text-muted); white-space: nowrap;">{{ p.updated }}</td> | ||
| 1807 | </tr> | ||
| 1808 | {% endfor %} | ||
| 1809 | </tbody> | ||
| 1810 | </table> | ||
| 1811 | {% endif %} | ||
| 1812 | {% endblock %} | ||
| 1813 | ``` | ||
| 1814 | |||
| 1815 | - [ ] **Step 2: Create patch detail template** | ||
| 1816 | |||
| 1817 | Create `src/server/http/templates/patch_detail.html`: | ||
| 1818 | |||
| 1819 | ```html | ||
| 1820 | {% extends "repo_base.html" %} | ||
| 1821 | |||
| 1822 | {% block content %} | ||
| 1823 | <h2 style="margin-bottom: 4px;">{{ patch.title }}</h2> | ||
| 1824 | <p style="color: var(--text-muted); font-size: 12px; margin-bottom: 16px;"> | ||
| 1825 | <span class="status-{{ patch.status }}">{{ patch.status }}</span> | ||
| 1826 | · {{ patch.author }} · {{ patch.branch }} → {{ patch.base_ref }} | ||
| 1827 | · created {{ patch.created_at }} | ||
| 1828 | </p> | ||
| 1829 | |||
| 1830 | {% if !patch.body.is_empty() %} | ||
| 1831 | <div style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 4px; padding: 12px; margin-bottom: 16px;"> | ||
| 1832 | <pre>{{ patch.body }}</pre> | ||
| 1833 | </div> | ||
| 1834 | {% endif %} | ||
| 1835 | |||
| 1836 | {% if !revisions.is_empty() %} | ||
| 1837 | <h3 style="font-size: 14px; color: var(--text-muted); margin: 16px 0 8px; text-transform: uppercase;">Revisions</h3> | ||
| 1838 | <table> | ||
| 1839 | <thead><tr><th>#</th><th>Commit</th><th>Date</th><th>Note</th></tr></thead> | ||
| 1840 | <tbody> | ||
| 1841 | {% for r in revisions %} | ||
| 1842 | <tr> | ||
| 1843 | <td>r{{ r.number }}</td> | ||
| 1844 | <td class="mono">{{ r.short_commit }}</td> | ||
| 1845 | <td class="mono" style="color: var(--text-muted);">{{ r.timestamp }}</td> | ||
| 1846 | <td style="color: var(--text-muted);">{{ r.body }}</td> | ||
| 1847 | </tr> | ||
| 1848 | {% endfor %} | ||
| 1849 | </tbody> | ||
| 1850 | </table> | ||
| 1851 | {% endif %} | ||
| 1852 | |||
| 1853 | {% if !reviews.is_empty() %} | ||
| 1854 | <h3 style="font-size: 14px; color: var(--text-muted); margin: 16px 0 8px; text-transform: uppercase;">Reviews</h3> | ||
| 1855 | {% for r in reviews %} | ||
| 1856 | <div style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 4px; padding: 12px; margin-bottom: 8px;"> | ||
| 1857 | <p style="font-size: 13px;"> | ||
| 1858 | <strong>{{ r.author }}</strong> | ||
| 1859 | <span class="status-{% if r.verdict == "approve" %}open{% else %}closed{% endif %}">{{ r.verdict }}</span> | ||
| 1860 | {% if let Some(rev) = r.revision %}<span style="color: var(--text-muted);"> (r{{ rev }})</span>{% endif %} | ||
| 1861 | <span style="color: var(--text-muted);"> · {{ r.timestamp }}</span> | ||
| 1862 | </p> | ||
| 1863 | {% if !r.body.is_empty() %} | ||
| 1864 | <pre style="margin-top: 8px;">{{ r.body }}</pre> | ||
| 1865 | {% endif %} | ||
| 1866 | </div> | ||
| 1867 | {% endfor %} | ||
| 1868 | {% endif %} | ||
| 1869 | |||
| 1870 | {% if !inline_comments.is_empty() %} | ||
| 1871 | <h3 style="font-size: 14px; color: var(--text-muted); margin: 16px 0 8px; text-transform: uppercase;">Inline Comments</h3> | ||
| 1872 | {% for c in inline_comments %} | ||
| 1873 | <div style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 4px; padding: 12px; margin-bottom: 8px;"> | ||
| 1874 | <p style="font-size: 12px; color: var(--text-muted);"> | ||
| 1875 | <strong style="color: var(--text);">{{ c.author }}</strong> on | ||
| 1876 | <span class="mono">{{ c.file }}:{{ c.line }}</span> | ||
| 1877 | {% if let Some(rev) = c.revision %} (r{{ rev }}){% endif %} | ||
| 1878 | · {{ c.timestamp }} | ||
| 1879 | </p> | ||
| 1880 | <pre style="margin-top: 4px;">{{ c.body }}</pre> | ||
| 1881 | </div> | ||
| 1882 | {% endfor %} | ||
| 1883 | {% endif %} | ||
| 1884 | |||
| 1885 | {% if !comments.is_empty() %} | ||
| 1886 | <h3 style="font-size: 14px; color: var(--text-muted); margin: 16px 0 8px; text-transform: uppercase;">Comments</h3> | ||
| 1887 | {% for c in comments %} | ||
| 1888 | <div style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 4px; padding: 12px; margin-bottom: 8px;"> | ||
| 1889 | <p style="font-size: 12px; color: var(--text-muted);"> | ||
| 1890 | <strong style="color: var(--text);">{{ c.author }}</strong> · {{ c.timestamp }} | ||
| 1891 | </p> | ||
| 1892 | <pre style="margin-top: 4px;">{{ c.body }}</pre> | ||
| 1893 | </div> | ||
| 1894 | {% endfor %} | ||
| 1895 | {% endif %} | ||
| 1896 | {% endblock %} | ||
| 1897 | ``` | ||
| 1898 | |||
| 1899 | - [ ] **Step 3: Add patches handlers to repo.rs** | ||
| 1900 | |||
| 1901 | Append to `src/server/http/repo.rs`: | ||
| 1902 | |||
| 1903 | ```rust | ||
| 1904 | // --- Patches --- | ||
| 1905 | |||
| 1906 | pub struct PatchListItem { | ||
| 1907 | pub id: String, | ||
| 1908 | pub short_id: String, | ||
| 1909 | pub status: String, | ||
| 1910 | pub title: String, | ||
| 1911 | pub author: String, | ||
| 1912 | pub branch: String, | ||
| 1913 | pub updated: String, | ||
| 1914 | } | ||
| 1915 | |||
| 1916 | #[derive(Template)] | ||
| 1917 | #[template(path = "patches.html")] | ||
| 1918 | struct PatchesTemplate { | ||
| 1919 | site_title: String, | ||
| 1920 | repo_name: String, | ||
| 1921 | active_section: String, | ||
| 1922 | open_patches: usize, | ||
| 1923 | open_issues: usize, | ||
| 1924 | patches: Vec<PatchListItem>, | ||
| 1925 | } | ||
| 1926 | |||
| 1927 | pub async fn patches( | ||
| 1928 | Path(repo_name): Path<String>, | ||
| 1929 | State(state): State<Arc<AppState>>, | ||
| 1930 | ) -> Response { | ||
| 1931 | let entry = match repos::resolve(&state.repos_dir, &repo_name) { | ||
| 1932 | Some(e) => e, | ||
| 1933 | None => return not_found(&state.site_title), | ||
| 1934 | }; | ||
| 1935 | let repo = match repos::open(&entry) { | ||
| 1936 | Ok(r) => r, | ||
| 1937 | Err(e) => return internal_error(&state.site_title, &e.to_string()), | ||
| 1938 | }; | ||
| 1939 | |||
| 1940 | let all = git_collab::state::list_patches(&repo).unwrap_or_default(); | ||
| 1941 | let (open_patches, open_issues) = collab_counts(&repo); | ||
| 1942 | |||
| 1943 | let patches: Vec<PatchListItem> = all | ||
| 1944 | .iter() | ||
| 1945 | .map(|p| PatchListItem { | ||
| 1946 | id: p.id.clone(), | ||
| 1947 | short_id: p.id[..8].to_string(), | ||
| 1948 | status: p.status.as_str().to_string(), | ||
| 1949 | title: p.title.clone(), | ||
| 1950 | author: p.author.name.clone(), | ||
| 1951 | branch: p.branch.clone(), | ||
| 1952 | updated: p.last_updated.chars().take(10).collect(), | ||
| 1953 | }) | ||
| 1954 | .collect(); | ||
| 1955 | |||
| 1956 | PatchesTemplate { | ||
| 1957 | site_title: state.site_title.clone(), | ||
| 1958 | repo_name, | ||
| 1959 | active_section: "patches".to_string(), | ||
| 1960 | open_patches, | ||
| 1961 | open_issues, | ||
| 1962 | patches, | ||
| 1963 | } | ||
| 1964 | .into_response() | ||
| 1965 | } | ||
| 1966 | |||
| 1967 | // --- Patch detail --- | ||
| 1968 | |||
| 1969 | pub struct PatchDetailView { | ||
| 1970 | pub title: String, | ||
| 1971 | pub body: String, | ||
| 1972 | pub status: String, | ||
| 1973 | pub author: String, | ||
| 1974 | pub branch: String, | ||
| 1975 | pub base_ref: String, | ||
| 1976 | pub created_at: String, | ||
| 1977 | } | ||
| 1978 | |||
| 1979 | pub struct RevisionView { | ||
| 1980 | pub number: u32, | ||
| 1981 | pub short_commit: String, | ||
| 1982 | pub timestamp: String, | ||
| 1983 | pub body: String, | ||
| 1984 | } | ||
| 1985 | |||
| 1986 | pub struct ReviewView { | ||
| 1987 | pub author: String, | ||
| 1988 | pub verdict: String, | ||
| 1989 | pub body: String, | ||
| 1990 | pub timestamp: String, | ||
| 1991 | pub revision: Option<u32>, | ||
| 1992 | } | ||
| 1993 | |||
| 1994 | pub struct InlineCommentView { | ||
| 1995 | pub author: String, | ||
| 1996 | pub file: String, | ||
| 1997 | pub line: u32, | ||
| 1998 | pub body: String, | ||
| 1999 | pub timestamp: String, | ||
| 2000 | pub revision: Option<u32>, | ||
| 2001 | } | ||
| 2002 | |||
| 2003 | pub struct CommentView { | ||
| 2004 | pub author: String, | ||
| 2005 | pub body: String, | ||
| 2006 | pub timestamp: String, | ||
| 2007 | } | ||
| 2008 | |||
| 2009 | #[derive(Template)] | ||
| 2010 | #[template(path = "patch_detail.html")] | ||
| 2011 | struct PatchDetailTemplate { | ||
| 2012 | site_title: String, | ||
| 2013 | repo_name: String, | ||
| 2014 | active_section: String, | ||
| 2015 | open_patches: usize, | ||
| 2016 | open_issues: usize, | ||
| 2017 | patch: PatchDetailView, | ||
| 2018 | revisions: Vec<RevisionView>, | ||
| 2019 | reviews: Vec<ReviewView>, | ||
| 2020 | inline_comments: Vec<InlineCommentView>, | ||
| 2021 | comments: Vec<CommentView>, | ||
| 2022 | } | ||
| 2023 | |||
| 2024 | pub async fn patch_detail( | ||
| 2025 | Path((repo_name, patch_id)): Path<(String, String)>, | ||
| 2026 | State(state): State<Arc<AppState>>, | ||
| 2027 | ) -> Response { | ||
| 2028 | let entry = match repos::resolve(&state.repos_dir, &repo_name) { | ||
| 2029 | Some(e) => e, | ||
| 2030 | None => return not_found(&state.site_title), | ||
| 2031 | }; | ||
| 2032 | let repo = match repos::open(&entry) { | ||
| 2033 | Ok(r) => r, | ||
| 2034 | Err(e) => return internal_error(&state.site_title, &e.to_string()), | ||
| 2035 | }; | ||
| 2036 | |||
| 2037 | let (ref_name, full_id) = match git_collab::state::resolve_patch_ref(&repo, &patch_id) { | ||
| 2038 | Ok(r) => r, | ||
| 2039 | Err(_) => return not_found(&state.site_title), | ||
| 2040 | }; | ||
| 2041 | let p = match git_collab::state::PatchState::from_ref(&repo, &ref_name, &full_id) { | ||
| 2042 | Ok(p) => p, | ||
| 2043 | Err(e) => return internal_error(&state.site_title, &e.to_string()), | ||
| 2044 | }; | ||
| 2045 | |||
| 2046 | let (open_patches, open_issues) = collab_counts(&repo); | ||
| 2047 | |||
| 2048 | let patch = PatchDetailView { | ||
| 2049 | title: p.title.clone(), | ||
| 2050 | body: p.body.clone(), | ||
| 2051 | status: p.status.as_str().to_string(), | ||
| 2052 | author: p.author.name.clone(), | ||
| 2053 | branch: p.branch.clone(), | ||
| 2054 | base_ref: p.base_ref.clone(), | ||
| 2055 | created_at: p.created_at.chars().take(10).collect(), | ||
| 2056 | }; | ||
| 2057 | |||
| 2058 | let revisions: Vec<RevisionView> = p.revisions.iter().map(|r| RevisionView { | ||
| 2059 | number: r.number, | ||
| 2060 | short_commit: r.commit.chars().take(8).collect(), | ||
| 2061 | timestamp: r.timestamp.chars().take(10).collect(), | ||
| 2062 | body: r.body.clone().unwrap_or_default(), | ||
| 2063 | }).collect(); | ||
| 2064 | |||
| 2065 | let reviews: Vec<ReviewView> = p.reviews.iter().map(|r| ReviewView { | ||
| 2066 | author: r.author.name.clone(), | ||
| 2067 | verdict: r.verdict.as_str().to_string(), | ||
| 2068 | body: r.body.clone(), | ||
| 2069 | timestamp: r.timestamp.chars().take(10).collect(), | ||
| 2070 | revision: r.revision, | ||
| 2071 | }).collect(); | ||
| 2072 | |||
| 2073 | let inline_comments: Vec<InlineCommentView> = p.inline_comments.iter().map(|c| InlineCommentView { | ||
| 2074 | author: c.author.name.clone(), | ||
| 2075 | file: c.file.clone(), | ||
| 2076 | line: c.line, | ||
| 2077 | body: c.body.clone(), | ||
| 2078 | timestamp: c.timestamp.chars().take(10).collect(), | ||
| 2079 | revision: c.revision, | ||
| 2080 | }).collect(); | ||
| 2081 | |||
| 2082 | let comments: Vec<CommentView> = p.comments.iter().map(|c| CommentView { | ||
| 2083 | author: c.author.name.clone(), | ||
| 2084 | body: c.body.clone(), | ||
| 2085 | timestamp: c.timestamp.chars().take(10).collect(), | ||
| 2086 | }).collect(); | ||
| 2087 | |||
| 2088 | PatchDetailTemplate { | ||
| 2089 | site_title: state.site_title.clone(), | ||
| 2090 | repo_name, | ||
| 2091 | active_section: "patches".to_string(), | ||
| 2092 | open_patches, | ||
| 2093 | open_issues, | ||
| 2094 | patch, | ||
| 2095 | revisions, | ||
| 2096 | reviews, | ||
| 2097 | inline_comments, | ||
| 2098 | comments, | ||
| 2099 | } | ||
| 2100 | .into_response() | ||
| 2101 | } | ||
| 2102 | ``` | ||
| 2103 | |||
| 2104 | - [ ] **Step 4: Add routes** | ||
| 2105 | |||
| 2106 | Add to router: | ||
| 2107 | |||
| 2108 | ```rust | ||
| 2109 | .route("/{repo_name}/patches", axum::routing::get(repo::patches)) | ||
| 2110 | .route("/{repo_name}/patches/{id}", axum::routing::get(repo::patch_detail)) | ||
| 2111 | ``` | ||
| 2112 | |||
| 2113 | - [ ] **Step 5: Verify compilation** | ||
| 2114 | |||
| 2115 | Run: `cargo build` | ||
| 2116 | Expected: Compiles. | ||
| 2117 | |||
| 2118 | - [ ] **Step 6: Commit** | ||
| 2119 | |||
| 2120 | ```bash | ||
| 2121 | git add src/server/http/ | ||
| 2122 | git commit -m "add patches list and detail pages" | ||
| 2123 | ``` | ||
| 2124 | |||
| 2125 | --- | ||
| 2126 | |||
| 2127 | ### Task 12: Issues list and detail pages | ||
| 2128 | |||
| 2129 | **Files:** | ||
| 2130 | - Create: `src/server/http/templates/issues.html` | ||
| 2131 | - Create: `src/server/http/templates/issue_detail.html` | ||
| 2132 | - Modify: `src/server/http/repo.rs` | ||
| 2133 | - Modify: `src/server/http/mod.rs` | ||
| 2134 | |||
| 2135 | - [ ] **Step 1: Create issues list template** | ||
| 2136 | |||
| 2137 | Create `src/server/http/templates/issues.html`: | ||
| 2138 | |||
| 2139 | ```html | ||
| 2140 | {% extends "repo_base.html" %} | ||
| 2141 | |||
| 2142 | {% block content %} | ||
| 2143 | <h2 style="margin-bottom: 16px;">Issues</h2> | ||
| 2144 | {% if issues.is_empty() %} | ||
| 2145 | <p style="color: var(--text-muted);">No issues found.</p> | ||
| 2146 | {% else %} | ||
| 2147 | <table> | ||
| 2148 | <thead><tr><th>ID</th><th>Status</th><th>Title</th><th>Author</th><th>Labels</th><th>Updated</th></tr></thead> | ||
| 2149 | <tbody> | ||
| 2150 | {% for i in issues %} | ||
| 2151 | <tr> | ||
| 2152 | <td class="mono"><a href="/{{ repo_name }}/issues/{{ i.id }}">{{ i.short_id }}</a></td> | ||
| 2153 | <td><span class="status-{{ i.status }}">{{ i.status }}</span></td> | ||
| 2154 | <td>{{ i.title }}</td> | ||
| 2155 | <td style="color: var(--text-muted);">{{ i.author }}</td> | ||
| 2156 | <td style="color: var(--text-muted);">{{ i.labels }}</td> | ||
| 2157 | <td class="mono" style="color: var(--text-muted); white-space: nowrap;">{{ i.updated }}</td> | ||
| 2158 | </tr> | ||
| 2159 | {% endfor %} | ||
| 2160 | </tbody> | ||
| 2161 | </table> | ||
| 2162 | {% endif %} | ||
| 2163 | {% endblock %} | ||
| 2164 | ``` | ||
| 2165 | |||
| 2166 | - [ ] **Step 2: Create issue detail template** | ||
| 2167 | |||
| 2168 | Create `src/server/http/templates/issue_detail.html`: | ||
| 2169 | |||
| 2170 | ```html | ||
| 2171 | {% extends "repo_base.html" %} | ||
| 2172 | |||
| 2173 | {% block content %} | ||
| 2174 | <h2 style="margin-bottom: 4px;">{{ issue.title }}</h2> | ||
| 2175 | <p style="color: var(--text-muted); font-size: 12px; margin-bottom: 16px;"> | ||
| 2176 | <span class="status-{{ issue.status }}">{{ issue.status }}</span> | ||
| 2177 | · {{ issue.author }} · created {{ issue.created_at }} | ||
| 2178 | {% if !issue.labels.is_empty() %} · [{{ issue.labels }}]{% endif %} | ||
| 2179 | {% if !issue.assignees.is_empty() %} · assigned to {{ issue.assignees }}{% endif %} | ||
| 2180 | </p> | ||
| 2181 | |||
| 2182 | {% if !issue.body.is_empty() %} | ||
| 2183 | <div style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 4px; padding: 12px; margin-bottom: 16px;"> | ||
| 2184 | <pre>{{ issue.body }}</pre> | ||
| 2185 | </div> | ||
| 2186 | {% endif %} | ||
| 2187 | |||
| 2188 | {% if let Some(reason) = issue.close_reason.as_ref() %} | ||
| 2189 | <p style="color: var(--text-muted); margin-bottom: 16px;">Closed: {{ reason }}</p> | ||
| 2190 | {% endif %} | ||
| 2191 | |||
| 2192 | {% if !comments.is_empty() %} | ||
| 2193 | <h3 style="font-size: 14px; color: var(--text-muted); margin: 16px 0 8px; text-transform: uppercase;">Comments</h3> | ||
| 2194 | {% for c in comments %} | ||
| 2195 | <div style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 4px; padding: 12px; margin-bottom: 8px;"> | ||
| 2196 | <p style="font-size: 12px; color: var(--text-muted);"> | ||
| 2197 | <strong style="color: var(--text);">{{ c.author }}</strong> · {{ c.timestamp }} | ||
| 2198 | </p> | ||
| 2199 | <pre style="margin-top: 4px;">{{ c.body }}</pre> | ||
| 2200 | </div> | ||
| 2201 | {% endfor %} | ||
| 2202 | {% endif %} | ||
| 2203 | {% endblock %} | ||
| 2204 | ``` | ||
| 2205 | |||
| 2206 | - [ ] **Step 3: Add issues handlers to repo.rs** | ||
| 2207 | |||
| 2208 | Append to `src/server/http/repo.rs`: | ||
| 2209 | |||
| 2210 | ```rust | ||
| 2211 | // --- Issues --- | ||
| 2212 | |||
| 2213 | pub struct IssueListItem { | ||
| 2214 | pub id: String, | ||
| 2215 | pub short_id: String, | ||
| 2216 | pub status: String, | ||
| 2217 | pub title: String, | ||
| 2218 | pub author: String, | ||
| 2219 | pub labels: String, | ||
| 2220 | pub updated: String, | ||
| 2221 | } | ||
| 2222 | |||
| 2223 | #[derive(Template)] | ||
| 2224 | #[template(path = "issues.html")] | ||
| 2225 | struct IssuesTemplate { | ||
| 2226 | site_title: String, | ||
| 2227 | repo_name: String, | ||
| 2228 | active_section: String, | ||
| 2229 | open_patches: usize, | ||
| 2230 | open_issues: usize, | ||
| 2231 | issues: Vec<IssueListItem>, | ||
| 2232 | } | ||
| 2233 | |||
| 2234 | pub async fn issues( | ||
| 2235 | Path(repo_name): Path<String>, | ||
| 2236 | State(state): State<Arc<AppState>>, | ||
| 2237 | ) -> Response { | ||
| 2238 | let entry = match repos::resolve(&state.repos_dir, &repo_name) { | ||
| 2239 | Some(e) => e, | ||
| 2240 | None => return not_found(&state.site_title), | ||
| 2241 | }; | ||
| 2242 | let repo = match repos::open(&entry) { | ||
| 2243 | Ok(r) => r, | ||
| 2244 | Err(e) => return internal_error(&state.site_title, &e.to_string()), | ||
| 2245 | }; | ||
| 2246 | |||
| 2247 | let all = git_collab::state::list_issues(&repo).unwrap_or_default(); | ||
| 2248 | let (open_patches, open_issues) = collab_counts(&repo); | ||
| 2249 | |||
| 2250 | let issues: Vec<IssueListItem> = all | ||
| 2251 | .iter() | ||
| 2252 | .map(|i| IssueListItem { | ||
| 2253 | id: i.id.clone(), | ||
| 2254 | short_id: i.id[..8].to_string(), | ||
| 2255 | status: i.status.as_str().to_string(), | ||
| 2256 | title: i.title.clone(), | ||
| 2257 | author: i.author.name.clone(), | ||
| 2258 | labels: i.labels.join(", "), | ||
| 2259 | updated: i.last_updated.chars().take(10).collect(), | ||
| 2260 | }) | ||
| 2261 | .collect(); | ||
| 2262 | |||
| 2263 | IssuesTemplate { | ||
| 2264 | site_title: state.site_title.clone(), | ||
| 2265 | repo_name, | ||
| 2266 | active_section: "issues".to_string(), | ||
| 2267 | open_patches, | ||
| 2268 | open_issues, | ||
| 2269 | issues, | ||
| 2270 | } | ||
| 2271 | .into_response() | ||
| 2272 | } | ||
| 2273 | |||
| 2274 | // --- Issue detail --- | ||
| 2275 | |||
| 2276 | pub struct IssueDetailView { | ||
| 2277 | pub title: String, | ||
| 2278 | pub body: String, | ||
| 2279 | pub status: String, | ||
| 2280 | pub author: String, | ||
| 2281 | pub labels: String, | ||
| 2282 | pub assignees: String, | ||
| 2283 | pub created_at: String, | ||
| 2284 | pub close_reason: Option<String>, | ||
| 2285 | } | ||
| 2286 | |||
| 2287 | #[derive(Template)] | ||
| 2288 | #[template(path = "issue_detail.html")] | ||
| 2289 | struct IssueDetailTemplate { | ||
| 2290 | site_title: String, | ||
| 2291 | repo_name: String, | ||
| 2292 | active_section: String, | ||
| 2293 | open_patches: usize, | ||
| 2294 | open_issues: usize, | ||
| 2295 | issue: IssueDetailView, | ||
| 2296 | comments: Vec<CommentView>, | ||
| 2297 | } | ||
| 2298 | |||
| 2299 | pub async fn issue_detail( | ||
| 2300 | Path((repo_name, issue_id)): Path<(String, String)>, | ||
| 2301 | State(state): State<Arc<AppState>>, | ||
| 2302 | ) -> Response { | ||
| 2303 | let entry = match repos::resolve(&state.repos_dir, &repo_name) { | ||
| 2304 | Some(e) => e, | ||
| 2305 | None => return not_found(&state.site_title), | ||
| 2306 | }; | ||
| 2307 | let repo = match repos::open(&entry) { | ||
| 2308 | Ok(r) => r, | ||
| 2309 | Err(e) => return internal_error(&state.site_title, &e.to_string()), | ||
| 2310 | }; | ||
| 2311 | |||
| 2312 | let (ref_name, full_id) = match git_collab::state::resolve_issue_ref(&repo, &issue_id) { | ||
| 2313 | Ok(r) => r, | ||
| 2314 | Err(_) => return not_found(&state.site_title), | ||
| 2315 | }; | ||
| 2316 | let i = match git_collab::state::IssueState::from_ref(&repo, &ref_name, &full_id) { | ||
| 2317 | Ok(i) => i, | ||
| 2318 | Err(e) => return internal_error(&state.site_title, &e.to_string()), | ||
| 2319 | }; | ||
| 2320 | |||
| 2321 | let (open_patches, open_issues) = collab_counts(&repo); | ||
| 2322 | |||
| 2323 | let issue = IssueDetailView { | ||
| 2324 | title: i.title.clone(), | ||
| 2325 | body: i.body.clone(), | ||
| 2326 | status: i.status.as_str().to_string(), | ||
| 2327 | author: i.author.name.clone(), | ||
| 2328 | labels: i.labels.join(", "), | ||
| 2329 | assignees: i.assignees.join(", "), | ||
| 2330 | created_at: i.created_at.chars().take(10).collect(), | ||
| 2331 | close_reason: i.close_reason.clone(), | ||
| 2332 | }; | ||
| 2333 | |||
| 2334 | let comments: Vec<CommentView> = i.comments.iter().map(|c| CommentView { | ||
| 2335 | author: c.author.name.clone(), | ||
| 2336 | body: c.body.clone(), | ||
| 2337 | timestamp: c.timestamp.chars().take(10).collect(), | ||
| 2338 | }).collect(); | ||
| 2339 | |||
| 2340 | IssueDetailTemplate { | ||
| 2341 | site_title: state.site_title.clone(), | ||
| 2342 | repo_name, | ||
| 2343 | active_section: "issues".to_string(), | ||
| 2344 | open_patches, | ||
| 2345 | open_issues, | ||
| 2346 | issue, | ||
| 2347 | comments, | ||
| 2348 | } | ||
| 2349 | .into_response() | ||
| 2350 | } | ||
| 2351 | ``` | ||
| 2352 | |||
| 2353 | - [ ] **Step 4: Add routes** | ||
| 2354 | |||
| 2355 | Add to router: | ||
| 2356 | |||
| 2357 | ```rust | ||
| 2358 | .route("/{repo_name}/issues", axum::routing::get(repo::issues)) | ||
| 2359 | .route("/{repo_name}/issues/{id}", axum::routing::get(repo::issue_detail)) | ||
| 2360 | ``` | ||
| 2361 | |||
| 2362 | - [ ] **Step 5: Verify compilation** | ||
| 2363 | |||
| 2364 | Run: `cargo build` | ||
| 2365 | Expected: Compiles. | ||
| 2366 | |||
| 2367 | - [ ] **Step 6: Commit** | ||
| 2368 | |||
| 2369 | ```bash | ||
| 2370 | git add src/server/http/ | ||
| 2371 | git commit -m "add issues list and detail pages" | ||
| 2372 | ``` | ||
| 2373 | |||
| 2374 | --- | ||
| 2375 | |||
| 2376 | ### Task 13: Git smart HTTP (HTTPS clone) | ||
| 2377 | |||
| 2378 | **Files:** | ||
| 2379 | - Create: `src/server/http/git_http.rs` | ||
| 2380 | - Modify: `src/server/http/mod.rs` | ||
| 2381 | |||
| 2382 | - [ ] **Step 1: Create git smart HTTP handler** | ||
| 2383 | |||
| 2384 | Create `src/server/http/git_http.rs`: | ||
| 2385 | |||
| 2386 | ```rust | ||
| 2387 | use std::sync::Arc; | ||
| 2388 | |||
| 2389 | use axum::body::Body; | ||
| 2390 | use axum::extract::{Path, Query, State}; | ||
| 2391 | use axum::http::{HeaderMap, StatusCode}; | ||
| 2392 | use axum::response::{IntoResponse, Response}; | ||
| 2393 | use tokio::process::Command; | ||
| 2394 | |||
| 2395 | use super::AppState; | ||
| 2396 | use crate::server::repos; | ||
| 2397 | |||
| 2398 | #[derive(serde::Deserialize)] | ||
| 2399 | pub struct InfoRefsQuery { | ||
| 2400 | pub service: Option<String>, | ||
| 2401 | } | ||
| 2402 | |||
| 2403 | /// GET /:repo.git/info/refs?service=git-upload-pack | ||
| 2404 | pub async fn info_refs( | ||
| 2405 | Path(repo_dot_git): Path<String>, | ||
| 2406 | Query(query): Query<InfoRefsQuery>, | ||
| 2407 | State(state): State<Arc<AppState>>, | ||
| 2408 | ) -> Response { | ||
| 2409 | let service = match query.service.as_deref() { | ||
| 2410 | Some("git-upload-pack") => "git-upload-pack", | ||
| 2411 | _ => { | ||
| 2412 | return (StatusCode::FORBIDDEN, "only git-upload-pack is supported") | ||
| 2413 | .into_response(); | ||
| 2414 | } | ||
| 2415 | }; | ||
| 2416 | |||
| 2417 | let repo_name = repo_dot_git.strip_suffix(".git").unwrap_or(&repo_dot_git); | ||
| 2418 | let entry = match repos::resolve(&state.repos_dir, repo_name) { | ||
| 2419 | Some(e) => e, | ||
| 2420 | None => return StatusCode::NOT_FOUND.into_response(), | ||
| 2421 | }; | ||
| 2422 | |||
| 2423 | let repo_path = if entry.bare { | ||
| 2424 | entry.path.clone() | ||
| 2425 | } else { | ||
| 2426 | entry.path.join(".git") | ||
| 2427 | }; | ||
| 2428 | |||
| 2429 | let output = match Command::new("git") | ||
| 2430 | .args(["upload-pack", "--stateless-rpc", "--advertise-refs"]) | ||
| 2431 | .arg(&repo_path) | ||
| 2432 | .output() | ||
| 2433 | .await | ||
| 2434 | { | ||
| 2435 | Ok(o) => o, | ||
| 2436 | Err(e) => { | ||
| 2437 | tracing::error!("git upload-pack --advertise-refs failed: {}", e); | ||
| 2438 | return StatusCode::INTERNAL_SERVER_ERROR.into_response(); | ||
| 2439 | } | ||
| 2440 | }; | ||
| 2441 | |||
| 2442 | if !output.status.success() { | ||
| 2443 | tracing::error!( | ||
| 2444 | "git upload-pack --advertise-refs exited with {}", | ||
| 2445 | output.status | ||
| 2446 | ); | ||
| 2447 | return StatusCode::INTERNAL_SERVER_ERROR.into_response(); | ||
| 2448 | } | ||
| 2449 | |||
| 2450 | // Smart HTTP protocol: prepend service announcement | ||
| 2451 | let mut body = Vec::new(); | ||
| 2452 | let announcement = format!("# service={}\n", service); | ||
| 2453 | let pkt_line = format!("{:04x}{}", announcement.len() + 4, announcement); | ||
| 2454 | body.extend_from_slice(pkt_line.as_bytes()); | ||
| 2455 | body.extend_from_slice(b"0000"); // flush-pkt | ||
| 2456 | body.extend_from_slice(&output.stdout); | ||
| 2457 | |||
| 2458 | let content_type = format!("application/x-{}-advertisement", service); | ||
| 2459 | let mut headers = HeaderMap::new(); | ||
| 2460 | headers.insert("Content-Type", content_type.parse().unwrap()); | ||
| 2461 | headers.insert("Cache-Control", "no-cache".parse().unwrap()); | ||
| 2462 | |||
| 2463 | (headers, body).into_response() | ||
| 2464 | } | ||
| 2465 | |||
| 2466 | /// POST /:repo.git/git-upload-pack | ||
| 2467 | pub async fn upload_pack( | ||
| 2468 | Path(repo_dot_git): Path<String>, | ||
| 2469 | State(state): State<Arc<AppState>>, | ||
| 2470 | body: axum::body::Bytes, | ||
| 2471 | ) -> Response { | ||
| 2472 | let repo_name = repo_dot_git.strip_suffix(".git").unwrap_or(&repo_dot_git); | ||
| 2473 | let entry = match repos::resolve(&state.repos_dir, repo_name) { | ||
| 2474 | Some(e) => e, | ||
| 2475 | None => return StatusCode::NOT_FOUND.into_response(), | ||
| 2476 | }; | ||
| 2477 | |||
| 2478 | let repo_path = if entry.bare { | ||
| 2479 | entry.path.clone() | ||
| 2480 | } else { | ||
| 2481 | entry.path.join(".git") | ||
| 2482 | }; | ||
| 2483 | |||
| 2484 | let mut child = match Command::new("git") | ||
| 2485 | .args(["upload-pack", "--stateless-rpc"]) | ||
| 2486 | .arg(&repo_path) | ||
| 2487 | .stdin(std::process::Stdio::piped()) | ||
| 2488 | .stdout(std::process::Stdio::piped()) | ||
| 2489 | .stderr(std::process::Stdio::piped()) | ||
| 2490 | .spawn() | ||
| 2491 | { | ||
| 2492 | Ok(c) => c, | ||
| 2493 | Err(e) => { | ||
| 2494 | tracing::error!("failed to spawn git upload-pack: {}", e); | ||
| 2495 | return StatusCode::INTERNAL_SERVER_ERROR.into_response(); | ||
| 2496 | } | ||
| 2497 | }; | ||
| 2498 | |||
| 2499 | // Write request body to stdin | ||
| 2500 | if let Some(mut stdin) = child.stdin.take() { | ||
| 2501 | use tokio::io::AsyncWriteExt; | ||
| 2502 | let _ = stdin.write_all(&body).await; | ||
| 2503 | drop(stdin); | ||
| 2504 | } | ||
| 2505 | |||
| 2506 | let output = match child.wait_with_output().await { | ||
| 2507 | Ok(o) => o, | ||
| 2508 | Err(e) => { | ||
| 2509 | tracing::error!("git upload-pack failed: {}", e); | ||
| 2510 | return StatusCode::INTERNAL_SERVER_ERROR.into_response(); | ||
| 2511 | } | ||
| 2512 | }; | ||
| 2513 | |||
| 2514 | let mut headers = HeaderMap::new(); | ||
| 2515 | headers.insert( | ||
| 2516 | "Content-Type", | ||
| 2517 | "application/x-git-upload-pack-result".parse().unwrap(), | ||
| 2518 | ); | ||
| 2519 | headers.insert("Cache-Control", "no-cache".parse().unwrap()); | ||
| 2520 | |||
| 2521 | (headers, output.stdout).into_response() | ||
| 2522 | } | ||
| 2523 | ``` | ||
| 2524 | |||
| 2525 | - [ ] **Step 2: Add routes** | ||
| 2526 | |||
| 2527 | Add `pub mod git_http;` to `src/server/http/mod.rs` and routes: | ||
| 2528 | |||
| 2529 | ```rust | ||
| 2530 | .route("/{repo_dot_git}/info/refs", axum::routing::get(git_http::info_refs)) | ||
| 2531 | .route("/{repo_dot_git}/git-upload-pack", axum::routing::post(git_http::upload_pack)) | ||
| 2532 | ``` | ||
| 2533 | |||
| 2534 | - [ ] **Step 3: Verify compilation** | ||
| 2535 | |||
| 2536 | Run: `cargo build` | ||
| 2537 | Expected: Compiles. | ||
| 2538 | |||
| 2539 | - [ ] **Step 4: Integration test — clone via HTTP** | ||
| 2540 | |||
| 2541 | ```bash | ||
| 2542 | # Setup | ||
| 2543 | mkdir -p /tmp/test-repos | ||
| 2544 | rm -rf /tmp/test-repos/hello.git | ||
| 2545 | git init --bare /tmp/test-repos/hello.git | ||
| 2546 | # Add a commit so there's something to clone | ||
| 2547 | cd /tmp && rm -rf /tmp/hello-work && git clone /tmp/test-repos/hello.git /tmp/hello-work | ||
| 2548 | cd /tmp/hello-work && echo "hello" > README.md && git add . && git commit -m "init" && git push origin main | ||
| 2549 | |||
| 2550 | cat > /tmp/test-server.toml <<'EOF' | ||
| 2551 | repos_dir = "/tmp/test-repos" | ||
| 2552 | http_bind = "127.0.0.1:18080" | ||
| 2553 | ssh_bind = "127.0.0.1:12222" | ||
| 2554 | authorized_keys = "/dev/null" | ||
| 2555 | EOF | ||
| 2556 | |||
| 2557 | cargo run --bin git-collab-server -- --config /tmp/test-server.toml & | ||
| 2558 | SERVER_PID=$! | ||
| 2559 | sleep 1 | ||
| 2560 | |||
| 2561 | # Test clone | ||
| 2562 | rm -rf /tmp/hello-clone | ||
| 2563 | git clone http://127.0.0.1:18080/hello.git /tmp/hello-clone && echo "PASS: HTTP clone works" || echo "FAIL" | ||
| 2564 | |||
| 2565 | kill $SERVER_PID | ||
| 2566 | ``` | ||
| 2567 | |||
| 2568 | Expected: Clone succeeds. | ||
| 2569 | |||
| 2570 | - [ ] **Step 5: Commit** | ||
| 2571 | |||
| 2572 | ```bash | ||
| 2573 | git add src/server/http/git_http.rs src/server/http/mod.rs | ||
| 2574 | git commit -m "add git smart HTTP for read-only HTTPS clone" | ||
| 2575 | ``` | ||
| 2576 | |||
| 2577 | --- | ||
| 2578 | |||
| 2579 | ### Task 14: SSH server — russh with key auth and git exec | ||
| 2580 | |||
| 2581 | **Files:** | ||
| 2582 | - Create: `src/server/ssh/session.rs` | ||
| 2583 | - Modify: `src/server/ssh/mod.rs` | ||
| 2584 | |||
| 2585 | - [ ] **Step 1: Create the SSH session handler** | ||
| 2586 | |||
| 2587 | Create `src/server/ssh/session.rs`: | ||
| 2588 | |||
| 2589 | ```rust | ||
| 2590 | use std::path::{Path, PathBuf}; | ||
| 2591 | use std::sync::Arc; | ||
| 2592 | |||
| 2593 | use async_trait::async_trait; | ||
| 2594 | use russh::server::{Auth, Msg, Session}; | ||
| 2595 | use russh::{Channel, ChannelId, CryptoVec}; | ||
| 2596 | use tokio::sync::Mutex; | ||
| 2597 | |||
| 2598 | use super::auth; | ||
| 2599 | |||
| 2600 | /// Shared server configuration for SSH. | ||
| 2601 | #[derive(Debug, Clone)] | ||
| 2602 | pub struct SshServerConfig { | ||
| 2603 | pub repos_dir: PathBuf, | ||
| 2604 | pub authorized_keys_path: PathBuf, | ||
| 2605 | } | ||
| 2606 | |||
| 2607 | /// Per-connection handler. | ||
| 2608 | pub struct SshHandler { | ||
| 2609 | config: Arc<SshServerConfig>, | ||
| 2610 | authenticated: bool, | ||
| 2611 | } | ||
| 2612 | |||
| 2613 | impl SshHandler { | ||
| 2614 | pub fn new(config: Arc<SshServerConfig>) -> Self { | ||
| 2615 | Self { | ||
| 2616 | config, | ||
| 2617 | authenticated: false, | ||
| 2618 | } | ||
| 2619 | } | ||
| 2620 | } | ||
| 2621 | |||
| 2622 | #[async_trait] | ||
| 2623 | impl russh::server::Handler for SshHandler { | ||
| 2624 | type Error = anyhow::Error; | ||
| 2625 | |||
| 2626 | async fn auth_publickey( | ||
| 2627 | &mut self, | ||
| 2628 | user: &str, | ||
| 2629 | public_key: &russh_keys::key::PublicKey, | ||
| 2630 | ) -> Result<Auth, Self::Error> { | ||
| 2631 | let key_data = public_key.public_key_base64(); | ||
| 2632 | let key_type = public_key.name().to_string(); | ||
| 2633 | |||
| 2634 | let keys = auth::load_authorized_keys(&self.config.authorized_keys_path) | ||
| 2635 | .unwrap_or_default(); | ||
| 2636 | |||
| 2637 | if auth::is_authorized(&keys, &key_type, &key_data) { | ||
| 2638 | tracing::info!(user = user, key_type = key_type, "SSH auth accepted"); | ||
| 2639 | self.authenticated = true; | ||
| 2640 | Ok(Auth::Accept) | ||
| 2641 | } else { | ||
| 2642 | tracing::warn!(user = user, key_type = key_type, "SSH auth rejected"); | ||
| 2643 | Ok(Auth::Reject { | ||
| 2644 | proceed_with_methods: None, | ||
| 2645 | }) | ||
| 2646 | } | ||
| 2647 | } | ||
| 2648 | |||
| 2649 | async fn channel_open_session( | ||
| 2650 | &mut self, | ||
| 2651 | channel: Channel<Msg>, | ||
| 2652 | session: &mut Session, | ||
| 2653 | ) -> Result<bool, Self::Error> { | ||
| 2654 | Ok(self.authenticated) | ||
| 2655 | } | ||
| 2656 | |||
| 2657 | async fn exec_request( | ||
| 2658 | &mut self, | ||
| 2659 | channel_id: ChannelId, | ||
| 2660 | data: &[u8], | ||
| 2661 | session: &mut Session, | ||
| 2662 | ) -> Result<(), Self::Error> { | ||
| 2663 | let command = String::from_utf8_lossy(data).to_string(); | ||
| 2664 | tracing::info!(command = command, "SSH exec request"); | ||
| 2665 | |||
| 2666 | // Parse the command — only allow git-upload-pack and git-receive-pack | ||
| 2667 | let (git_cmd, repo_path) = match parse_git_command(&command) { | ||
| 2668 | Some(parsed) => parsed, | ||
| 2669 | None => { | ||
| 2670 | tracing::warn!(command = command, "SSH command rejected"); | ||
| 2671 | session.close(channel_id); | ||
| 2672 | return Ok(()); | ||
| 2673 | } | ||
| 2674 | }; | ||
| 2675 | |||
| 2676 | // Validate repo path against repos_dir | ||
| 2677 | let resolved = match resolve_repo_path(&self.config.repos_dir, &repo_path) { | ||
| 2678 | Some(p) => p, | ||
| 2679 | None => { | ||
| 2680 | tracing::warn!(path = repo_path, "SSH repo path rejected (traversal or not found)"); | ||
| 2681 | session.close(channel_id); | ||
| 2682 | return Ok(()); | ||
| 2683 | } | ||
| 2684 | }; | ||
| 2685 | |||
| 2686 | // Spawn the git subprocess | ||
| 2687 | let child = tokio::process::Command::new(&git_cmd) | ||
| 2688 | .arg(&resolved) | ||
| 2689 | .stdin(std::process::Stdio::piped()) | ||
| 2690 | .stdout(std::process::Stdio::piped()) | ||
| 2691 | .stderr(std::process::Stdio::piped()) | ||
| 2692 | .spawn(); | ||
| 2693 | |||
| 2694 | let mut child = match child { | ||
| 2695 | Ok(c) => c, | ||
| 2696 | Err(e) => { | ||
| 2697 | tracing::error!(error = %e, "failed to spawn {}", git_cmd); | ||
| 2698 | session.close(channel_id); | ||
| 2699 | return Ok(()); | ||
| 2700 | } | ||
| 2701 | }; | ||
| 2702 | |||
| 2703 | // Relay stdin/stdout between SSH channel and subprocess | ||
| 2704 | let handle = session.handle(); | ||
| 2705 | let stdin = child.stdin.take(); | ||
| 2706 | let stdout = child.stdout.take(); | ||
| 2707 | |||
| 2708 | tokio::spawn(async move { | ||
| 2709 | use tokio::io::{AsyncReadExt, AsyncWriteExt}; | ||
| 2710 | |||
| 2711 | if let Some(mut stdout) = stdout { | ||
| 2712 | let mut buf = vec![0u8; 32768]; | ||
| 2713 | loop { | ||
| 2714 | match stdout.read(&mut buf).await { | ||
| 2715 | Ok(0) => break, | ||
| 2716 | Ok(n) => { | ||
| 2717 | let _ = handle.data(channel_id, CryptoVec::from_slice(&buf[..n])).await; | ||
| 2718 | } | ||
| 2719 | Err(_) => break, | ||
| 2720 | } | ||
| 2721 | } | ||
| 2722 | } | ||
| 2723 | |||
| 2724 | let status = child.wait().await.map(|s| s.code().unwrap_or(1)).unwrap_or(1); | ||
| 2725 | let _ = handle.exit_status_request(channel_id, status as u32).await; | ||
| 2726 | let _ = handle.eof(channel_id).await; | ||
| 2727 | let _ = handle.close(channel_id).await; | ||
| 2728 | }); | ||
| 2729 | |||
| 2730 | Ok(()) | ||
| 2731 | } | ||
| 2732 | |||
| 2733 | async fn data( | ||
| 2734 | &mut self, | ||
| 2735 | channel_id: ChannelId, | ||
| 2736 | data: &[u8], | ||
| 2737 | session: &mut Session, | ||
| 2738 | ) -> Result<(), Self::Error> { | ||
| 2739 | // Data from client → relay to subprocess stdin | ||
| 2740 | // For simplicity in v1, we handle this via the spawned task's stdin | ||
| 2741 | // This requires a more complex architecture with channels; for now | ||
| 2742 | // the subprocess gets stdin from the exec_request spawn | ||
| 2743 | Ok(()) | ||
| 2744 | } | ||
| 2745 | } | ||
| 2746 | |||
| 2747 | /// Parse a git command string like `git-upload-pack '/path/to/repo'` | ||
| 2748 | /// Returns (command, repo_path) or None if not a valid git command. | ||
| 2749 | fn parse_git_command(command: &str) -> Option<(String, String)> { | ||
| 2750 | let command = command.trim(); | ||
| 2751 | let (cmd, rest) = command.split_once(' ')?; | ||
| 2752 | |||
| 2753 | match cmd { | ||
| 2754 | "git-upload-pack" | "git-receive-pack" => {} | ||
| 2755 | _ => return None, | ||
| 2756 | } | ||
| 2757 | |||
| 2758 | // Strip surrounding quotes from the path | ||
| 2759 | let path = rest.trim(); | ||
| 2760 | let path = path | ||
| 2761 | .strip_prefix('\'') | ||
| 2762 | .and_then(|p| p.strip_suffix('\'')) | ||
| 2763 | .or_else(|| path.strip_prefix('"').and_then(|p| p.strip_suffix('"'))) | ||
| 2764 | .unwrap_or(path); | ||
| 2765 | |||
| 2766 | Some((cmd.to_string(), path.to_string())) | ||
| 2767 | } | ||
| 2768 | |||
| 2769 | /// Validate and resolve a repo path to a real path under repos_dir. | ||
| 2770 | /// Prevents path traversal attacks. | ||
| 2771 | fn resolve_repo_path(repos_dir: &Path, repo_path: &str) -> Option<PathBuf> { | ||
| 2772 | // Strip leading / — repo_path comes from the SSH command | ||
| 2773 | let repo_path = repo_path.trim_start_matches('/'); | ||
| 2774 | let repo_name = repo_path.strip_suffix(".git").unwrap_or(repo_path); | ||
| 2775 | |||
| 2776 | // Check for traversal | ||
| 2777 | if repo_name.contains("..") || repo_name.contains('\0') { | ||
| 2778 | return None; | ||
| 2779 | } | ||
| 2780 | |||
| 2781 | let entry = crate::server::repos::resolve(repos_dir, repo_name)?; | ||
| 2782 | |||
| 2783 | if entry.bare { | ||
| 2784 | Some(entry.path) | ||
| 2785 | } else { | ||
| 2786 | Some(entry.path.join(".git")) | ||
| 2787 | } | ||
| 2788 | } | ||
| 2789 | |||
| 2790 | #[cfg(test)] | ||
| 2791 | mod tests { | ||
| 2792 | use super::*; | ||
| 2793 | |||
| 2794 | #[test] | ||
| 2795 | fn parse_upload_pack() { | ||
| 2796 | let (cmd, path) = parse_git_command("git-upload-pack '/srv/git/repo.git'").unwrap(); | ||
| 2797 | assert_eq!(cmd, "git-upload-pack"); | ||
| 2798 | assert_eq!(path, "/srv/git/repo.git"); | ||
| 2799 | } | ||
| 2800 | |||
| 2801 | #[test] | ||
| 2802 | fn parse_receive_pack() { | ||
| 2803 | let (cmd, path) = parse_git_command("git-receive-pack '/srv/git/repo.git'").unwrap(); | ||
| 2804 | assert_eq!(cmd, "git-receive-pack"); | ||
| 2805 | assert_eq!(path, "/srv/git/repo.git"); | ||
| 2806 | } | ||
| 2807 | |||
| 2808 | #[test] | ||
| 2809 | fn reject_unknown_command() { | ||
| 2810 | assert!(parse_git_command("rm -rf /").is_none()); | ||
| 2811 | assert!(parse_git_command("ls /srv").is_none()); | ||
| 2812 | } | ||
| 2813 | |||
| 2814 | #[test] | ||
| 2815 | fn reject_traversal() { | ||
| 2816 | let tmp = tempfile::TempDir::new().unwrap(); | ||
| 2817 | assert!(resolve_repo_path(tmp.path(), "../../../etc/passwd").is_none()); | ||
| 2818 | assert!(resolve_repo_path(tmp.path(), "repo/../../../etc/passwd").is_none()); | ||
| 2819 | } | ||
| 2820 | } | ||
| 2821 | ``` | ||
| 2822 | |||
| 2823 | - [ ] **Step 2: Run tests** | ||
| 2824 | |||
| 2825 | Run: `cargo test --lib server::ssh::session` | ||
| 2826 | Expected: 4 tests pass. | ||
| 2827 | |||
| 2828 | - [ ] **Step 3: Add SSH server startup to ssh/mod.rs** | ||
| 2829 | |||
| 2830 | Update `src/server/ssh/mod.rs`: | ||
| 2831 | |||
| 2832 | ```rust | ||
| 2833 | pub mod auth; | ||
| 2834 | pub mod session; | ||
| 2835 | |||
| 2836 | use std::path::PathBuf; | ||
| 2837 | use std::sync::Arc; | ||
| 2838 | |||
| 2839 | use session::SshServerConfig; | ||
| 2840 | |||
| 2841 | /// Generate or load the server host key. | ||
| 2842 | pub fn load_or_generate_host_key( | ||
| 2843 | key_path: &std::path::Path, | ||
| 2844 | ) -> Result<russh_keys::key::KeyPair, Box<dyn std::error::Error>> { | ||
| 2845 | if key_path.exists() { | ||
| 2846 | let key = russh_keys::load_secret_key(key_path, None)?; | ||
| 2847 | Ok(key) | ||
| 2848 | } else { | ||
| 2849 | let key = russh_keys::key::KeyPair::generate_ed25519().unwrap(); | ||
| 2850 | if let Some(parent) = key_path.parent() { | ||
| 2851 | std::fs::create_dir_all(parent)?; | ||
| 2852 | } | ||
| 2853 | russh_keys::encode_pkcs8_pem(&key, key_path)?; | ||
| 2854 | Ok(key) | ||
| 2855 | } | ||
| 2856 | } | ||
| 2857 | |||
| 2858 | /// Start the SSH server. | ||
| 2859 | pub async fn serve( | ||
| 2860 | bind: std::net::SocketAddr, | ||
| 2861 | host_key: russh_keys::key::KeyPair, | ||
| 2862 | ssh_config: Arc<SshServerConfig>, | ||
| 2863 | ) -> Result<(), Box<dyn std::error::Error>> { | ||
| 2864 | let config = russh::server::Config { | ||
| 2865 | keys: vec![host_key], | ||
| 2866 | ..Default::default() | ||
| 2867 | }; | ||
| 2868 | let config = Arc::new(config); | ||
| 2869 | |||
| 2870 | let server = Server { ssh_config }; | ||
| 2871 | russh::server::run(config, bind, server).await?; | ||
| 2872 | Ok(()) | ||
| 2873 | } | ||
| 2874 | |||
| 2875 | struct Server { | ||
| 2876 | ssh_config: Arc<SshServerConfig>, | ||
| 2877 | } | ||
| 2878 | |||
| 2879 | impl russh::server::Server for Server { | ||
| 2880 | type Handler = session::SshHandler; | ||
| 2881 | |||
| 2882 | fn new_client(&mut self, _peer_addr: Option<std::net::SocketAddr>) -> Self::Handler { | ||
| 2883 | session::SshHandler::new(self.ssh_config.clone()) | ||
| 2884 | } | ||
| 2885 | } | ||
| 2886 | ``` | ||
| 2887 | |||
| 2888 | - [ ] **Step 4: Verify compilation** | ||
| 2889 | |||
| 2890 | Run: `cargo build` | ||
| 2891 | Expected: Compiles (russh API may need adjustments depending on exact version — fix any trait method signature mismatches). | ||
| 2892 | |||
| 2893 | - [ ] **Step 5: Commit** | ||
| 2894 | |||
| 2895 | ```bash | ||
| 2896 | git add src/server/ssh/ | ||
| 2897 | git commit -m "add SSH server with key auth and git exec handling" | ||
| 2898 | ``` | ||
| 2899 | |||
| 2900 | --- | ||
| 2901 | |||
| 2902 | ### Task 15: Wire up main.rs — HTTP + SSH startup | ||
| 2903 | |||
| 2904 | **Files:** | ||
| 2905 | - Modify: `src/server/main.rs` | ||
| 2906 | |||
| 2907 | - [ ] **Step 1: Update main.rs to start both HTTP and SSH** | ||
| 2908 | |||
| 2909 | Replace `src/server/main.rs`: | ||
| 2910 | |||
| 2911 | ```rust | ||
| 2912 | use std::path::PathBuf; | ||
| 2913 | use std::sync::Arc; | ||
| 2914 | |||
| 2915 | use clap::Parser; | ||
| 2916 | |||
| 2917 | mod config; | ||
| 2918 | mod http; | ||
| 2919 | mod repos; | ||
| 2920 | mod ssh; | ||
| 2921 | |||
| 2922 | #[derive(Parser)] | ||
| 2923 | #[command(name = "git-collab-server", about = "Minimal git hosting server")] | ||
| 2924 | struct Args { | ||
| 2925 | /// Path to config file | ||
| 2926 | #[arg(short, long)] | ||
| 2927 | config: PathBuf, | ||
| 2928 | } | ||
| 2929 | |||
| 2930 | #[tokio::main] | ||
| 2931 | async fn main() { | ||
| 2932 | tracing_subscriber::fmt::init(); | ||
| 2933 | |||
| 2934 | let args = Args::parse(); | ||
| 2935 | let config = match config::ServerConfig::from_file(&args.config) { | ||
| 2936 | Ok(c) => c, | ||
| 2937 | Err(e) => { | ||
| 2938 | eprintln!("error: failed to load config {:?}: {}", args.config, e); | ||
| 2939 | std::process::exit(1); | ||
| 2940 | } | ||
| 2941 | }; | ||
| 2942 | |||
| 2943 | if !config.repos_dir.is_dir() { | ||
| 2944 | eprintln!("error: repos_dir {:?} is not a directory", config.repos_dir); | ||
| 2945 | std::process::exit(1); | ||
| 2946 | } | ||
| 2947 | |||
| 2948 | let repo_count = repos::discover(&config.repos_dir) | ||
| 2949 | .map(|r| r.len()) | ||
| 2950 | .unwrap_or(0); | ||
| 2951 | |||
| 2952 | // Load or generate SSH host key | ||
| 2953 | let key_dir = config.repos_dir.join(".server"); | ||
| 2954 | let key_path = key_dir.join("host_key"); | ||
| 2955 | let host_key = match ssh::load_or_generate_host_key(&key_path) { | ||
| 2956 | Ok(k) => k, | ||
| 2957 | Err(e) => { | ||
| 2958 | eprintln!("error: failed to load/generate SSH host key: {}", e); | ||
| 2959 | std::process::exit(1); | ||
| 2960 | } | ||
| 2961 | }; | ||
| 2962 | |||
| 2963 | let fingerprint = host_key.clone_public_key() | ||
| 2964 | .map(|pk| pk.fingerprint()) | ||
| 2965 | .unwrap_or_else(|| "unknown".to_string()); | ||
| 2966 | tracing::info!("SSH host key fingerprint: {}", fingerprint); | ||
| 2967 | |||
| 2968 | tracing::info!( | ||
| 2969 | "HTTP listening on {}, SSH listening on {}, serving {} repos from {:?}", | ||
| 2970 | config.http_bind, | ||
| 2971 | config.ssh_bind, | ||
| 2972 | repo_count, | ||
| 2973 | config.repos_dir | ||
| 2974 | ); | ||
| 2975 | |||
| 2976 | let app = http::router(http::AppState { | ||
| 2977 | repos_dir: config.repos_dir.clone(), | ||
| 2978 | site_title: config.site_title.clone(), | ||
| 2979 | }); | ||
| 2980 | |||
| 2981 | let ssh_config = Arc::new(ssh::session::SshServerConfig { | ||
| 2982 | repos_dir: config.repos_dir.clone(), | ||
| 2983 | authorized_keys_path: config.authorized_keys.clone(), | ||
| 2984 | }); | ||
| 2985 | |||
| 2986 | let http_bind = config.http_bind; | ||
| 2987 | let ssh_bind = config.ssh_bind; | ||
| 2988 | |||
| 2989 | let http_task = tokio::spawn(async move { | ||
| 2990 | let listener = tokio::net::TcpListener::bind(http_bind) | ||
| 2991 | .await | ||
| 2992 | .expect("failed to bind HTTP listener"); | ||
| 2993 | axum::serve(listener, app).await.expect("HTTP server error"); | ||
| 2994 | }); | ||
| 2995 | |||
| 2996 | let ssh_task = tokio::spawn(async move { | ||
| 2997 | if let Err(e) = ssh::serve(ssh_bind, host_key, ssh_config).await { | ||
| 2998 | tracing::error!("SSH server error: {}", e); | ||
| 2999 | } | ||
| 3000 | }); | ||
| 3001 | |||
| 3002 | tokio::select! { | ||
| 3003 | _ = http_task => { tracing::error!("HTTP server exited"); } | ||
| 3004 | _ = ssh_task => { tracing::error!("SSH server exited"); } | ||
| 3005 | } | ||
| 3006 | } | ||
| 3007 | ``` | ||
| 3008 | |||
| 3009 | - [ ] **Step 2: Verify compilation** | ||
| 3010 | |||
| 3011 | Run: `cargo build` | ||
| 3012 | Expected: Compiles. | ||
| 3013 | |||
| 3014 | - [ ] **Step 3: Full integration smoke test** | ||
| 3015 | |||
| 3016 | ```bash | ||
| 3017 | mkdir -p /tmp/test-repos | ||
| 3018 | rm -rf /tmp/test-repos/hello.git | ||
| 3019 | git init --bare /tmp/test-repos/hello.git | ||
| 3020 | |||
| 3021 | cat > /tmp/test-server.toml <<'EOF' | ||
| 3022 | repos_dir = "/tmp/test-repos" | ||
| 3023 | http_bind = "127.0.0.1:18080" | ||
| 3024 | ssh_bind = "127.0.0.1:12222" | ||
| 3025 | authorized_keys = "/dev/null" | ||
| 3026 | EOF | ||
| 3027 | |||
| 3028 | cargo run --bin git-collab-server -- --config /tmp/test-server.toml & | ||
| 3029 | SERVER_PID=$! | ||
| 3030 | sleep 2 | ||
| 3031 | |||
| 3032 | # Test web UI | ||
| 3033 | curl -s http://127.0.0.1:18080/ | grep -q "hello" && echo "PASS: repo list" || echo "FAIL: repo list" | ||
| 3034 | curl -s http://127.0.0.1:18080/hello | grep -q "Commits" || echo "NOTE: overview may be empty (no commits)" | ||
| 3035 | |||
| 3036 | kill $SERVER_PID | ||
| 3037 | ``` | ||
| 3038 | |||
| 3039 | Expected: Server starts, web UI responds. | ||
| 3040 | |||
| 3041 | - [ ] **Step 4: Commit** | ||
| 3042 | |||
| 3043 | ```bash | ||
| 3044 | git add src/server/main.rs | ||
| 3045 | git commit -m "wire up HTTP + SSH concurrent startup in main" | ||
| 3046 | ``` | ||
| 3047 | |||
| 3048 | --- | ||
| 3049 | |||
| 3050 | ### Task 16: Makefile updates | ||
| 3051 | |||
| 3052 | **Files:** | ||
| 3053 | - Modify: `Makefile` | ||
| 3054 | |||
| 3055 | - [ ] **Step 1: Add server targets to Makefile** | ||
| 3056 | |||
| 3057 | Add to the Makefile: | ||
| 3058 | |||
| 3059 | ```makefile | ||
| 3060 | SERVER_BIN := git-collab-server | ||
| 3061 | |||
| 3062 | # Add to install target | ||
| 3063 | install-server: PROFILE := release | ||
| 3064 | install-server: build | ||
| 3065 | install -Dm755 target/release/$(SERVER_BIN) $(PREFIX)/bin/$(SERVER_BIN) | ||
| 3066 | |||
| 3067 | # Dev helper | ||
| 3068 | serve: | ||
| 3069 | $(CARGO) run --bin $(SERVER_BIN) -- $(ARGS) | ||
| 3070 | ``` | ||
| 3071 | |||
| 3072 | - [ ] **Step 2: Update the existing install target to include the server** | ||
| 3073 | |||
| 3074 | Modify the `install` target to also install the server binary: | ||
| 3075 | |||
| 3076 | ```makefile | ||
| 3077 | install: PROFILE := release | ||
| 3078 | install: build install-man | ||
| 3079 | install -Dm755 target/release/$(BIN) $(PREFIX)/bin/$(BIN) | ||
| 3080 | install -Dm755 target/release/$(SERVER_BIN) $(PREFIX)/bin/$(SERVER_BIN) | ||
| 3081 | ``` | ||
| 3082 | |||
| 3083 | - [ ] **Step 3: Verify make targets** | ||
| 3084 | |||
| 3085 | Run: `make build` | ||
| 3086 | Expected: Both binaries compile. | ||
| 3087 | |||
| 3088 | - [ ] **Step 4: Commit** | ||
| 3089 | |||
| 3090 | ```bash | ||
| 3091 | git add Makefile | ||
| 3092 | git commit -m "add server build and install targets to Makefile" | ||
| 3093 | ``` | ||
| 3094 | |||
| 3095 | --- | ||
| 3096 | |||
| 3097 | ### Task 17: Final verification — clippy + all tests | ||
| 3098 | |||
| 3099 | **Files:** None (verification only) | ||
| 3100 | |||
| 3101 | - [ ] **Step 1: Run clippy** | ||
| 3102 | |||
| 3103 | Run: `cargo clippy -- -D warnings` | ||
| 3104 | Expected: No warnings. Fix any that appear. | ||
| 3105 | |||
| 3106 | - [ ] **Step 2: Run all tests** | ||
| 3107 | |||
| 3108 | Run: `cargo test` | ||
| 3109 | Expected: All tests pass — existing tests + new config/repos/auth/session tests. | ||
| 3110 | |||
| 3111 | - [ ] **Step 3: Commit any fixes** | ||
| 3112 | |||
| 3113 | If clippy or tests required fixes, commit them: | ||
| 3114 | |||
| 3115 | ```bash | ||
| 3116 | git add -u | ||
| 3117 | git commit -m "fix clippy warnings and test issues" | ||
| 3118 | ``` | ||
docs/superpowers/specs/2026-03-30-git-collab-server-design.md
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,204 @@ | |||
| 1 | # git-collab-server Design | ||
| 2 | |||
| 3 | A minimal, self-contained git hosting server — "Gerrit lite" — companion to the git-collab CLI. | ||
| 4 | |||
| 5 | ## Goals | ||
| 6 | |||
| 7 | - Web UI for read-only browsing of code and collab objects (patches, reviews, issues) | ||
| 8 | - HTTPS read-only clone (anonymous, no auth) | ||
| 9 | - SSH read/write clone (key-based auth) | ||
| 10 | - Single binary, zero external dependencies | ||
| 11 | - Multi-repo hosting (serve a directory of repos) | ||
| 12 | |||
| 13 | ## Non-goals | ||
| 14 | |||
| 15 | - Web-based write actions (reviews, comments via browser) | ||
| 16 | - HTTPS push | ||
| 17 | - User accounts, permissions, or access control beyond SSH key auth | ||
| 18 | - Syntax highlighting in v1 | ||
| 19 | |||
| 20 | ## Architecture | ||
| 21 | |||
| 22 | ### Binary | ||
| 23 | |||
| 24 | `git-collab-server` — a second `[[bin]]` in the existing Cargo workspace. Shares the `git-collab` lib crate to read patches, issues, reviews, events without duplication. | ||
| 25 | |||
| 26 | ### Source Layout | ||
| 27 | |||
| 28 | ``` | ||
| 29 | src/ | ||
| 30 | server/ | ||
| 31 | main.rs ← entry point: parse config, start HTTP + SSH | ||
| 32 | config.rs ← parse collab-server.toml | ||
| 33 | http/ | ||
| 34 | mod.rs ← axum router setup | ||
| 35 | repo_list.rs ← GET / — list repos | ||
| 36 | repo.rs ← GET /:repo/* — repo pages | ||
| 37 | git_http.rs ← git smart HTTP protocol (clone) | ||
| 38 | templates/ ← askama HTML templates | ||
| 39 | ssh/ | ||
| 40 | mod.rs ← russh server setup | ||
| 41 | auth.rs ← authorized_keys parsing + key verification | ||
| 42 | session.rs ← exec handler (git-upload-pack / git-receive-pack) | ||
| 43 | ``` | ||
| 44 | |||
| 45 | ### Dependencies | ||
| 46 | |||
| 47 | New dependencies (server-only, gated behind `[[bin]]`): | ||
| 48 | |||
| 49 | ```toml | ||
| 50 | axum = "0.8" | ||
| 51 | tokio = { version = "1", features = ["full"] } | ||
| 52 | askama = "0.12" | ||
| 53 | russh = "0.44" | ||
| 54 | russh-keys = "0.44" | ||
| 55 | toml = "0.8" | ||
| 56 | tracing = "0.1" | ||
| 57 | tracing-subscriber = "0.3" | ||
| 58 | ``` | ||
| 59 | |||
| 60 | ## Configuration | ||
| 61 | |||
| 62 | File: `collab-server.toml`, passed via `--config` flag. | ||
| 63 | |||
| 64 | ```toml | ||
| 65 | repos_dir = "/srv/git" | ||
| 66 | http_bind = "0.0.0.0:8080" | ||
| 67 | ssh_bind = "0.0.0.0:2222" | ||
| 68 | authorized_keys = "/etc/git-collab-server/authorized_keys" | ||
| 69 | site_title = "my git server" # optional | ||
| 70 | ``` | ||
| 71 | |||
| 72 | ## HTTP Layer | ||
| 73 | |||
| 74 | Two responsibilities on the same axum router. | ||
| 75 | |||
| 76 | ### Web UI Routes | ||
| 77 | |||
| 78 | ``` | ||
| 79 | GET / → repo list | ||
| 80 | GET /:repo → repo overview (recent commits, open patches, open issues) | ||
| 81 | GET /:repo/commits → full commit log | ||
| 82 | GET /:repo/tree → file tree at HEAD | ||
| 83 | GET /:repo/tree/:ref/*path → browse directory | ||
| 84 | GET /:repo/blob/:ref/*path → file contents | ||
| 85 | GET /:repo/diff/:oid → single commit diff | ||
| 86 | GET /:repo/patches → patch list | ||
| 87 | GET /:repo/patches/:id → patch detail (revisions, reviews, inline comments) | ||
| 88 | GET /:repo/issues → issue list | ||
| 89 | GET /:repo/issues/:id → issue detail | ||
| 90 | ``` | ||
| 91 | |||
| 92 | ### Git Smart HTTP (Clone Only) | ||
| 93 | |||
| 94 | ``` | ||
| 95 | GET /:repo.git/info/refs?service=git-upload-pack | ||
| 96 | POST /:repo.git/git-upload-pack | ||
| 97 | ``` | ||
| 98 | |||
| 99 | Spawns `git upload-pack --stateless-rpc` as a subprocess. No auth required. No push over HTTP. | ||
| 100 | |||
| 101 | ### Rendering | ||
| 102 | |||
| 103 | Askama templates compiled at build time. Each route reads collab data via the existing lib (`patch::list`, `issue::list`, etc.) and git data via `git2`. | ||
| 104 | |||
| 105 | ## SSH Layer | ||
| 106 | |||
| 107 | russh-based embedded SSH server. | ||
| 108 | |||
| 109 | ### Auth | ||
| 110 | |||
| 111 | - Public key only. No passwords. | ||
| 112 | - Keys checked against the `authorized_keys` file. Custom parser (simple format: `ssh-ed25519 <base64-key> <comment>`, one per line). | ||
| 113 | - `#` lines and blanks ignored. | ||
| 114 | - File reloaded on SIGHUP — no restart needed to add/remove users. | ||
| 115 | |||
| 116 | ### Session Exec | ||
| 117 | |||
| 118 | Only two commands allowed, anything else is rejected: | ||
| 119 | |||
| 120 | ``` | ||
| 121 | git-upload-pack '/path/to/repo' ← clone/fetch | ||
| 122 | git-receive-pack '/path/to/repo' ← push | ||
| 123 | ``` | ||
| 124 | |||
| 125 | Repo path argument validated against `repos_dir` to prevent path traversal — must resolve to a subdirectory of the configured repos root. | ||
| 126 | |||
| 127 | ### Host Key | ||
| 128 | |||
| 129 | - Ed25519 key generated at first startup, saved to `<config_dir>/server_host_key`. | ||
| 130 | - Fingerprint printed on startup for operator verification. | ||
| 131 | |||
| 132 | ### Push Semantics | ||
| 133 | |||
| 134 | Push lands commits on branches as normal git. Collab refs (`refs/collab/`) are pushed via `git-collab sync` as usual — the server treats them as normal refs with no special handling. | ||
| 135 | |||
| 136 | ## Web UI Design | ||
| 137 | |||
| 138 | Sidebar navigation (Gerrit-style layout). | ||
| 139 | |||
| 140 | ### Page Layout | ||
| 141 | |||
| 142 | ``` | ||
| 143 | ┌─────────────────────────────────────────────┐ | ||
| 144 | │ site_title repo_name │ | ||
| 145 | ├────────┬────────────────────────────────────┤ | ||
| 146 | │ │ │ | ||
| 147 | │ commits│ (main content area) │ | ||
| 148 | │ tree │ │ | ||
| 149 | │ ────── │ │ | ||
| 150 | │ patches│ │ | ||
| 151 | │ issues │ │ | ||
| 152 | │ │ │ | ||
| 153 | ├────────┴────────────────────────────────────┤ | ||
| 154 | │ git-collab-server │ | ||
| 155 | └─────────────────────────────────────────────┘ | ||
| 156 | ``` | ||
| 157 | |||
| 158 | - Sidebar sections: commits, tree, (divider), patches (open count badge), issues (open count badge) | ||
| 159 | - Active section highlighted | ||
| 160 | - Repo list page (`/`) has no sidebar — simple list with repo name, last commit date, description | ||
| 161 | |||
| 162 | ### Styling | ||
| 163 | |||
| 164 | - Single embedded `<style>` in base template | ||
| 165 | - Dark theme, monospace for code, system fonts for nav | ||
| 166 | - No external CSS/JS CDN, no JavaScript dependencies | ||
| 167 | - Diffs rendered as colored `<pre>` blocks (green/red lines) | ||
| 168 | - File contents as plain `<pre>` (no syntax highlighting in v1) | ||
| 169 | |||
| 170 | ## Repo Discovery | ||
| 171 | |||
| 172 | - Scans `repos_dir` for directories containing a `HEAD` file (bare repos) or `.git` subdirectory (non-bare) | ||
| 173 | - Re-scans on each request (cheap readdir, no watcher) | ||
| 174 | - Repo name = directory name, minus `.git` suffix if present | ||
| 175 | |||
| 176 | ## Startup Sequence | ||
| 177 | |||
| 178 | 1. Parse CLI args (`--config collab-server.toml`) via clap | ||
| 179 | 2. Load and validate config (`repos_dir` must exist) | ||
| 180 | 3. Generate or load SSH host key | ||
| 181 | 4. Print host key fingerprint | ||
| 182 | 5. Start HTTP and SSH servers concurrently via `tokio::select!` | ||
| 183 | 6. Log: `HTTP listening on ..., SSH listening on ..., serving N repos from /path` | ||
| 184 | |||
| 185 | ## Error Handling | ||
| 186 | |||
| 187 | - Invalid repo in URL → 404 | ||
| 188 | - Git errors → 500 with simple error template | ||
| 189 | - SSH bad command → channel close with error message | ||
| 190 | - Bad config at startup → exit with clear message to stderr | ||
| 191 | |||
| 192 | ## Logging | ||
| 193 | |||
| 194 | `tracing` crate for structured logs: | ||
| 195 | - Startup info (bind addresses, repo count, host key fingerprint) | ||
| 196 | - SSH auth events (accepted/rejected + key fingerprint) | ||
| 197 | - HTTP request log | ||
| 198 | |||
| 199 | ## Auth Model | ||
| 200 | |||
| 201 | Two separate concerns: | ||
| 202 | |||
| 203 | - **SSH access:** Server-level `authorized_keys` file controls who can connect and push/pull over SSH. | ||
| 204 | - **Collab event signing:** Per-repo `.git/collab/trusted-keys` controls whose collab events (patches, reviews, issues) are trusted. This is unchanged from the CLI tool. | ||
src/server/config.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,82 @@ | |||
| 1 | use std::net::SocketAddr; | ||
| 2 | use std::path::PathBuf; | ||
| 3 | |||
| 4 | use serde::Deserialize; | ||
| 5 | |||
| 6 | #[derive(Debug, Clone, Deserialize)] | ||
| 7 | pub struct ServerConfig { | ||
| 8 | pub repos_dir: PathBuf, | ||
| 9 | #[serde(default = "default_http_bind")] | ||
| 10 | pub http_bind: SocketAddr, | ||
| 11 | #[serde(default = "default_ssh_bind")] | ||
| 12 | pub ssh_bind: SocketAddr, | ||
| 13 | pub authorized_keys: PathBuf, | ||
| 14 | #[serde(default = "default_site_title")] | ||
| 15 | pub site_title: String, | ||
| 16 | } | ||
| 17 | |||
| 18 | fn default_http_bind() -> SocketAddr { | ||
| 19 | "0.0.0.0:8080".parse().unwrap() | ||
| 20 | } | ||
| 21 | |||
| 22 | fn default_ssh_bind() -> SocketAddr { | ||
| 23 | "0.0.0.0:2222".parse().unwrap() | ||
| 24 | } | ||
| 25 | |||
| 26 | fn default_site_title() -> String { | ||
| 27 | "git-collab".to_string() | ||
| 28 | } | ||
| 29 | |||
| 30 | impl ServerConfig { | ||
| 31 | pub fn from_toml(content: &str) -> Result<Self, toml_edit::de::Error> { | ||
| 32 | toml_edit::de::from_str(content) | ||
| 33 | } | ||
| 34 | |||
| 35 | pub fn from_file(path: &std::path::Path) -> Result<Self, Box<dyn std::error::Error>> { | ||
| 36 | let content = std::fs::read_to_string(path)?; | ||
| 37 | Ok(Self::from_toml(&content)?) | ||
| 38 | } | ||
| 39 | } | ||
| 40 | |||
| 41 | #[cfg(test)] | ||
| 42 | mod tests { | ||
| 43 | use super::*; | ||
| 44 | |||
| 45 | #[test] | ||
| 46 | fn parse_full_config() { | ||
| 47 | let toml = r#" | ||
| 48 | repos_dir = "/srv/git" | ||
| 49 | http_bind = "127.0.0.1:3000" | ||
| 50 | ssh_bind = "127.0.0.1:2222" | ||
| 51 | authorized_keys = "/etc/git-collab-server/authorized_keys" | ||
| 52 | site_title = "my repos" | ||
| 53 | "#; | ||
| 54 | let config = ServerConfig::from_toml(toml).unwrap(); | ||
| 55 | assert_eq!(config.repos_dir, PathBuf::from("/srv/git")); | ||
| 56 | assert_eq!(config.http_bind, "127.0.0.1:3000".parse::<SocketAddr>().unwrap()); | ||
| 57 | assert_eq!(config.ssh_bind, "127.0.0.1:2222".parse::<SocketAddr>().unwrap()); | ||
| 58 | assert_eq!(config.authorized_keys, PathBuf::from("/etc/git-collab-server/authorized_keys")); | ||
| 59 | assert_eq!(config.site_title, "my repos"); | ||
| 60 | } | ||
| 61 | |||
| 62 | #[test] | ||
| 63 | fn parse_minimal_config_uses_defaults() { | ||
| 64 | let toml = r#" | ||
| 65 | repos_dir = "/srv/git" | ||
| 66 | authorized_keys = "/keys" | ||
| 67 | "#; | ||
| 68 | let config = ServerConfig::from_toml(toml).unwrap(); | ||
| 69 | assert_eq!(config.repos_dir, PathBuf::from("/srv/git")); | ||
| 70 | assert_eq!(config.http_bind, "0.0.0.0:8080".parse::<SocketAddr>().unwrap()); | ||
| 71 | assert_eq!(config.ssh_bind, "0.0.0.0:2222".parse::<SocketAddr>().unwrap()); | ||
| 72 | assert_eq!(config.site_title, "git-collab"); | ||
| 73 | } | ||
| 74 | |||
| 75 | #[test] | ||
| 76 | fn parse_missing_required_field_fails() { | ||
| 77 | let toml = r#" | ||
| 78 | authorized_keys = "/keys" | ||
| 79 | "#; | ||
| 80 | assert!(ServerConfig::from_toml(toml).is_err()); | ||
| 81 | } | ||
| 82 | } | ||
src/server/http/git_http.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,166 @@ | |||
| 1 | use std::sync::Arc; | ||
| 2 | |||
| 3 | use axum::body::Bytes; | ||
| 4 | use axum::extract::{Path, Query, State}; | ||
| 5 | use axum::http::{HeaderValue, StatusCode}; | ||
| 6 | use axum::response::{IntoResponse, Response}; | ||
| 7 | use tokio::io::AsyncWriteExt; | ||
| 8 | use tokio::process::Command; | ||
| 9 | |||
| 10 | use super::AppState; | ||
| 11 | |||
| 12 | /// Max request body for git upload-pack negotiation (10 MiB). | ||
| 13 | /// Git smart HTTP negotiation payloads are typically small (a few KiB of want/have lines), | ||
| 14 | /// but we allow headroom for repos with many refs. | ||
| 15 | pub const UPLOAD_PACK_BODY_LIMIT: usize = 10 * 1024 * 1024; | ||
| 16 | |||
| 17 | #[derive(serde::Deserialize)] | ||
| 18 | pub struct InfoRefsQuery { | ||
| 19 | pub service: Option<String>, | ||
| 20 | } | ||
| 21 | |||
| 22 | pub async fn info_refs( | ||
| 23 | Path(repo_dot_git): Path<String>, | ||
| 24 | Query(query): Query<InfoRefsQuery>, | ||
| 25 | State(state): State<Arc<AppState>>, | ||
| 26 | ) -> Response { | ||
| 27 | let service = match query.service.as_deref() { | ||
| 28 | Some("git-upload-pack") => "git-upload-pack", | ||
| 29 | _ => { | ||
| 30 | return (StatusCode::FORBIDDEN, "Only git-upload-pack is supported").into_response(); | ||
| 31 | } | ||
| 32 | }; | ||
| 33 | |||
| 34 | let repo_name = repo_dot_git.strip_suffix(".git").unwrap_or(&repo_dot_git); | ||
| 35 | |||
| 36 | let entry = match crate::repos::resolve(&state.repos_dir, repo_name) { | ||
| 37 | Some(e) => e, | ||
| 38 | None => return (StatusCode::NOT_FOUND, format!("Repository '{}' not found.", repo_name)).into_response(), | ||
| 39 | }; | ||
| 40 | |||
| 41 | let git_dir = if entry.bare { | ||
| 42 | entry.path.clone() | ||
| 43 | } else { | ||
| 44 | entry.path.join(".git") | ||
| 45 | }; | ||
| 46 | |||
| 47 | let output = match Command::new("git") | ||
| 48 | .args(["upload-pack", "--stateless-rpc", "--advertise-refs"]) | ||
| 49 | .arg(&git_dir) | ||
| 50 | .output() | ||
| 51 | .await | ||
| 52 | { | ||
| 53 | Ok(o) => o, | ||
| 54 | Err(e) => { | ||
| 55 | tracing::error!("Failed to spawn git upload-pack: {}", e); | ||
| 56 | return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to run git upload-pack").into_response(); | ||
| 57 | } | ||
| 58 | }; | ||
| 59 | |||
| 60 | if !output.status.success() { | ||
| 61 | tracing::error!( | ||
| 62 | "git upload-pack --advertise-refs exited with status {}: {}", | ||
| 63 | output.status, | ||
| 64 | String::from_utf8_lossy(&output.stderr) | ||
| 65 | ); | ||
| 66 | return (StatusCode::INTERNAL_SERVER_ERROR, "git upload-pack failed").into_response(); | ||
| 67 | } | ||
| 68 | |||
| 69 | // Prepend pkt-line service announcement: | ||
| 70 | // "{4-hex-length}# service=git-upload-pack\n" + "0000" | ||
| 71 | let service_line = format!("# service={}\n", service); | ||
| 72 | let pkt_len = service_line.len() + 4; // +4 for the 4 hex digits themselves | ||
| 73 | let pkt_prefix = format!("{:04x}{}", pkt_len, service_line); | ||
| 74 | let flush = b"0000"; | ||
| 75 | |||
| 76 | let mut body = Vec::new(); | ||
| 77 | body.extend_from_slice(pkt_prefix.as_bytes()); | ||
| 78 | body.extend_from_slice(flush); | ||
| 79 | body.extend_from_slice(&output.stdout); | ||
| 80 | |||
| 81 | ( | ||
| 82 | StatusCode::OK, | ||
| 83 | [ | ||
| 84 | ("Content-Type", "application/x-git-upload-pack-advertisement"), | ||
| 85 | ("Cache-Control", "no-cache"), | ||
| 86 | ], | ||
| 87 | body, | ||
| 88 | ) | ||
| 89 | .into_response() | ||
| 90 | } | ||
| 91 | |||
| 92 | pub async fn upload_pack( | ||
| 93 | Path(repo_dot_git): Path<String>, | ||
| 94 | State(state): State<Arc<AppState>>, | ||
| 95 | body: Bytes, | ||
| 96 | ) -> Response { | ||
| 97 | let repo_name = repo_dot_git.strip_suffix(".git").unwrap_or(&repo_dot_git); | ||
| 98 | |||
| 99 | let entry = match crate::repos::resolve(&state.repos_dir, repo_name) { | ||
| 100 | Some(e) => e, | ||
| 101 | None => return (StatusCode::NOT_FOUND, format!("Repository '{}' not found.", repo_name)).into_response(), | ||
| 102 | }; | ||
| 103 | |||
| 104 | let git_dir = if entry.bare { | ||
| 105 | entry.path.clone() | ||
| 106 | } else { | ||
| 107 | entry.path.join(".git") | ||
| 108 | }; | ||
| 109 | |||
| 110 | let mut child = match Command::new("git") | ||
| 111 | .args(["upload-pack", "--stateless-rpc"]) | ||
| 112 | .arg(&git_dir) | ||
| 113 | .stdin(std::process::Stdio::piped()) | ||
| 114 | .stdout(std::process::Stdio::piped()) | ||
| 115 | .stderr(std::process::Stdio::piped()) | ||
| 116 | .spawn() | ||
| 117 | { | ||
| 118 | Ok(c) => c, | ||
| 119 | Err(e) => { | ||
| 120 | tracing::error!("Failed to spawn git upload-pack: {}", e); | ||
| 121 | return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to run git upload-pack").into_response(); | ||
| 122 | } | ||
| 123 | }; | ||
| 124 | |||
| 125 | if let Some(mut stdin) = child.stdin.take() { | ||
| 126 | if let Err(e) = stdin.write_all(&body).await { | ||
| 127 | tracing::error!("Failed to write to git upload-pack stdin: {}", e); | ||
| 128 | return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to communicate with git upload-pack").into_response(); | ||
| 129 | } | ||
| 130 | } | ||
| 131 | |||
| 132 | let output = match child.wait_with_output().await { | ||
| 133 | Ok(o) => o, | ||
| 134 | Err(e) => { | ||
| 135 | tracing::error!("Failed to wait for git upload-pack: {}", e); | ||
| 136 | return (StatusCode::INTERNAL_SERVER_ERROR, "git upload-pack failed").into_response(); | ||
| 137 | } | ||
| 138 | }; | ||
| 139 | |||
| 140 | if !output.status.success() { | ||
| 141 | tracing::error!( | ||
| 142 | "git upload-pack exited with status {}: {}", | ||
| 143 | output.status, | ||
| 144 | String::from_utf8_lossy(&output.stderr) | ||
| 145 | ); | ||
| 146 | return (StatusCode::INTERNAL_SERVER_ERROR, "git upload-pack failed").into_response(); | ||
| 147 | } | ||
| 148 | |||
| 149 | let mut response = ( | ||
| 150 | StatusCode::OK, | ||
| 151 | output.stdout, | ||
| 152 | ) | ||
| 153 | .into_response(); | ||
| 154 | |||
| 155 | let headers = response.headers_mut(); | ||
| 156 | headers.insert( | ||
| 157 | "Content-Type", | ||
| 158 | HeaderValue::from_static("application/x-git-upload-pack-result"), | ||
| 159 | ); | ||
| 160 | headers.insert( | ||
| 161 | "Cache-Control", | ||
| 162 | HeaderValue::from_static("no-cache"), | ||
| 163 | ); | ||
| 164 | |||
| 165 | response | ||
| 166 | } | ||
src/server/http/mod.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,34 @@ | |||
| 1 | pub mod git_http; | ||
| 2 | pub mod repo_list; | ||
| 3 | pub mod repo; | ||
| 4 | |||
| 5 | use std::path::PathBuf; | ||
| 6 | use std::sync::Arc; | ||
| 7 | use axum::extract::DefaultBodyLimit; | ||
| 8 | use axum::Router; | ||
| 9 | |||
| 10 | #[derive(Debug, Clone)] | ||
| 11 | pub struct AppState { | ||
| 12 | pub repos_dir: PathBuf, | ||
| 13 | pub site_title: String, | ||
| 14 | } | ||
| 15 | |||
| 16 | pub fn router(state: AppState) -> Router { | ||
| 17 | let shared = Arc::new(state); | ||
| 18 | Router::new() | ||
| 19 | .route("/", axum::routing::get(repo_list::handler)) | ||
| 20 | .route("/{repo_name}", axum::routing::get(repo::overview)) | ||
| 21 | .route("/{repo_name}/commits", axum::routing::get(repo::commits)) | ||
| 22 | .route("/{repo_name}/tree", axum::routing::get(repo::tree_root)) | ||
| 23 | .route("/{repo_name}/tree/{*rest}", axum::routing::get(repo::tree)) | ||
| 24 | .route("/{repo_name}/blob/{*rest}", axum::routing::get(repo::blob)) | ||
| 25 | .route("/{repo_name}/diff/{oid}", axum::routing::get(repo::diff)) | ||
| 26 | .route("/{repo_name}/patches", axum::routing::get(repo::patches)) | ||
| 27 | .route("/{repo_name}/patches/{id}", axum::routing::get(repo::patch_detail)) | ||
| 28 | .route("/{repo_name}/issues", axum::routing::get(repo::issues)) | ||
| 29 | .route("/{repo_name}/issues/{id}", axum::routing::get(repo::issue_detail)) | ||
| 30 | .route("/{repo_dot_git}/info/refs", axum::routing::get(git_http::info_refs)) | ||
| 31 | .route("/{repo_dot_git}/git-upload-pack", axum::routing::post(git_http::upload_pack) | ||
| 32 | .layer(DefaultBodyLimit::max(git_http::UPLOAD_PACK_BODY_LIMIT))) | ||
| 33 | .with_state(shared) | ||
| 34 | } | ||
src/server/http/repo/commits.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,40 @@ | |||
| 1 | use std::sync::Arc; | ||
| 2 | |||
| 3 | use axum::extract::{Path, State}; | ||
| 4 | use axum::response::{IntoResponse, Response}; | ||
| 5 | |||
| 6 | use super::{AppState, OverviewCommit, collab_counts, open_repo, recent_commits}; | ||
| 7 | |||
| 8 | #[derive(askama::Template, askama_web::WebTemplate)] | ||
| 9 | #[template(path = "commits.html")] | ||
| 10 | pub struct CommitsTemplate { | ||
| 11 | pub site_title: String, | ||
| 12 | pub repo_name: String, | ||
| 13 | pub active_section: String, | ||
| 14 | pub open_patches: usize, | ||
| 15 | pub open_issues: usize, | ||
| 16 | pub commits: Vec<OverviewCommit>, | ||
| 17 | } | ||
| 18 | |||
| 19 | pub async fn commits( | ||
| 20 | Path(repo_name): Path<String>, | ||
| 21 | State(state): State<Arc<AppState>>, | ||
| 22 | ) -> Response { | ||
| 23 | let (_entry, repo) = match open_repo(&state, &repo_name) { | ||
| 24 | Ok(r) => r, | ||
| 25 | Err(resp) => return resp, | ||
| 26 | }; | ||
| 27 | |||
| 28 | let commits = recent_commits(&repo, 200); | ||
| 29 | let (open_patches, open_issues) = collab_counts(&repo); | ||
| 30 | |||
| 31 | CommitsTemplate { | ||
| 32 | site_title: state.site_title.clone(), | ||
| 33 | repo_name, | ||
| 34 | active_section: "commits".to_string(), | ||
| 35 | open_patches, | ||
| 36 | open_issues, | ||
| 37 | commits, | ||
| 38 | } | ||
| 39 | .into_response() | ||
| 40 | } | ||
src/server/http/repo/diff.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,127 @@ | |||
| 1 | use std::sync::Arc; | ||
| 2 | |||
| 3 | use axum::extract::{Path, State}; | ||
| 4 | use axum::response::{IntoResponse, Response}; | ||
| 5 | use chrono::{TimeZone, Utc}; | ||
| 6 | |||
| 7 | use super::{AppState, collab_counts, not_found, open_repo}; | ||
| 8 | |||
| 9 | #[derive(Debug)] | ||
| 10 | pub struct DiffLine { | ||
| 11 | pub kind: String, | ||
| 12 | pub text: String, | ||
| 13 | } | ||
| 14 | |||
| 15 | #[derive(askama::Template, askama_web::WebTemplate)] | ||
| 16 | #[template(path = "diff.html")] | ||
| 17 | pub struct DiffTemplate { | ||
| 18 | pub site_title: String, | ||
| 19 | pub repo_name: String, | ||
| 20 | pub active_section: String, | ||
| 21 | pub open_patches: usize, | ||
| 22 | pub open_issues: usize, | ||
| 23 | pub short_id: String, | ||
| 24 | pub summary: String, | ||
| 25 | pub body: String, | ||
| 26 | pub author: String, | ||
| 27 | pub date: String, | ||
| 28 | pub diff_lines: Vec<DiffLine>, | ||
| 29 | } | ||
| 30 | |||
| 31 | fn compute_diff_lines(repo: &git2::Repository, oid: git2::Oid) -> Vec<DiffLine> { | ||
| 32 | let commit = match repo.find_commit(oid) { | ||
| 33 | Ok(c) => c, | ||
| 34 | Err(_) => return Vec::new(), | ||
| 35 | }; | ||
| 36 | |||
| 37 | let new_tree = match commit.tree() { | ||
| 38 | Ok(t) => t, | ||
| 39 | Err(_) => return Vec::new(), | ||
| 40 | }; | ||
| 41 | |||
| 42 | let old_tree = commit | ||
| 43 | .parent(0) | ||
| 44 | .ok() | ||
| 45 | .and_then(|p| p.tree().ok()); | ||
| 46 | |||
| 47 | let diff = match repo.diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), None) { | ||
| 48 | Ok(d) => d, | ||
| 49 | Err(_) => return Vec::new(), | ||
| 50 | }; | ||
| 51 | |||
| 52 | let mut lines: Vec<DiffLine> = Vec::new(); | ||
| 53 | |||
| 54 | let _ = diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| { | ||
| 55 | let text = String::from_utf8_lossy(line.content()).into_owned(); | ||
| 56 | let kind = match line.origin() { | ||
| 57 | '+' => "add", | ||
| 58 | '-' => "del", | ||
| 59 | '@' => "hunk", | ||
| 60 | 'F' => "file", | ||
| 61 | _ => "ctx", | ||
| 62 | } | ||
| 63 | .to_string(); | ||
| 64 | lines.push(DiffLine { kind, text }); | ||
| 65 | true | ||
| 66 | }); | ||
| 67 | |||
| 68 | lines | ||
| 69 | } | ||
| 70 | |||
| 71 | pub async fn diff( | ||
| 72 | Path((repo_name, oid_str)): Path<(String, String)>, | ||
| 73 | State(state): State<Arc<AppState>>, | ||
| 74 | ) -> Response { | ||
| 75 | let (_entry, repo) = match open_repo(&state, &repo_name) { | ||
| 76 | Ok(r) => r, | ||
| 77 | Err(resp) => return resp, | ||
| 78 | }; | ||
| 79 | |||
| 80 | let (open_patches, open_issues) = collab_counts(&repo); | ||
| 81 | |||
| 82 | let oid = match git2::Oid::from_str(&oid_str) { | ||
| 83 | Ok(o) => o, | ||
| 84 | Err(_) => return not_found(&state, format!("Invalid OID: {}", oid_str)), | ||
| 85 | }; | ||
| 86 | |||
| 87 | let commit = match repo.find_commit(oid) { | ||
| 88 | Ok(c) => c, | ||
| 89 | Err(_) => return not_found(&state, format!("Commit '{}' not found.", oid_str)), | ||
| 90 | }; | ||
| 91 | |||
| 92 | let id = oid.to_string(); | ||
| 93 | let short_id = id[..8.min(id.len())].to_string(); | ||
| 94 | let summary = commit.summary().unwrap_or("").to_string(); | ||
| 95 | let author = commit.author().name().unwrap_or("").to_string(); | ||
| 96 | let secs = commit.time().seconds(); | ||
| 97 | let date = Utc | ||
| 98 | .timestamp_opt(secs, 0) | ||
| 99 | .single() | ||
| 100 | .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string()) | ||
| 101 | .unwrap_or_default(); | ||
| 102 | |||
| 103 | let body = commit | ||
| 104 | .message() | ||
| 105 | .map(|m| { | ||
| 106 | let lines: Vec<&str> = m.splitn(2, '\n').collect(); | ||
| 107 | if lines.len() > 1 { lines[1].trim_start_matches('\n').to_string() } else { String::new() } | ||
| 108 | }) | ||
| 109 | .unwrap_or_default(); | ||
| 110 | |||
| 111 | let diff_lines = compute_diff_lines(&repo, oid); | ||
| 112 | |||
| 113 | DiffTemplate { | ||
| 114 | site_title: state.site_title.clone(), | ||
| 115 | repo_name, | ||
| 116 | active_section: "commits".to_string(), | ||
| 117 | open_patches, | ||
| 118 | open_issues, | ||
| 119 | short_id, | ||
| 120 | summary, | ||
| 121 | body, | ||
| 122 | author, | ||
| 123 | date, | ||
| 124 | diff_lines, | ||
| 125 | } | ||
| 126 | .into_response() | ||
| 127 | } | ||
src/server/http/repo/issues.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,139 @@ | |||
| 1 | use std::sync::Arc; | ||
| 2 | |||
| 3 | use axum::extract::{Path, State}; | ||
| 4 | use axum::response::{IntoResponse, Response}; | ||
| 5 | |||
| 6 | use super::{AppState, CommentView, collab_counts, internal_error, not_found, open_repo}; | ||
| 7 | |||
| 8 | #[derive(Debug)] | ||
| 9 | pub struct IssueListItem { | ||
| 10 | pub id: String, | ||
| 11 | pub short_id: String, | ||
| 12 | pub status: String, | ||
| 13 | pub title: String, | ||
| 14 | pub author: String, | ||
| 15 | pub labels: String, | ||
| 16 | pub updated: String, | ||
| 17 | } | ||
| 18 | |||
| 19 | #[derive(Debug)] | ||
| 20 | pub struct IssueDetailView { | ||
| 21 | pub title: String, | ||
| 22 | pub body: String, | ||
| 23 | pub status: String, | ||
| 24 | pub author: String, | ||
| 25 | pub labels: String, | ||
| 26 | pub assignees: String, | ||
| 27 | pub close_reason: Option<String>, | ||
| 28 | pub comments: Vec<CommentView>, | ||
| 29 | } | ||
| 30 | |||
| 31 | #[derive(askama::Template, askama_web::WebTemplate)] | ||
| 32 | #[template(path = "issues.html")] | ||
| 33 | pub struct IssuesTemplate { | ||
| 34 | pub site_title: String, | ||
| 35 | pub repo_name: String, | ||
| 36 | pub active_section: String, | ||
| 37 | pub open_patches: usize, | ||
| 38 | pub open_issues: usize, | ||
| 39 | pub issues: Vec<IssueListItem>, | ||
| 40 | } | ||
| 41 | |||
| 42 | #[derive(askama::Template, askama_web::WebTemplate)] | ||
| 43 | #[template(path = "issue_detail.html")] | ||
| 44 | pub struct IssueDetailTemplate { | ||
| 45 | pub site_title: String, | ||
| 46 | pub repo_name: String, | ||
| 47 | pub active_section: String, | ||
| 48 | pub open_patches: usize, | ||
| 49 | pub open_issues: usize, | ||
| 50 | pub issue: IssueDetailView, | ||
| 51 | } | ||
| 52 | |||
| 53 | pub async fn issues( | ||
| 54 | Path(repo_name): Path<String>, | ||
| 55 | State(state): State<Arc<AppState>>, | ||
| 56 | ) -> Response { | ||
| 57 | let (_entry, repo) = match open_repo(&state, &repo_name) { | ||
| 58 | Ok(r) => r, | ||
| 59 | Err(resp) => return resp, | ||
| 60 | }; | ||
| 61 | |||
| 62 | let (open_patches, open_issues) = collab_counts(&repo); | ||
| 63 | |||
| 64 | let all_issues = git_collab::state::list_issues_with_archived(&repo).unwrap_or_default(); | ||
| 65 | |||
| 66 | let issues = all_issues | ||
| 67 | .into_iter() | ||
| 68 | .map(|i| { | ||
| 69 | let id = i.id.clone(); | ||
| 70 | let short_id = id[..8.min(id.len())].to_string(); | ||
| 71 | IssueListItem { | ||
| 72 | short_id, | ||
| 73 | id, | ||
| 74 | status: i.status.as_str().to_string(), | ||
| 75 | title: i.title, | ||
| 76 | author: i.author.name, | ||
| 77 | labels: i.labels.join(", "), | ||
| 78 | updated: i.last_updated, | ||
| 79 | } | ||
| 80 | }) | ||
| 81 | .collect(); | ||
| 82 | |||
| 83 | IssuesTemplate { | ||
| 84 | site_title: state.site_title.clone(), | ||
| 85 | repo_name, | ||
| 86 | active_section: "issues".to_string(), | ||
| 87 | open_patches, | ||
| 88 | open_issues, | ||
| 89 | issues, | ||
| 90 | } | ||
| 91 | .into_response() | ||
| 92 | } | ||
| 93 | |||
| 94 | pub async fn issue_detail( | ||
| 95 | Path((repo_name, issue_id)): Path<(String, String)>, | ||
| 96 | State(state): State<Arc<AppState>>, | ||
| 97 | ) -> Response { | ||
| 98 | let (_entry, repo) = match open_repo(&state, &repo_name) { | ||
| 99 | Ok(r) => r, | ||
| 100 | Err(resp) => return resp, | ||
| 101 | }; | ||
| 102 | |||
| 103 | let (open_patches, open_issues) = collab_counts(&repo); | ||
| 104 | |||
| 105 | let (ref_name, full_id) = match git_collab::state::resolve_issue_ref(&repo, &issue_id) { | ||
| 106 | Ok(r) => r, | ||
| 107 | Err(_) => return not_found(&state, format!("Issue '{}' not found.", issue_id)), | ||
| 108 | }; | ||
| 109 | |||
| 110 | let is = match git_collab::state::IssueState::from_ref(&repo, &ref_name, &full_id) { | ||
| 111 | Ok(s) => s, | ||
| 112 | Err(_) => return internal_error(&state, "Failed to load issue state."), | ||
| 113 | }; | ||
| 114 | |||
| 115 | let issue = IssueDetailView { | ||
| 116 | title: is.title, | ||
| 117 | body: is.body, | ||
| 118 | status: is.status.as_str().to_string(), | ||
| 119 | author: is.author.name, | ||
| 120 | labels: is.labels.join(", "), | ||
| 121 | assignees: is.assignees.join(", "), | ||
| 122 | close_reason: is.close_reason, | ||
| 123 | comments: is.comments.into_iter().map(|c| CommentView { | ||
| 124 | author: c.author.name, | ||
| 125 | body: c.body, | ||
| 126 | timestamp: c.timestamp, | ||
| 127 | }).collect(), | ||
| 128 | }; | ||
| 129 | |||
| 130 | IssueDetailTemplate { | ||
| 131 | site_title: state.site_title.clone(), | ||
| 132 | repo_name, | ||
| 133 | active_section: "issues".to_string(), | ||
| 134 | open_patches, | ||
| 135 | open_issues, | ||
| 136 | issue, | ||
| 137 | } | ||
| 138 | .into_response() | ||
| 139 | } | ||
src/server/http/repo/mod.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,130 @@ | |||
| 1 | mod overview; | ||
| 2 | mod commits; | ||
| 3 | mod tree; | ||
| 4 | mod diff; | ||
| 5 | mod patches; | ||
| 6 | mod issues; | ||
| 7 | |||
| 8 | pub use overview::overview; | ||
| 9 | pub use commits::commits; | ||
| 10 | pub use tree::{tree_root, tree, blob}; | ||
| 11 | pub use diff::diff; | ||
| 12 | pub use patches::{patches, patch_detail}; | ||
| 13 | pub use issues::{issues, issue_detail}; | ||
| 14 | |||
| 15 | use axum::http::StatusCode; | ||
| 16 | use axum::response::{IntoResponse, Response}; | ||
| 17 | use chrono::{TimeZone, Utc}; | ||
| 18 | |||
| 19 | use super::AppState; | ||
| 20 | |||
| 21 | #[derive(askama::Template, askama_web::WebTemplate)] | ||
| 22 | #[template(path = "error.html")] | ||
| 23 | pub struct ErrorTemplate { | ||
| 24 | pub site_title: String, | ||
| 25 | pub status_code: u16, | ||
| 26 | pub message: String, | ||
| 27 | } | ||
| 28 | |||
| 29 | fn not_found(state: &AppState, message: impl Into<String>) -> Response { | ||
| 30 | let mut response = ErrorTemplate { | ||
| 31 | site_title: state.site_title.clone(), | ||
| 32 | status_code: 404, | ||
| 33 | message: message.into(), | ||
| 34 | } | ||
| 35 | .into_response(); | ||
| 36 | *response.status_mut() = StatusCode::NOT_FOUND; | ||
| 37 | response | ||
| 38 | } | ||
| 39 | |||
| 40 | fn internal_error(state: &AppState, message: impl Into<String>) -> Response { | ||
| 41 | let mut response = ErrorTemplate { | ||
| 42 | site_title: state.site_title.clone(), | ||
| 43 | status_code: 500, | ||
| 44 | message: message.into(), | ||
| 45 | } | ||
| 46 | .into_response(); | ||
| 47 | *response.status_mut() = StatusCode::INTERNAL_SERVER_ERROR; | ||
| 48 | response | ||
| 49 | } | ||
| 50 | |||
| 51 | /// Count open patches and issues for sidebar badges. | ||
| 52 | /// Note: this does a full state read on each call. For repos with many collab | ||
| 53 | /// objects this could be slow — consider caching with a short TTL if needed. | ||
| 54 | fn collab_counts(repo: &git2::Repository) -> (usize, usize) { | ||
| 55 | let open_patches = git_collab::state::list_patches(repo) | ||
| 56 | .unwrap_or_default() | ||
| 57 | .into_iter() | ||
| 58 | .filter(|p| p.status == git_collab::state::PatchStatus::Open) | ||
| 59 | .count(); | ||
| 60 | |||
| 61 | let open_issues = git_collab::state::list_issues(repo) | ||
| 62 | .unwrap_or_default() | ||
| 63 | .into_iter() | ||
| 64 | .filter(|i| i.status == git_collab::state::IssueStatus::Open) | ||
| 65 | .count(); | ||
| 66 | |||
| 67 | (open_patches, open_issues) | ||
| 68 | } | ||
| 69 | |||
| 70 | #[derive(Debug)] | ||
| 71 | pub struct OverviewCommit { | ||
| 72 | pub id: String, | ||
| 73 | pub short_id: String, | ||
| 74 | pub summary: String, | ||
| 75 | pub author: String, | ||
| 76 | pub date: String, | ||
| 77 | } | ||
| 78 | |||
| 79 | fn recent_commits(repo: &git2::Repository, limit: usize) -> Vec<OverviewCommit> { | ||
| 80 | let mut revwalk = match repo.revwalk() { | ||
| 81 | Ok(rw) => rw, | ||
| 82 | Err(_) => return Vec::new(), | ||
| 83 | }; | ||
| 84 | |||
| 85 | if revwalk.push_head().is_err() { | ||
| 86 | return Vec::new(); | ||
| 87 | } | ||
| 88 | |||
| 89 | revwalk | ||
| 90 | .take(limit) | ||
| 91 | .filter_map(|oid| { | ||
| 92 | let oid = oid.ok()?; | ||
| 93 | let commit = repo.find_commit(oid).ok()?; | ||
| 94 | let id = oid.to_string(); | ||
| 95 | let short_id = id[..8.min(id.len())].to_string(); | ||
| 96 | let summary = commit.summary().unwrap_or("").to_string(); | ||
| 97 | let author = commit.author().name().unwrap_or("").to_string(); | ||
| 98 | let secs = commit.time().seconds(); | ||
| 99 | let date = Utc | ||
| 100 | .timestamp_opt(secs, 0) | ||
| 101 | .single() | ||
| 102 | .map(|dt| dt.format("%Y-%m-%d").to_string()) | ||
| 103 | .unwrap_or_default(); | ||
| 104 | Some(OverviewCommit { id, short_id, summary, author, date }) | ||
| 105 | }) | ||
| 106 | .collect() | ||
| 107 | } | ||
| 108 | |||
| 109 | #[derive(Debug)] | ||
| 110 | pub struct CommentView { | ||
| 111 | pub author: String, | ||
| 112 | pub body: String, | ||
| 113 | pub timestamp: String, | ||
| 114 | } | ||
| 115 | |||
| 116 | /// Open a repo by name, returning the entry and git2::Repository or an error Response. | ||
| 117 | #[allow(clippy::result_large_err)] | ||
| 118 | fn open_repo(state: &AppState, repo_name: &str) -> Result<(crate::repos::RepoEntry, git2::Repository), Response> { | ||
| 119 | let entry = match crate::repos::resolve(&state.repos_dir, repo_name) { | ||
| 120 | Some(e) => e, | ||
| 121 | None => return Err(not_found(state, format!("Repository '{}' not found.", repo_name))), | ||
| 122 | }; | ||
| 123 | |||
| 124 | let repo = match crate::repos::open(&entry) { | ||
| 125 | Ok(r) => r, | ||
| 126 | Err(_) => return Err(internal_error(state, "Failed to open repository.")), | ||
| 127 | }; | ||
| 128 | |||
| 129 | Ok((entry, repo)) | ||
| 130 | } | ||
src/server/http/repo/overview.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,80 @@ | |||
| 1 | use std::sync::Arc; | ||
| 2 | |||
| 3 | use axum::extract::{Path, State}; | ||
| 4 | use axum::response::{IntoResponse, Response}; | ||
| 5 | |||
| 6 | use super::{AppState, OverviewCommit, collab_counts, open_repo, recent_commits}; | ||
| 7 | |||
| 8 | #[derive(Debug)] | ||
| 9 | pub struct OverviewPatch { | ||
| 10 | pub id: String, | ||
| 11 | pub title: String, | ||
| 12 | pub author: String, | ||
| 13 | } | ||
| 14 | |||
| 15 | #[derive(Debug)] | ||
| 16 | pub struct OverviewIssue { | ||
| 17 | pub id: String, | ||
| 18 | pub title: String, | ||
| 19 | pub author: String, | ||
| 20 | } | ||
| 21 | |||
| 22 | #[derive(askama::Template, askama_web::WebTemplate)] | ||
| 23 | #[template(path = "repo_overview.html")] | ||
| 24 | pub struct OverviewTemplate { | ||
| 25 | pub site_title: String, | ||
| 26 | pub repo_name: String, | ||
| 27 | pub active_section: String, | ||
| 28 | pub open_patches: usize, | ||
| 29 | pub open_issues: usize, | ||
| 30 | pub commits: Vec<OverviewCommit>, | ||
| 31 | pub patches: Vec<OverviewPatch>, | ||
| 32 | pub issues: Vec<OverviewIssue>, | ||
| 33 | } | ||
| 34 | |||
| 35 | pub async fn overview( | ||
| 36 | Path(repo_name): Path<String>, | ||
| 37 | State(state): State<Arc<AppState>>, | ||
| 38 | ) -> Response { | ||
| 39 | let (_entry, repo) = match open_repo(&state, &repo_name) { | ||
| 40 | Ok(r) => r, | ||
| 41 | Err(resp) => return resp, | ||
| 42 | }; | ||
| 43 | |||
| 44 | let commits = recent_commits(&repo, 10); | ||
| 45 | let (open_patches, open_issues) = collab_counts(&repo); | ||
| 46 | |||
| 47 | let patches = git_collab::state::list_patches(&repo) | ||
| 48 | .unwrap_or_default() | ||
| 49 | .into_iter() | ||
| 50 | .filter(|p| p.status == git_collab::state::PatchStatus::Open) | ||
| 51 | .map(|p| OverviewPatch { | ||
| 52 | id: p.id, | ||
| 53 | title: p.title, | ||
| 54 | author: p.author.name, | ||
| 55 | }) | ||
| 56 | .collect(); | ||
| 57 | |||
| 58 | let issues = git_collab::state::list_issues(&repo) | ||
| 59 | .unwrap_or_default() | ||
| 60 | .into_iter() | ||
| 61 | .filter(|i| i.status == git_collab::state::IssueStatus::Open) | ||
| 62 | .map(|i| OverviewIssue { | ||
| 63 | id: i.id, | ||
| 64 | title: i.title, | ||
| 65 | author: i.author.name, | ||
| 66 | }) | ||
| 67 | .collect(); | ||
| 68 | |||
| 69 | OverviewTemplate { | ||
| 70 | site_title: state.site_title.clone(), | ||
| 71 | repo_name, | ||
| 72 | active_section: "overview".to_string(), | ||
| 73 | open_patches, | ||
| 74 | open_issues, | ||
| 75 | commits, | ||
| 76 | patches, | ||
| 77 | issues, | ||
| 78 | } | ||
| 79 | .into_response() | ||
| 80 | } | ||
src/server/http/repo/patches.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,188 @@ | |||
| 1 | use std::sync::Arc; | ||
| 2 | |||
| 3 | use axum::extract::{Path, State}; | ||
| 4 | use axum::response::{IntoResponse, Response}; | ||
| 5 | |||
| 6 | use super::{AppState, CommentView, collab_counts, internal_error, not_found, open_repo}; | ||
| 7 | |||
| 8 | #[derive(Debug)] | ||
| 9 | pub struct PatchListItem { | ||
| 10 | pub id: String, | ||
| 11 | pub short_id: String, | ||
| 12 | pub status: String, | ||
| 13 | pub title: String, | ||
| 14 | pub author: String, | ||
| 15 | pub branch: String, | ||
| 16 | pub updated: String, | ||
| 17 | } | ||
| 18 | |||
| 19 | #[derive(askama::Template, askama_web::WebTemplate)] | ||
| 20 | #[template(path = "patches.html")] | ||
| 21 | pub struct PatchesTemplate { | ||
| 22 | pub site_title: String, | ||
| 23 | pub repo_name: String, | ||
| 24 | pub active_section: String, | ||
| 25 | pub open_patches: usize, | ||
| 26 | pub open_issues: usize, | ||
| 27 | pub patches: Vec<PatchListItem>, | ||
| 28 | } | ||
| 29 | |||
| 30 | #[derive(Debug)] | ||
| 31 | pub struct RevisionView { | ||
| 32 | pub number: u32, | ||
| 33 | pub commit: String, | ||
| 34 | pub timestamp: String, | ||
| 35 | pub body: Option<String>, | ||
| 36 | } | ||
| 37 | |||
| 38 | #[derive(Debug)] | ||
| 39 | pub struct ReviewView { | ||
| 40 | pub author: String, | ||
| 41 | pub verdict: String, | ||
| 42 | pub body: String, | ||
| 43 | pub timestamp: String, | ||
| 44 | pub revision: Option<u32>, | ||
| 45 | } | ||
| 46 | |||
| 47 | #[derive(Debug)] | ||
| 48 | pub struct InlineCommentView { | ||
| 49 | pub author: String, | ||
| 50 | pub file: String, | ||
| 51 | pub line: u32, | ||
| 52 | pub body: String, | ||
| 53 | pub timestamp: String, | ||
| 54 | pub revision: Option<u32>, | ||
| 55 | } | ||
| 56 | |||
| 57 | #[derive(Debug)] | ||
| 58 | pub struct PatchDetailView { | ||
| 59 | pub title: String, | ||
| 60 | pub body: String, | ||
| 61 | pub status: String, | ||
| 62 | pub author: String, | ||
| 63 | pub branch: String, | ||
| 64 | pub base_ref: String, | ||
| 65 | pub revisions: Vec<RevisionView>, | ||
| 66 | pub reviews: Vec<ReviewView>, | ||
| 67 | pub inline_comments: Vec<InlineCommentView>, | ||
| 68 | pub comments: Vec<CommentView>, | ||
| 69 | } | ||
| 70 | |||
| 71 | #[derive(askama::Template, askama_web::WebTemplate)] | ||
| 72 | #[template(path = "patch_detail.html")] | ||
| 73 | pub struct PatchDetailTemplate { | ||
| 74 | pub site_title: String, | ||
| 75 | pub repo_name: String, | ||
| 76 | pub active_section: String, | ||
| 77 | pub open_patches: usize, | ||
| 78 | pub open_issues: usize, | ||
| 79 | pub patch: PatchDetailView, | ||
| 80 | } | ||
| 81 | |||
| 82 | pub async fn patches( | ||
| 83 | Path(repo_name): Path<String>, | ||
| 84 | State(state): State<Arc<AppState>>, | ||
| 85 | ) -> Response { | ||
| 86 | let (_entry, repo) = match open_repo(&state, &repo_name) { | ||
| 87 | Ok(r) => r, | ||
| 88 | Err(resp) => return resp, | ||
| 89 | }; | ||
| 90 | |||
| 91 | let (open_patches, open_issues) = collab_counts(&repo); | ||
| 92 | |||
| 93 | let all_patches = git_collab::state::list_patches_with_archived(&repo).unwrap_or_default(); | ||
| 94 | |||
| 95 | let patches = all_patches | ||
| 96 | .into_iter() | ||
| 97 | .map(|p| { | ||
| 98 | let id = p.id.clone(); | ||
| 99 | let short_id = id[..8.min(id.len())].to_string(); | ||
| 100 | PatchListItem { | ||
| 101 | short_id, | ||
| 102 | id, | ||
| 103 | status: p.status.as_str().to_string(), | ||
| 104 | title: p.title, | ||
| 105 | author: p.author.name, | ||
| 106 | branch: p.branch, | ||
| 107 | updated: p.last_updated, | ||
| 108 | } | ||
| 109 | }) | ||
| 110 | .collect(); | ||
| 111 | |||
| 112 | PatchesTemplate { | ||
| 113 | site_title: state.site_title.clone(), | ||
| 114 | repo_name, | ||
| 115 | active_section: "patches".to_string(), | ||
| 116 | open_patches, | ||
| 117 | open_issues, | ||
| 118 | patches, | ||
| 119 | } | ||
| 120 | .into_response() | ||
| 121 | } | ||
| 122 | |||
| 123 | pub async fn patch_detail( | ||
| 124 | Path((repo_name, patch_id)): Path<(String, String)>, | ||
| 125 | State(state): State<Arc<AppState>>, | ||
| 126 | ) -> Response { | ||
| 127 | let (_entry, repo) = match open_repo(&state, &repo_name) { | ||
| 128 | Ok(r) => r, | ||
| 129 | Err(resp) => return resp, | ||
| 130 | }; | ||
| 131 | |||
| 132 | let (open_patches, open_issues) = collab_counts(&repo); | ||
| 133 | |||
| 134 | let (ref_name, full_id) = match git_collab::state::resolve_patch_ref(&repo, &patch_id) { | ||
| 135 | Ok(r) => r, | ||
| 136 | Err(_) => return not_found(&state, format!("Patch '{}' not found.", patch_id)), | ||
| 137 | }; | ||
| 138 | |||
| 139 | let ps = match git_collab::state::PatchState::from_ref(&repo, &ref_name, &full_id) { | ||
| 140 | Ok(s) => s, | ||
| 141 | Err(_) => return internal_error(&state, "Failed to load patch state."), | ||
| 142 | }; | ||
| 143 | |||
| 144 | let patch = PatchDetailView { | ||
| 145 | title: ps.title, | ||
| 146 | body: ps.body, | ||
| 147 | status: ps.status.as_str().to_string(), | ||
| 148 | author: ps.author.name, | ||
| 149 | branch: ps.branch, | ||
| 150 | base_ref: ps.base_ref, | ||
| 151 | revisions: ps.revisions.into_iter().map(|r| RevisionView { | ||
| 152 | number: r.number, | ||
| 153 | commit: r.commit, | ||
| 154 | timestamp: r.timestamp, | ||
| 155 | body: r.body, | ||
| 156 | }).collect(), | ||
| 157 | reviews: ps.reviews.into_iter().map(|r| ReviewView { | ||
| 158 | author: r.author.name, | ||
| 159 | verdict: r.verdict.as_str().to_string(), | ||
| 160 | body: r.body, | ||
| 161 | timestamp: r.timestamp, | ||
| 162 | revision: r.revision, | ||
| 163 | }).collect(), | ||
| 164 | inline_comments: ps.inline_comments.into_iter().map(|ic| InlineCommentView { | ||
| 165 | author: ic.author.name, | ||
| 166 | file: ic.file, | ||
| 167 | line: ic.line, | ||
| 168 | body: ic.body, | ||
| 169 | timestamp: ic.timestamp, | ||
| 170 | revision: ic.revision, | ||
| 171 | }).collect(), | ||
| 172 | comments: ps.comments.into_iter().map(|c| CommentView { | ||
| 173 | author: c.author.name, | ||
| 174 | body: c.body, | ||
| 175 | timestamp: c.timestamp, | ||
| 176 | }).collect(), | ||
| 177 | }; | ||
| 178 | |||
| 179 | PatchDetailTemplate { | ||
| 180 | site_title: state.site_title.clone(), | ||
| 181 | repo_name, | ||
| 182 | active_section: "patches".to_string(), | ||
| 183 | open_patches, | ||
| 184 | open_issues, | ||
| 185 | patch, | ||
| 186 | } | ||
| 187 | .into_response() | ||
| 188 | } | ||
src/server/http/repo/tree.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,238 @@ | |||
| 1 | use std::sync::Arc; | ||
| 2 | |||
| 3 | use axum::extract::{Path, State}; | ||
| 4 | use axum::response::{IntoResponse, Response}; | ||
| 5 | |||
| 6 | use super::{AppState, collab_counts, internal_error, not_found, open_repo}; | ||
| 7 | |||
| 8 | #[derive(Debug)] | ||
| 9 | pub struct TreeEntry { | ||
| 10 | pub name: String, | ||
| 11 | pub full_path: String, | ||
| 12 | pub is_dir: bool, | ||
| 13 | } | ||
| 14 | |||
| 15 | #[derive(askama::Template, askama_web::WebTemplate)] | ||
| 16 | #[template(path = "tree.html")] | ||
| 17 | pub struct TreeTemplate { | ||
| 18 | pub site_title: String, | ||
| 19 | pub repo_name: String, | ||
| 20 | pub active_section: String, | ||
| 21 | pub open_patches: usize, | ||
| 22 | pub open_issues: usize, | ||
| 23 | pub ref_name: String, | ||
| 24 | pub path_display: String, | ||
| 25 | pub show_parent: bool, | ||
| 26 | pub parent_path: String, | ||
| 27 | pub entries: Vec<TreeEntry>, | ||
| 28 | } | ||
| 29 | |||
| 30 | #[derive(askama::Template, askama_web::WebTemplate)] | ||
| 31 | #[template(path = "blob.html")] | ||
| 32 | pub struct BlobTemplate { | ||
| 33 | pub site_title: String, | ||
| 34 | pub repo_name: String, | ||
| 35 | pub active_section: String, | ||
| 36 | pub open_patches: usize, | ||
| 37 | pub open_issues: usize, | ||
| 38 | pub ref_name: String, | ||
| 39 | pub file_path: String, | ||
| 40 | pub content: String, | ||
| 41 | pub is_binary: bool, | ||
| 42 | pub size_display: String, | ||
| 43 | } | ||
| 44 | |||
| 45 | /// Split "ref/path/to/file" — first segment is ref, rest joined is path. | ||
| 46 | /// Returns ("HEAD", "") for empty input. | ||
| 47 | fn split_ref_path(input: &str) -> (String, String) { | ||
| 48 | if input.is_empty() { | ||
| 49 | return ("HEAD".to_string(), String::new()); | ||
| 50 | } | ||
| 51 | let parts: Vec<&str> = input.splitn(2, '/').collect(); | ||
| 52 | let ref_name = parts[0].to_string(); | ||
| 53 | let path = if parts.len() > 1 { parts[1].to_string() } else { String::new() }; | ||
| 54 | (ref_name, path) | ||
| 55 | } | ||
| 56 | |||
| 57 | fn build_tree_response( | ||
| 58 | state: &AppState, | ||
| 59 | repo_name: String, | ||
| 60 | repo: &git2::Repository, | ||
| 61 | ref_name: String, | ||
| 62 | path: String, | ||
| 63 | ) -> Response { | ||
| 64 | let (open_patches, open_issues) = collab_counts(repo); | ||
| 65 | |||
| 66 | let obj = match repo.revparse_single(&ref_name) { | ||
| 67 | Ok(o) => o, | ||
| 68 | Err(_) => return not_found(state, format!("Ref '{}' not found.", ref_name)), | ||
| 69 | }; | ||
| 70 | |||
| 71 | let commit = match obj.peel_to_commit() { | ||
| 72 | Ok(c) => c, | ||
| 73 | Err(_) => return not_found(state, "Could not resolve ref to a commit."), | ||
| 74 | }; | ||
| 75 | |||
| 76 | let root_tree = match commit.tree() { | ||
| 77 | Ok(t) => t, | ||
| 78 | Err(_) => return internal_error(state, "Failed to get commit tree."), | ||
| 79 | }; | ||
| 80 | |||
| 81 | let tree = if path.is_empty() { | ||
| 82 | root_tree | ||
| 83 | } else { | ||
| 84 | let entry = match root_tree.get_path(std::path::Path::new(&path)) { | ||
| 85 | Ok(e) => e, | ||
| 86 | Err(_) => return not_found(state, format!("Path '{}' not found.", path)), | ||
| 87 | }; | ||
| 88 | let obj = match entry.to_object(repo) { | ||
| 89 | Ok(o) => o, | ||
| 90 | Err(_) => return internal_error(state, "Failed to resolve path object."), | ||
| 91 | }; | ||
| 92 | match obj.into_tree() { | ||
| 93 | Ok(t) => t, | ||
| 94 | Err(_) => return not_found(state, "Path is not a directory."), | ||
| 95 | } | ||
| 96 | }; | ||
| 97 | |||
| 98 | let mut entries: Vec<TreeEntry> = tree | ||
| 99 | .iter() | ||
| 100 | .map(|e| { | ||
| 101 | let name = e.name().unwrap_or("").to_string(); | ||
| 102 | let full_path = if path.is_empty() { | ||
| 103 | name.clone() | ||
| 104 | } else { | ||
| 105 | format!("{}/{}", path, name) | ||
| 106 | }; | ||
| 107 | let is_dir = e.kind() == Some(git2::ObjectType::Tree); | ||
| 108 | TreeEntry { name, full_path, is_dir } | ||
| 109 | }) | ||
| 110 | .collect(); | ||
| 111 | |||
| 112 | entries.sort_by(|a, b| { | ||
| 113 | match (a.is_dir, b.is_dir) { | ||
| 114 | (true, false) => std::cmp::Ordering::Less, | ||
| 115 | (false, true) => std::cmp::Ordering::Greater, | ||
| 116 | _ => a.name.cmp(&b.name), | ||
| 117 | } | ||
| 118 | }); | ||
| 119 | |||
| 120 | let (show_parent, parent_path) = if path.is_empty() { | ||
| 121 | (false, String::new()) | ||
| 122 | } else { | ||
| 123 | let parent = path.rfind('/').map(|i| &path[..i]).unwrap_or("").to_string(); | ||
| 124 | (true, parent) | ||
| 125 | }; | ||
| 126 | |||
| 127 | TreeTemplate { | ||
| 128 | site_title: state.site_title.clone(), | ||
| 129 | repo_name, | ||
| 130 | active_section: "tree".to_string(), | ||
| 131 | open_patches, | ||
| 132 | open_issues, | ||
| 133 | ref_name, | ||
| 134 | path_display: path, | ||
| 135 | show_parent, | ||
| 136 | parent_path, | ||
| 137 | entries, | ||
| 138 | } | ||
| 139 | .into_response() | ||
| 140 | } | ||
| 141 | |||
| 142 | pub async fn tree_root( | ||
| 143 | Path(repo_name): Path<String>, | ||
| 144 | State(state): State<Arc<AppState>>, | ||
| 145 | ) -> Response { | ||
| 146 | let (_entry, repo) = match open_repo(&state, &repo_name) { | ||
| 147 | Ok(r) => r, | ||
| 148 | Err(resp) => return resp, | ||
| 149 | }; | ||
| 150 | |||
| 151 | build_tree_response(&state, repo_name, &repo, "HEAD".to_string(), String::new()) | ||
| 152 | } | ||
| 153 | |||
| 154 | pub async fn tree( | ||
| 155 | Path((repo_name, rest)): Path<(String, String)>, | ||
| 156 | State(state): State<Arc<AppState>>, | ||
| 157 | ) -> Response { | ||
| 158 | let (_entry, repo) = match open_repo(&state, &repo_name) { | ||
| 159 | Ok(r) => r, | ||
| 160 | Err(resp) => return resp, | ||
| 161 | }; | ||
| 162 | |||
| 163 | let (ref_name, path) = split_ref_path(&rest); | ||
| 164 | build_tree_response(&state, repo_name, &repo, ref_name, path) | ||
| 165 | } | ||
| 166 | |||
| 167 | pub async fn blob( | ||
| 168 | Path((repo_name, rest)): Path<(String, String)>, | ||
| 169 | State(state): State<Arc<AppState>>, | ||
| 170 | ) -> Response { | ||
| 171 | let (_entry, repo) = match open_repo(&state, &repo_name) { | ||
| 172 | Ok(r) => r, | ||
| 173 | Err(resp) => return resp, | ||
| 174 | }; | ||
| 175 | |||
| 176 | let (open_patches, open_issues) = collab_counts(&repo); | ||
| 177 | let (ref_name, file_path) = split_ref_path(&rest); | ||
| 178 | |||
| 179 | let obj = match repo.revparse_single(&ref_name) { | ||
| 180 | Ok(o) => o, | ||
| 181 | Err(_) => return not_found(&state, format!("Ref '{}' not found.", ref_name)), | ||
| 182 | }; | ||
| 183 | |||
| 184 | let commit = match obj.peel_to_commit() { | ||
| 185 | Ok(c) => c, | ||
| 186 | Err(_) => return not_found(&state, "Could not resolve ref to a commit."), | ||
| 187 | }; | ||
| 188 | |||
| 189 | let tree = match commit.tree() { | ||
| 190 | Ok(t) => t, | ||
| 191 | Err(_) => return internal_error(&state, "Failed to get commit tree."), | ||
| 192 | }; | ||
| 193 | |||
| 194 | let entry_obj = match tree.get_path(std::path::Path::new(&file_path)) { | ||
| 195 | Ok(e) => e, | ||
| 196 | Err(_) => return not_found(&state, format!("File '{}' not found.", file_path)), | ||
| 197 | }; | ||
| 198 | |||
| 199 | let obj = match entry_obj.to_object(&repo) { | ||
| 200 | Ok(o) => o, | ||
| 201 | Err(_) => return internal_error(&state, "Failed to resolve file object."), | ||
| 202 | }; | ||
| 203 | |||
| 204 | let blob = match obj.into_blob() { | ||
| 205 | Ok(b) => b, | ||
| 206 | Err(_) => return not_found(&state, "Path is not a file."), | ||
| 207 | }; | ||
| 208 | |||
| 209 | let is_binary = blob.is_binary(); | ||
| 210 | let size = blob.size(); | ||
| 211 | let size_display = if size < 1024 { | ||
| 212 | format!("{} B", size) | ||
| 213 | } else if size < 1024 * 1024 { | ||
| 214 | format!("{:.1} KiB", size as f64 / 1024.0) | ||
| 215 | } else { | ||
| 216 | format!("{:.1} MiB", size as f64 / (1024.0 * 1024.0)) | ||
| 217 | }; | ||
| 218 | |||
| 219 | let content = if is_binary { | ||
| 220 | String::new() | ||
| 221 | } else { | ||
| 222 | String::from_utf8_lossy(blob.content()).into_owned() | ||
| 223 | }; | ||
| 224 | |||
| 225 | BlobTemplate { | ||
| 226 | site_title: state.site_title.clone(), | ||
| 227 | repo_name, | ||
| 228 | active_section: "tree".to_string(), | ||
| 229 | open_patches, | ||
| 230 | open_issues, | ||
| 231 | ref_name, | ||
| 232 | file_path, | ||
| 233 | content, | ||
| 234 | is_binary, | ||
| 235 | size_display, | ||
| 236 | } | ||
| 237 | .into_response() | ||
| 238 | } | ||
src/server/http/repo_list.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,87 @@ | |||
| 1 | use std::sync::Arc; | ||
| 2 | use axum::extract::State; | ||
| 3 | use axum::response::IntoResponse; | ||
| 4 | |||
| 5 | use super::AppState; | ||
| 6 | |||
| 7 | #[derive(Debug)] | ||
| 8 | pub struct RepoListItem { | ||
| 9 | pub name: String, | ||
| 10 | pub description: String, | ||
| 11 | pub last_commit: String, | ||
| 12 | } | ||
| 13 | |||
| 14 | #[derive(askama::Template, askama_web::WebTemplate)] | ||
| 15 | #[template(path = "repo_list.html")] | ||
| 16 | pub struct RepoListTemplate { | ||
| 17 | pub site_title: String, | ||
| 18 | pub repos: Vec<RepoListItem>, | ||
| 19 | } | ||
| 20 | |||
| 21 | pub async fn handler(State(state): State<Arc<AppState>>) -> impl IntoResponse { | ||
| 22 | let repos = build_repo_list(&state); | ||
| 23 | RepoListTemplate { | ||
| 24 | site_title: state.site_title.clone(), | ||
| 25 | repos, | ||
| 26 | } | ||
| 27 | } | ||
| 28 | |||
| 29 | fn build_repo_list(state: &AppState) -> Vec<RepoListItem> { | ||
| 30 | let entries = match crate::repos::discover(&state.repos_dir) { | ||
| 31 | Ok(e) => e, | ||
| 32 | Err(_) => return Vec::new(), | ||
| 33 | }; | ||
| 34 | |||
| 35 | entries | ||
| 36 | .into_iter() | ||
| 37 | .map(|entry| { | ||
| 38 | let description = read_description(&entry); | ||
| 39 | let last_commit = read_last_commit(&entry); | ||
| 40 | RepoListItem { | ||
| 41 | name: entry.name, | ||
| 42 | description, | ||
| 43 | last_commit, | ||
| 44 | } | ||
| 45 | }) | ||
| 46 | .collect() | ||
| 47 | } | ||
| 48 | |||
| 49 | fn read_description(entry: &crate::repos::RepoEntry) -> String { | ||
| 50 | let desc_path = if entry.bare { | ||
| 51 | entry.path.join("description") | ||
| 52 | } else { | ||
| 53 | entry.path.join(".git").join("description") | ||
| 54 | }; | ||
| 55 | |||
| 56 | std::fs::read_to_string(&desc_path) | ||
| 57 | .ok() | ||
| 58 | .map(|s| s.trim().to_string()) | ||
| 59 | .filter(|s| !s.is_empty() && !s.starts_with("Unnamed repository")) | ||
| 60 | .unwrap_or_default() | ||
| 61 | } | ||
| 62 | |||
| 63 | fn read_last_commit(entry: &crate::repos::RepoEntry) -> String { | ||
| 64 | let repo = match crate::repos::open(entry) { | ||
| 65 | Ok(r) => r, | ||
| 66 | Err(_) => return String::new(), | ||
| 67 | }; | ||
| 68 | |||
| 69 | let head = match repo.head() { | ||
| 70 | Ok(h) => h, | ||
| 71 | Err(_) => return String::new(), | ||
| 72 | }; | ||
| 73 | |||
| 74 | let commit = match head.peel_to_commit() { | ||
| 75 | Ok(c) => c, | ||
| 76 | Err(_) => return String::new(), | ||
| 77 | }; | ||
| 78 | |||
| 79 | let time = commit.time(); | ||
| 80 | let secs = time.seconds(); | ||
| 81 | // Format as YYYY-MM-DD using chrono | ||
| 82 | use chrono::{TimeZone, Utc}; | ||
| 83 | Utc.timestamp_opt(secs, 0) | ||
| 84 | .single() | ||
| 85 | .map(|dt| dt.format("%Y-%m-%d").to_string()) | ||
| 86 | .unwrap_or_default() | ||
| 87 | } | ||
src/server/http/templates/base.html
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,40 @@ | |||
| 1 | <!DOCTYPE html> | ||
| 2 | <html lang="en"> | ||
| 3 | <head> | ||
| 4 | <meta charset="UTF-8" /> | ||
| 5 | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | ||
| 6 | <title>{% block title %}{{ site_title }}{% endblock %}</title> | ||
| 7 | <style> | ||
| 8 | * { box-sizing: border-box; margin: 0; padding: 0; } | ||
| 9 | body { font-family: monospace; font-size: 14px; line-height: 1.6; } | ||
| 10 | a { color: #0366d6; } | ||
| 11 | table { border-collapse: collapse; width: 100%; } | ||
| 12 | th, td { text-align: left; padding: 4px 8px; } | ||
| 13 | th { font-weight: normal; color: #666; } | ||
| 14 | td { border-top: 1px solid #eee; } | ||
| 15 | pre { padding: 8px; background: #f8f8f8; overflow-x: auto; } | ||
| 16 | .badge { font-size: 11px; color: #666; } | ||
| 17 | .status-open { color: green; } | ||
| 18 | .status-closed { color: red; } | ||
| 19 | .diff-add { color: green; } | ||
| 20 | .diff-del { color: red; } | ||
| 21 | .diff-hunk { color: #666; } | ||
| 22 | .diff-file { font-weight: bold; border-top: 1px solid #ccc; padding-top: 8px; } | ||
| 23 | .header { padding: 8px 16px; border-bottom: 1px solid #ccc; display: flex; align-items: baseline; gap: 16px; } | ||
| 24 | .header a { text-decoration: none; } | ||
| 25 | .header .site { font-weight: bold; } | ||
| 26 | .header nav { display: flex; gap: 12px; } | ||
| 27 | .header nav a.active { font-weight: bold; } | ||
| 28 | .content { padding: 16px; max-width: 1200px; margin: 0 auto; } | ||
| 29 | </style> | ||
| 30 | </head> | ||
| 31 | <body> | ||
| 32 | <div class="header"> | ||
| 33 | <a class="site" href="/">{{ site_title }}</a> | ||
| 34 | {% block nav %}{% endblock %} | ||
| 35 | </div> | ||
| 36 | <div class="content"> | ||
| 37 | {% block body %}{% endblock %} | ||
| 38 | </div> | ||
| 39 | </body> | ||
| 40 | </html> | ||
src/server/http/templates/blob.html
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,13 @@ | |||
| 1 | {% extends "repo_base.html" %} | ||
| 2 | |||
| 3 | {% block title %}{{ file_path }} — {{ repo_name }} — {{ site_title }}{% endblock %} | ||
| 4 | |||
| 5 | {% block content %} | ||
| 6 | <h2>{{ file_path }}</h2> | ||
| 7 | <p style="color: #666;">Ref: <span class="mono">{{ ref_name }}</span> Size: {{ size_display }}</p> | ||
| 8 | {% if is_binary %} | ||
| 9 | <p style="color: #666; font-style: italic;">Binary file</p> | ||
| 10 | {% else %} | ||
| 11 | <pre>{{ content }}</pre> | ||
| 12 | {% endif %} | ||
| 13 | {% endblock %} | ||
src/server/http/templates/commits.html
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,31 @@ | |||
| 1 | {% extends "repo_base.html" %} | ||
| 2 | |||
| 3 | {% block title %}Commits — {{ repo_name }} — {{ site_title }}{% endblock %} | ||
| 4 | |||
| 5 | {% block content %} | ||
| 6 | <h2>Commits</h2> | ||
| 7 | {% if commits.is_empty() %} | ||
| 8 | <p style="color: #666;">No commits yet.</p> | ||
| 9 | {% else %} | ||
| 10 | <table> | ||
| 11 | <thead> | ||
| 12 | <tr> | ||
| 13 | <th>Hash</th> | ||
| 14 | <th>Message</th> | ||
| 15 | <th>Author</th> | ||
| 16 | <th>Date</th> | ||
| 17 | </tr> | ||
| 18 | </thead> | ||
| 19 | <tbody> | ||
| 20 | {% for c in commits %} | ||
| 21 | <tr> | ||
| 22 | <td class="mono"><a href="/{{ repo_name }}/diff/{{ c.id }}">{{ c.short_id }}</a></td> | ||
| 23 | <td>{{ c.summary }}</td> | ||
| 24 | <td style="color: #666;">{{ c.author }}</td> | ||
| 25 | <td class="mono" style="color: #666;">{{ c.date }}</td> | ||
| 26 | </tr> | ||
| 27 | {% endfor %} | ||
| 28 | </tbody> | ||
| 29 | </table> | ||
| 30 | {% endif %} | ||
| 31 | {% endblock %} | ||
src/server/http/templates/diff.html
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,14 @@ | |||
| 1 | {% extends "repo_base.html" %} | ||
| 2 | |||
| 3 | {% block title %}{{ short_id }} — {{ repo_name }} — {{ site_title }}{% endblock %} | ||
| 4 | |||
| 5 | {% block content %} | ||
| 6 | <h2 class="mono">{{ short_id }}</h2> | ||
| 7 | <p><strong>{{ summary }}</strong></p> | ||
| 8 | <p style="color: #666;">{{ author }} {{ date }}</p> | ||
| 9 | {% if !body.is_empty() %} | ||
| 10 | <pre style="background: #f8f8f8; padding: 12px; border-radius: 4px; white-space: pre-wrap;">{{ body }}</pre> | ||
| 11 | {% endif %} | ||
| 12 | <hr style="margin: 16px 0;"> | ||
| 13 | <pre class="diff">{% for line in diff_lines %}{% if line.kind == "add" %}<span class="diff-add">{{ line.text }}</span>{% elif line.kind == "del" %}<span class="diff-del">{{ line.text }}</span>{% elif line.kind == "hunk" %}<span class="diff-hunk">{{ line.text }}</span>{% elif line.kind == "file" %}<span class="diff-file">{{ line.text }}</span>{% else %}{{ line.text }}{% endif %}{% endfor %}</pre> | ||
| 14 | {% endblock %} | ||
src/server/http/templates/error.html
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,10 @@ | |||
| 1 | {% extends "base.html" %} | ||
| 2 | |||
| 3 | {% block title %}{{ status_code }} — {{ site_title }}{% endblock %} | ||
| 4 | |||
| 5 | {% block body %} | ||
| 6 | <div style="max-width: 800px; margin: 80px auto; text-align: center;"> | ||
| 7 | <div style="font-size: 72px; font-weight: 700; color: #666; line-height: 1;">{{ status_code }}</div> | ||
| 8 | <p style="color: #666; margin-top: 16px; font-size: 16px;">{{ message }}</p> | ||
| 9 | </div> | ||
| 10 | {% endblock %} | ||
src/server/http/templates/issue_detail.html
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,36 @@ | |||
| 1 | {% extends "repo_base.html" %} | ||
| 2 | |||
| 3 | {% block title %}{{ issue.title }} — {{ repo_name }} — {{ site_title }}{% endblock %} | ||
| 4 | |||
| 5 | {% block content %} | ||
| 6 | <h2>{{ issue.title }}</h2> | ||
| 7 | <p> | ||
| 8 | <span class="status-{{ issue.status }}">{{ issue.status }}</span> | ||
| 9 | by <strong>{{ issue.author }}</strong> | ||
| 10 | </p> | ||
| 11 | {% if !issue.labels.is_empty() %} | ||
| 12 | <p style="color: #666;">Labels: {{ issue.labels }}</p> | ||
| 13 | {% endif %} | ||
| 14 | {% if !issue.assignees.is_empty() %} | ||
| 15 | <p style="color: #666;">Assignees: {{ issue.assignees }}</p> | ||
| 16 | {% endif %} | ||
| 17 | {% if !issue.body.is_empty() %} | ||
| 18 | <pre style="background: #f8f8f8; padding: 12px; border-radius: 4px; white-space: pre-wrap;">{{ issue.body }}</pre> | ||
| 19 | {% endif %} | ||
| 20 | {% if let Some(reason) = issue.close_reason %} | ||
| 21 | <p style="color: #666;">Close reason: {{ reason }}</p> | ||
| 22 | {% endif %} | ||
| 23 | |||
| 24 | {% if !issue.comments.is_empty() %} | ||
| 25 | <h3>Comments</h3> | ||
| 26 | {% for comment in issue.comments %} | ||
| 27 | <div style="border: 1px solid #ddd; border-radius: 4px; padding: 12px; margin-bottom: 12px;"> | ||
| 28 | <p style="margin: 0 0 8px 0;"> | ||
| 29 | <strong>{{ comment.author }}</strong> | ||
| 30 | <span class="mono" style="color: #666; font-size: 0.85em;">{{ comment.timestamp }}</span> | ||
| 31 | </p> | ||
| 32 | <pre style="margin: 0; white-space: pre-wrap;">{{ comment.body }}</pre> | ||
| 33 | </div> | ||
| 34 | {% endfor %} | ||
| 35 | {% endif %} | ||
| 36 | {% endblock %} | ||
src/server/http/templates/issues.html
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,35 @@ | |||
| 1 | {% extends "repo_base.html" %} | ||
| 2 | |||
| 3 | {% block title %}Issues — {{ repo_name }} — {{ site_title }}{% endblock %} | ||
| 4 | |||
| 5 | {% block content %} | ||
| 6 | <h2>Issues</h2> | ||
| 7 | {% if issues.is_empty() %} | ||
| 8 | <p style="color: #666;">No issues.</p> | ||
| 9 | {% else %} | ||
| 10 | <table> | ||
| 11 | <thead> | ||
| 12 | <tr> | ||
| 13 | <th>ID</th> | ||
| 14 | <th>Status</th> | ||
| 15 | <th>Title</th> | ||
| 16 | <th>Author</th> | ||
| 17 | <th>Labels</th> | ||
| 18 | <th>Updated</th> | ||
| 19 | </tr> | ||
| 20 | </thead> | ||
| 21 | <tbody> | ||
| 22 | {% for i in issues %} | ||
| 23 | <tr> | ||
| 24 | <td class="mono"><a href="/{{ repo_name }}/issues/{{ i.id }}">{{ i.short_id }}</a></td> | ||
| 25 | <td><span class="status-{{ i.status }}">{{ i.status }}</span></td> | ||
| 26 | <td><a href="/{{ repo_name }}/issues/{{ i.id }}">{{ i.title }}</a></td> | ||
| 27 | <td style="color: #666;">{{ i.author }}</td> | ||
| 28 | <td style="color: #666;">{{ i.labels }}</td> | ||
| 29 | <td class="mono" style="color: #666;">{{ i.updated }}</td> | ||
| 30 | </tr> | ||
| 31 | {% endfor %} | ||
| 32 | </tbody> | ||
| 33 | </table> | ||
| 34 | {% endif %} | ||
| 35 | {% endblock %} | ||
src/server/http/templates/patch_detail.html
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,86 @@ | |||
| 1 | {% extends "repo_base.html" %} | ||
| 2 | |||
| 3 | {% block title %}{{ patch.title }} — {{ repo_name }} — {{ site_title }}{% endblock %} | ||
| 4 | |||
| 5 | {% block content %} | ||
| 6 | <h2>{{ patch.title }}</h2> | ||
| 7 | <p> | ||
| 8 | <span class="status-{{ patch.status }}">{{ patch.status }}</span> | ||
| 9 | by <strong>{{ patch.author }}</strong> | ||
| 10 | <span class="mono" style="color: #666;">{{ patch.branch }} → {{ patch.base_ref }}</span> | ||
| 11 | </p> | ||
| 12 | {% if !patch.body.is_empty() %} | ||
| 13 | <pre style="background: #f8f8f8; padding: 12px; border-radius: 4px; white-space: pre-wrap;">{{ patch.body }}</pre> | ||
| 14 | {% endif %} | ||
| 15 | |||
| 16 | <h3>Revisions</h3> | ||
| 17 | {% if patch.revisions.is_empty() %} | ||
| 18 | <p style="color: #666;">No revisions.</p> | ||
| 19 | {% else %} | ||
| 20 | <table> | ||
| 21 | <thead> | ||
| 22 | <tr> | ||
| 23 | <th>#</th> | ||
| 24 | <th>Commit</th> | ||
| 25 | <th>Date</th> | ||
| 26 | <th>Notes</th> | ||
| 27 | </tr> | ||
| 28 | </thead> | ||
| 29 | <tbody> | ||
| 30 | {% for rev in patch.revisions %} | ||
| 31 | <tr> | ||
| 32 | <td>{{ rev.number }}</td> | ||
| 33 | <td class="mono"><a href="/{{ repo_name }}/diff/{{ rev.commit }}">{{ rev.commit[..8] }}</a></td> | ||
| 34 | <td class="mono" style="color: #666;">{{ rev.timestamp }}</td> | ||
| 35 | <td>{% if let Some(body) = rev.body %}{{ body }}{% endif %}</td> | ||
| 36 | </tr> | ||
| 37 | {% endfor %} | ||
| 38 | </tbody> | ||
| 39 | </table> | ||
| 40 | {% endif %} | ||
| 41 | |||
| 42 | {% if !patch.reviews.is_empty() %} | ||
| 43 | <h3>Reviews</h3> | ||
| 44 | {% for review in patch.reviews %} | ||
| 45 | <div style="border: 1px solid #ddd; border-radius: 4px; padding: 12px; margin-bottom: 12px;"> | ||
| 46 | <p style="margin: 0 0 8px 0;"> | ||
| 47 | <strong>{{ review.author }}</strong> | ||
| 48 | <span class="status-{{ review.verdict }}">{{ review.verdict }}</span> | ||
| 49 | <span class="mono" style="color: #666; font-size: 0.85em;">{{ review.timestamp }}</span> | ||
| 50 | {% if let Some(rev) = review.revision %} rev {{ rev }}{% endif %} | ||
| 51 | </p> | ||
| 52 | {% if !review.body.is_empty() %} | ||
| 53 | <pre style="margin: 0; white-space: pre-wrap;">{{ review.body }}</pre> | ||
| 54 | {% endif %} | ||
| 55 | </div> | ||
| 56 | {% endfor %} | ||
| 57 | {% endif %} | ||
| 58 | |||
| 59 | {% if !patch.inline_comments.is_empty() %} | ||
| 60 | <h3>Inline Comments</h3> | ||
| 61 | {% for ic in patch.inline_comments %} | ||
| 62 | <div style="border: 1px solid #ddd; border-radius: 4px; padding: 12px; margin-bottom: 12px;"> | ||
| 63 | <p style="margin: 0 0 8px 0;"> | ||
| 64 | <strong>{{ ic.author }}</strong> | ||
| 65 | <span class="mono" style="color: #666;">{{ ic.file }}:{{ ic.line }}</span> | ||
| 66 | <span class="mono" style="color: #666; font-size: 0.85em;">{{ ic.timestamp }}</span> | ||
| 67 | {% if let Some(rev) = ic.revision %} rev {{ rev }}{% endif %} | ||
| 68 | </p> | ||
| 69 | <pre style="margin: 0; white-space: pre-wrap;">{{ ic.body }}</pre> | ||
| 70 | </div> | ||
| 71 | {% endfor %} | ||
| 72 | {% endif %} | ||
| 73 | |||
| 74 | {% if !patch.comments.is_empty() %} | ||
| 75 | <h3>Comments</h3> | ||
| 76 | {% for comment in patch.comments %} | ||
| 77 | <div style="border: 1px solid #ddd; border-radius: 4px; padding: 12px; margin-bottom: 12px;"> | ||
| 78 | <p style="margin: 0 0 8px 0;"> | ||
| 79 | <strong>{{ comment.author }}</strong> | ||
| 80 | <span class="mono" style="color: #666; font-size: 0.85em;">{{ comment.timestamp }}</span> | ||
| 81 | </p> | ||
| 82 | <pre style="margin: 0; white-space: pre-wrap;">{{ comment.body }}</pre> | ||
| 83 | </div> | ||
| 84 | {% endfor %} | ||
| 85 | {% endif %} | ||
| 86 | {% endblock %} | ||
src/server/http/templates/patches.html
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,35 @@ | |||
| 1 | {% extends "repo_base.html" %} | ||
| 2 | |||
| 3 | {% block title %}Patches — {{ repo_name }} — {{ site_title }}{% endblock %} | ||
| 4 | |||
| 5 | {% block content %} | ||
| 6 | <h2>Patches</h2> | ||
| 7 | {% if patches.is_empty() %} | ||
| 8 | <p style="color: #666;">No patches.</p> | ||
| 9 | {% else %} | ||
| 10 | <table> | ||
| 11 | <thead> | ||
| 12 | <tr> | ||
| 13 | <th>ID</th> | ||
| 14 | <th>Status</th> | ||
| 15 | <th>Title</th> | ||
| 16 | <th>Author</th> | ||
| 17 | <th>Branch</th> | ||
| 18 | <th>Updated</th> | ||
| 19 | </tr> | ||
| 20 | </thead> | ||
| 21 | <tbody> | ||
| 22 | {% for p in patches %} | ||
| 23 | <tr> | ||
| 24 | <td class="mono"><a href="/{{ repo_name }}/patches/{{ p.id }}">{{ p.short_id }}</a></td> | ||
| 25 | <td><span class="status-{{ p.status }}">{{ p.status }}</span></td> | ||
| 26 | <td><a href="/{{ repo_name }}/patches/{{ p.id }}">{{ p.title }}</a></td> | ||
| 27 | <td style="color: #666;">{{ p.author }}</td> | ||
| 28 | <td class="mono" style="color: #666;">{{ p.branch }}</td> | ||
| 29 | <td class="mono" style="color: #666;">{{ p.updated }}</td> | ||
| 30 | </tr> | ||
| 31 | {% endfor %} | ||
| 32 | </tbody> | ||
| 33 | </table> | ||
| 34 | {% endif %} | ||
| 35 | {% endblock %} | ||
src/server/http/templates/repo_base.html
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,15 @@ | |||
| 1 | {% extends "base.html" %} | ||
| 2 | |||
| 3 | {% block nav %} | ||
| 4 | <nav> | ||
| 5 | <a href="/{{ repo_name }}">{{ repo_name }}</a> / | ||
| 6 | <a href="/{{ repo_name }}/commits"{% if active_section == "commits" %} class="active"{% endif %}>commits</a> | ||
| 7 | <a href="/{{ repo_name }}/tree"{% if active_section == "tree" %} class="active"{% endif %}>tree</a> | ||
| 8 | <a href="/{{ repo_name }}/patches"{% if active_section == "patches" %} class="active"{% endif %}>patches ({{ open_patches }})</a> | ||
| 9 | <a href="/{{ repo_name }}/issues"{% if active_section == "issues" %} class="active"{% endif %}>issues ({{ open_issues }})</a> | ||
| 10 | </nav> | ||
| 11 | {% endblock %} | ||
| 12 | |||
| 13 | {% block body %} | ||
| 14 | {% block content %}{% endblock %} | ||
| 15 | {% endblock %} | ||
src/server/http/templates/repo_list.html
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,29 @@ | |||
| 1 | {% extends "base.html" %} | ||
| 2 | |||
| 3 | {% block body %} | ||
| 4 | <div> | ||
| 5 | <h2 style="margin-top: 0;">Repositories</h2> | ||
| 6 | {% if repos.is_empty() %} | ||
| 7 | <p style="color: #666;">No repositories found.</p> | ||
| 8 | {% else %} | ||
| 9 | <table> | ||
| 10 | <thead> | ||
| 11 | <tr> | ||
| 12 | <th>Name</th> | ||
| 13 | <th>Description</th> | ||
| 14 | <th>Last Commit</th> | ||
| 15 | </tr> | ||
| 16 | </thead> | ||
| 17 | <tbody> | ||
| 18 | {% for repo in repos %} | ||
| 19 | <tr> | ||
| 20 | <td><a href="/{{ repo.name }}">{{ repo.name }}</a></td> | ||
| 21 | <td style="color: #666;">{{ repo.description }}</td> | ||
| 22 | <td class="mono" style="color: #666;">{{ repo.last_commit }}</td> | ||
| 23 | </tr> | ||
| 24 | {% endfor %} | ||
| 25 | </tbody> | ||
| 26 | </table> | ||
| 27 | {% endif %} | ||
| 28 | </div> | ||
| 29 | {% endblock %} | ||
src/server/http/templates/repo_overview.html
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,87 @@ | |||
| 1 | {% extends "repo_base.html" %} | ||
| 2 | |||
| 3 | {% block title %}{{ repo_name }} — {{ site_title }}{% endblock %} | ||
| 4 | |||
| 5 | {% block content %} | ||
| 6 | <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin-bottom: 32px;"> | ||
| 7 | <div> | ||
| 8 | <h3 style="margin-top: 0;">Open Patches</h3> | ||
| 9 | {% if patches.is_empty() %} | ||
| 10 | <p style="color: #666;">No open patches.</p> | ||
| 11 | {% else %} | ||
| 12 | <table> | ||
| 13 | <thead> | ||
| 14 | <tr> | ||
| 15 | <th>ID</th> | ||
| 16 | <th>Title</th> | ||
| 17 | <th>Author</th> | ||
| 18 | </tr> | ||
| 19 | </thead> | ||
| 20 | <tbody> | ||
| 21 | {% for patch in patches %} | ||
| 22 | <tr> | ||
| 23 | <td class="mono"><a href="/{{ repo_name }}/patches/{{ patch.id }}">{{ patch.id[..8] }}</a></td> | ||
| 24 | <td><a href="/{{ repo_name }}/patches/{{ patch.id }}">{{ patch.title }}</a></td> | ||
| 25 | <td style="color: #666;">{{ patch.author }}</td> | ||
| 26 | </tr> | ||
| 27 | {% endfor %} | ||
| 28 | </tbody> | ||
| 29 | </table> | ||
| 30 | {% endif %} | ||
| 31 | </div> | ||
| 32 | |||
| 33 | <div> | ||
| 34 | <h3 style="margin-top: 0;">Open Issues</h3> | ||
| 35 | {% if issues.is_empty() %} | ||
| 36 | <p style="color: #666;">No open issues.</p> | ||
| 37 | {% else %} | ||
| 38 | <table> | ||
| 39 | <thead> | ||
| 40 | <tr> | ||
| 41 | <th>ID</th> | ||
| 42 | <th>Title</th> | ||
| 43 | <th>Author</th> | ||
| 44 | </tr> | ||
| 45 | </thead> | ||
| 46 | <tbody> | ||
| 47 | {% for issue in issues %} | ||
| 48 | <tr> | ||
| 49 | <td class="mono"><a href="/{{ repo_name }}/issues/{{ issue.id }}">{{ issue.id[..8] }}</a></td> | ||
| 50 | <td><a href="/{{ repo_name }}/issues/{{ issue.id }}">{{ issue.title }}</a></td> | ||
| 51 | <td style="color: #666;">{{ issue.author }}</td> | ||
| 52 | </tr> | ||
| 53 | {% endfor %} | ||
| 54 | </tbody> | ||
| 55 | </table> | ||
| 56 | {% endif %} | ||
| 57 | </div> | ||
| 58 | </div> | ||
| 59 | |||
| 60 | <div> | ||
| 61 | <h3>Recent Commits</h3> | ||
| 62 | {% if commits.is_empty() %} | ||
| 63 | <p style="color: #666;">No commits yet.</p> | ||
| 64 | {% else %} | ||
| 65 | <table> | ||
| 66 | <thead> | ||
| 67 | <tr> | ||
| 68 | <th>Commit</th> | ||
| 69 | <th>Summary</th> | ||
| 70 | <th>Author</th> | ||
| 71 | <th>Date</th> | ||
| 72 | </tr> | ||
| 73 | </thead> | ||
| 74 | <tbody> | ||
| 75 | {% for commit in commits %} | ||
| 76 | <tr> | ||
| 77 | <td class="mono"><a href="/{{ repo_name }}/diff/{{ commit.id }}">{{ commit.short_id }}</a></td> | ||
| 78 | <td>{{ commit.summary }}</td> | ||
| 79 | <td style="color: #666;">{{ commit.author }}</td> | ||
| 80 | <td class="mono" style="color: #666;">{{ commit.date }}</td> | ||
| 81 | </tr> | ||
| 82 | {% endfor %} | ||
| 83 | </tbody> | ||
| 84 | </table> | ||
| 85 | {% endif %} | ||
| 86 | </div> | ||
| 87 | {% endblock %} | ||
src/server/http/templates/tree.html
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,34 @@ | |||
| 1 | {% extends "repo_base.html" %} | ||
| 2 | |||
| 3 | {% block title %}Tree — {{ repo_name }} — {{ site_title }}{% endblock %} | ||
| 4 | |||
| 5 | {% block content %} | ||
| 6 | <h2>Tree: {{ ref_name }}{% if !path_display.is_empty() %} / {{ path_display }}{% endif %}</h2> | ||
| 7 | <table> | ||
| 8 | <thead> | ||
| 9 | <tr> | ||
| 10 | <th>Name</th> | ||
| 11 | <th>Type</th> | ||
| 12 | </tr> | ||
| 13 | </thead> | ||
| 14 | <tbody> | ||
| 15 | {% if show_parent %} | ||
| 16 | <tr> | ||
| 17 | <td class="mono"><a href="/{{ repo_name }}/tree/{{ ref_name }}/{{ parent_path }}">..</a></td> | ||
| 18 | <td style="color: #666;">dir</td> | ||
| 19 | </tr> | ||
| 20 | {% endif %} | ||
| 21 | {% for entry in entries %} | ||
| 22 | <tr> | ||
| 23 | {% if entry.is_dir %} | ||
| 24 | <td class="mono"><a href="/{{ repo_name }}/tree/{{ ref_name }}/{{ entry.full_path }}">{{ entry.name }}/</a></td> | ||
| 25 | <td style="color: #666;">dir</td> | ||
| 26 | {% else %} | ||
| 27 | <td class="mono"><a href="/{{ repo_name }}/blob/{{ ref_name }}/{{ entry.full_path }}">{{ entry.name }}</a></td> | ||
| 28 | <td style="color: #666;">file</td> | ||
| 29 | {% endif %} | ||
| 30 | </tr> | ||
| 31 | {% endfor %} | ||
| 32 | </tbody> | ||
| 33 | </table> | ||
| 34 | {% endblock %} | ||
src/server/main.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,97 @@ | |||
| 1 | use std::path::PathBuf; | ||
| 2 | |||
| 3 | use clap::Parser; | ||
| 4 | use tracing::info; | ||
| 5 | |||
| 6 | mod config; | ||
| 7 | mod http; | ||
| 8 | mod repos; | ||
| 9 | mod ssh; | ||
| 10 | |||
| 11 | #[derive(Parser)] | ||
| 12 | #[command(name = "git-collab-server", about = "Minimal git hosting server")] | ||
| 13 | struct Args { | ||
| 14 | #[arg(short, long)] | ||
| 15 | config: PathBuf, | ||
| 16 | } | ||
| 17 | |||
| 18 | #[tokio::main] | ||
| 19 | async fn main() { | ||
| 20 | tracing_subscriber::fmt::init(); | ||
| 21 | |||
| 22 | let args = Args::parse(); | ||
| 23 | |||
| 24 | let config = match config::ServerConfig::from_file(&args.config) { | ||
| 25 | Ok(c) => c, | ||
| 26 | Err(e) => { | ||
| 27 | eprintln!("Failed to load config from {:?}: {}", args.config, e); | ||
| 28 | std::process::exit(1); | ||
| 29 | } | ||
| 30 | }; | ||
| 31 | |||
| 32 | if !config.repos_dir.exists() { | ||
| 33 | eprintln!("repos_dir {:?} does not exist", config.repos_dir); | ||
| 34 | std::process::exit(1); | ||
| 35 | } | ||
| 36 | |||
| 37 | let host_key_path = config.repos_dir.join(".server").join("host_key"); | ||
| 38 | let host_key = match ssh::load_or_generate_host_key(&host_key_path) { | ||
| 39 | Ok(k) => k, | ||
| 40 | Err(e) => { | ||
| 41 | eprintln!("Failed to load/generate SSH host key: {}", e); | ||
| 42 | std::process::exit(1); | ||
| 43 | } | ||
| 44 | }; | ||
| 45 | |||
| 46 | let fingerprint = match host_key.clone_public_key() { | ||
| 47 | Ok(pub_key) => pub_key.fingerprint(), | ||
| 48 | Err(e) => { | ||
| 49 | eprintln!("Failed to get host key fingerprint: {}", e); | ||
| 50 | std::process::exit(1); | ||
| 51 | } | ||
| 52 | }; | ||
| 53 | |||
| 54 | info!("HTTP listening on {}", config.http_bind); | ||
| 55 | info!("SSH listening on {}", config.ssh_bind); | ||
| 56 | info!("Repos dir: {:?}", config.repos_dir); | ||
| 57 | info!("Site title: {}", config.site_title); | ||
| 58 | info!("SSH host key fingerprint: SHA256:{}", fingerprint); | ||
| 59 | |||
| 60 | let app_state = http::AppState { | ||
| 61 | repos_dir: config.repos_dir.clone(), | ||
| 62 | site_title: config.site_title.clone(), | ||
| 63 | }; | ||
| 64 | let router = http::router(app_state); | ||
| 65 | |||
| 66 | let ssh_config = ssh::session::SshServerConfig { | ||
| 67 | repos_dir: config.repos_dir.clone(), | ||
| 68 | authorized_keys_path: config.authorized_keys.clone(), | ||
| 69 | }; | ||
| 70 | |||
| 71 | let http_bind = config.http_bind; | ||
| 72 | let ssh_bind = config.ssh_bind; | ||
| 73 | |||
| 74 | let http_listener = match tokio::net::TcpListener::bind(http_bind).await { | ||
| 75 | Ok(l) => l, | ||
| 76 | Err(e) => { | ||
| 77 | eprintln!("Failed to bind HTTP on {}: {}", http_bind, e); | ||
| 78 | std::process::exit(1); | ||
| 79 | } | ||
| 80 | }; | ||
| 81 | |||
| 82 | let http_fut = axum::serve(http_listener, router); | ||
| 83 | let ssh_fut = ssh::serve(ssh_bind, host_key, ssh_config); | ||
| 84 | |||
| 85 | tokio::select! { | ||
| 86 | result = http_fut => { | ||
| 87 | if let Err(e) = result { | ||
| 88 | eprintln!("HTTP server error: {}", e); | ||
| 89 | } | ||
| 90 | } | ||
| 91 | result = ssh_fut => { | ||
| 92 | if let Err(e) = result { | ||
| 93 | eprintln!("SSH server error: {}", e); | ||
| 94 | } | ||
| 95 | } | ||
| 96 | } | ||
| 97 | } | ||
src/server/repos.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,130 @@ | |||
| 1 | use std::path::{Path, PathBuf}; | ||
| 2 | |||
| 3 | /// A discovered git repository on disk. | ||
| 4 | #[derive(Debug, Clone)] | ||
| 5 | pub struct RepoEntry { | ||
| 6 | pub name: String, | ||
| 7 | pub path: PathBuf, | ||
| 8 | pub bare: bool, | ||
| 9 | } | ||
| 10 | |||
| 11 | /// Scan a directory for git repositories. | ||
| 12 | pub fn discover(repos_dir: &Path) -> Result<Vec<RepoEntry>, std::io::Error> { | ||
| 13 | let mut entries = Vec::new(); | ||
| 14 | let read_dir = std::fs::read_dir(repos_dir)?; | ||
| 15 | |||
| 16 | for entry in read_dir { | ||
| 17 | let entry = entry?; | ||
| 18 | let path = entry.path(); | ||
| 19 | if !path.is_dir() { | ||
| 20 | continue; | ||
| 21 | } | ||
| 22 | |||
| 23 | let dir_name = entry.file_name().to_string_lossy().to_string(); | ||
| 24 | |||
| 25 | if path.join("HEAD").is_file() { | ||
| 26 | let name = dir_name.strip_suffix(".git").unwrap_or(&dir_name).to_string(); | ||
| 27 | entries.push(RepoEntry { name, path, bare: true }); | ||
| 28 | } else if path.join(".git").is_dir() { | ||
| 29 | entries.push(RepoEntry { name: dir_name, path, bare: false }); | ||
| 30 | } | ||
| 31 | } | ||
| 32 | |||
| 33 | entries.sort_by(|a, b| a.name.cmp(&b.name)); | ||
| 34 | Ok(entries) | ||
| 35 | } | ||
| 36 | |||
| 37 | /// Resolve a repo name to its on-disk entry. | ||
| 38 | /// Note: this performs a full directory scan on each call. Fine for a small | ||
| 39 | /// number of repos; consider caching with a short TTL if this becomes a bottleneck. | ||
| 40 | pub fn resolve(repos_dir: &Path, name: &str) -> Option<RepoEntry> { | ||
| 41 | let entries = discover(repos_dir).ok()?; | ||
| 42 | entries.into_iter().find(|e| e.name == name) | ||
| 43 | } | ||
| 44 | |||
| 45 | /// Open a git2::Repository from a RepoEntry. | ||
| 46 | pub fn open(entry: &RepoEntry) -> Result<git2::Repository, git2::Error> { | ||
| 47 | if entry.bare { | ||
| 48 | git2::Repository::open_bare(&entry.path) | ||
| 49 | } else { | ||
| 50 | git2::Repository::open(&entry.path) | ||
| 51 | } | ||
| 52 | } | ||
| 53 | |||
| 54 | #[cfg(test)] | ||
| 55 | mod tests { | ||
| 56 | use super::*; | ||
| 57 | use tempfile::TempDir; | ||
| 58 | use std::process::Command; | ||
| 59 | |||
| 60 | fn init_bare(parent: &Path, name: &str) { | ||
| 61 | Command::new("git") | ||
| 62 | .args(["init", "--bare", name]) | ||
| 63 | .current_dir(parent) | ||
| 64 | .output() | ||
| 65 | .expect("git init --bare failed"); | ||
| 66 | } | ||
| 67 | |||
| 68 | fn init_non_bare(parent: &Path, name: &str) { | ||
| 69 | Command::new("git") | ||
| 70 | .args(["init", name]) | ||
| 71 | .current_dir(parent) | ||
| 72 | .output() | ||
| 73 | .expect("git init failed"); | ||
| 74 | } | ||
| 75 | |||
| 76 | #[test] | ||
| 77 | fn discover_bare_repos() { | ||
| 78 | let tmp = TempDir::new().unwrap(); | ||
| 79 | init_bare(tmp.path(), "alpha.git"); | ||
| 80 | init_bare(tmp.path(), "beta.git"); | ||
| 81 | let repos = discover(tmp.path()).unwrap(); | ||
| 82 | assert_eq!(repos.len(), 2); | ||
| 83 | assert_eq!(repos[0].name, "alpha"); | ||
| 84 | assert_eq!(repos[1].name, "beta"); | ||
| 85 | assert!(repos[0].bare); | ||
| 86 | } | ||
| 87 | |||
| 88 | #[test] | ||
| 89 | fn discover_non_bare_repos() { | ||
| 90 | let tmp = TempDir::new().unwrap(); | ||
| 91 | init_non_bare(tmp.path(), "myproject"); | ||
| 92 | let repos = discover(tmp.path()).unwrap(); | ||
| 93 | assert_eq!(repos.len(), 1); | ||
| 94 | assert_eq!(repos[0].name, "myproject"); | ||
| 95 | assert!(!repos[0].bare); | ||
| 96 | } | ||
| 97 | |||
| 98 | #[test] | ||
| 99 | fn discover_skips_non_repo_dirs() { | ||
| 100 | let tmp = TempDir::new().unwrap(); | ||
| 101 | std::fs::create_dir(tmp.path().join("not-a-repo")).unwrap(); | ||
| 102 | init_bare(tmp.path(), "real.git"); | ||
| 103 | let repos = discover(tmp.path()).unwrap(); | ||
| 104 | assert_eq!(repos.len(), 1); | ||
| 105 | assert_eq!(repos[0].name, "real"); | ||
| 106 | } | ||
| 107 | |||
| 108 | #[test] | ||
| 109 | fn resolve_finds_repo_by_name() { | ||
| 110 | let tmp = TempDir::new().unwrap(); | ||
| 111 | init_bare(tmp.path(), "myrepo.git"); | ||
| 112 | let entry = resolve(tmp.path(), "myrepo").unwrap(); | ||
| 113 | assert_eq!(entry.name, "myrepo"); | ||
| 114 | } | ||
| 115 | |||
| 116 | #[test] | ||
| 117 | fn resolve_returns_none_for_unknown() { | ||
| 118 | let tmp = TempDir::new().unwrap(); | ||
| 119 | assert!(resolve(tmp.path(), "nope").is_none()); | ||
| 120 | } | ||
| 121 | |||
| 122 | #[test] | ||
| 123 | fn open_bare_repo() { | ||
| 124 | let tmp = TempDir::new().unwrap(); | ||
| 125 | init_bare(tmp.path(), "test.git"); | ||
| 126 | let entry = resolve(tmp.path(), "test").unwrap(); | ||
| 127 | let repo = open(&entry).unwrap(); | ||
| 128 | assert!(repo.is_bare()); | ||
| 129 | } | ||
| 130 | } | ||
src/server/ssh/auth.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,83 @@ | |||
| 1 | use std::path::Path; | ||
| 2 | |||
| 3 | #[derive(Debug, Clone)] | ||
| 4 | #[allow(dead_code)] | ||
| 5 | pub struct AuthorizedKey { | ||
| 6 | pub key_type: String, | ||
| 7 | pub key_data: String, | ||
| 8 | pub comment: Option<String>, | ||
| 9 | } | ||
| 10 | |||
| 11 | pub fn parse_authorized_keys(content: &str) -> Vec<AuthorizedKey> { | ||
| 12 | content | ||
| 13 | .lines() | ||
| 14 | .filter_map(|line| { | ||
| 15 | let line = line.trim(); | ||
| 16 | if line.is_empty() || line.starts_with('#') { | ||
| 17 | return None; | ||
| 18 | } | ||
| 19 | let mut parts = line.splitn(3, ' '); | ||
| 20 | let key_type = parts.next()?.to_string(); | ||
| 21 | let key_data = parts.next()?.to_string(); | ||
| 22 | let comment = parts.next().map(|s| s.to_string()); | ||
| 23 | Some(AuthorizedKey { key_type, key_data, comment }) | ||
| 24 | }) | ||
| 25 | .collect() | ||
| 26 | } | ||
| 27 | |||
| 28 | pub fn load_authorized_keys(path: &Path) -> Result<Vec<AuthorizedKey>, std::io::Error> { | ||
| 29 | let content = std::fs::read_to_string(path)?; | ||
| 30 | Ok(parse_authorized_keys(&content)) | ||
| 31 | } | ||
| 32 | |||
| 33 | pub fn is_authorized(keys: &[AuthorizedKey], key_type: &str, key_data: &str) -> bool { | ||
| 34 | keys.iter().any(|k| k.key_type == key_type && k.key_data == key_data) | ||
| 35 | } | ||
| 36 | |||
| 37 | #[cfg(test)] | ||
| 38 | mod tests { | ||
| 39 | use super::*; | ||
| 40 | |||
| 41 | #[test] | ||
| 42 | fn parse_single_key() { | ||
| 43 | let content = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITest user@host\n"; | ||
| 44 | let keys = parse_authorized_keys(content); | ||
| 45 | assert_eq!(keys.len(), 1); | ||
| 46 | assert_eq!(keys[0].key_type, "ssh-ed25519"); | ||
| 47 | assert_eq!(keys[0].key_data, "AAAAC3NzaC1lZDI1NTE5AAAAITest"); | ||
| 48 | assert_eq!(keys[0].comment.as_deref(), Some("user@host")); | ||
| 49 | } | ||
| 50 | |||
| 51 | #[test] | ||
| 52 | fn parse_multiple_keys() { | ||
| 53 | let content = "# alice\nssh-ed25519 AAAA1111 alice@example.com\n\n# bob\nssh-ed25519 AAAA2222 bob@work\n"; | ||
| 54 | let keys = parse_authorized_keys(content); | ||
| 55 | assert_eq!(keys.len(), 2); | ||
| 56 | assert_eq!(keys[0].key_data, "AAAA1111"); | ||
| 57 | assert_eq!(keys[1].key_data, "AAAA2222"); | ||
| 58 | } | ||
| 59 | |||
| 60 | #[test] | ||
| 61 | fn skip_comments_and_blanks() { | ||
| 62 | let content = "# this is a comment\n\n # indented comment\n\nssh-ed25519 AAAAkey user\n"; | ||
| 63 | let keys = parse_authorized_keys(content); | ||
| 64 | assert_eq!(keys.len(), 1); | ||
| 65 | } | ||
| 66 | |||
| 67 | #[test] | ||
| 68 | fn key_without_comment() { | ||
| 69 | let content = "ssh-ed25519 AAAAnocomment\n"; | ||
| 70 | let keys = parse_authorized_keys(content); | ||
| 71 | assert_eq!(keys.len(), 1); | ||
| 72 | assert!(keys[0].comment.is_none()); | ||
| 73 | } | ||
| 74 | |||
| 75 | #[test] | ||
| 76 | fn is_authorized_matches() { | ||
| 77 | let keys = parse_authorized_keys("ssh-ed25519 AAAA1111 alice\nssh-ed25519 AAAA2222 bob\n"); | ||
| 78 | assert!(is_authorized(&keys, "ssh-ed25519", "AAAA1111")); | ||
| 79 | assert!(is_authorized(&keys, "ssh-ed25519", "AAAA2222")); | ||
| 80 | assert!(!is_authorized(&keys, "ssh-ed25519", "AAAA9999")); | ||
| 81 | assert!(!is_authorized(&keys, "ssh-rsa", "AAAA1111")); | ||
| 82 | } | ||
| 83 | } | ||
src/server/ssh/mod.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,82 @@ | |||
| 1 | pub mod auth; | ||
| 2 | pub mod session; | ||
| 3 | |||
| 4 | use std::net::SocketAddr; | ||
| 5 | use std::path::Path; | ||
| 6 | use std::sync::Arc; | ||
| 7 | |||
| 8 | use async_trait::async_trait; | ||
| 9 | use russh_keys::key::KeyPair; | ||
| 10 | use russh::server::Server as _; | ||
| 11 | use tracing::{error, info}; | ||
| 12 | |||
| 13 | use session::{SshHandler, SshServerConfig}; | ||
| 14 | |||
| 15 | /// Load an ed25519 host key from disk, or generate a new one if not found. | ||
| 16 | pub fn load_or_generate_host_key(key_path: &Path) -> Result<KeyPair, Box<dyn std::error::Error>> { | ||
| 17 | if key_path.exists() { | ||
| 18 | info!("Loading SSH host key from {:?}", key_path); | ||
| 19 | let key = russh_keys::load_secret_key(key_path, None)?; | ||
| 20 | Ok(key) | ||
| 21 | } else { | ||
| 22 | info!("Generating new SSH host key at {:?}", key_path); | ||
| 23 | let key = KeyPair::generate_ed25519(); | ||
| 24 | // Ensure parent directory exists | ||
| 25 | if let Some(parent) = key_path.parent() { | ||
| 26 | std::fs::create_dir_all(parent)?; | ||
| 27 | } | ||
| 28 | let file = std::fs::File::create(key_path)?; | ||
| 29 | // Restrict host key file permissions to owner-only | ||
| 30 | #[cfg(unix)] | ||
| 31 | { | ||
| 32 | use std::os::unix::fs::PermissionsExt; | ||
| 33 | std::fs::set_permissions(key_path, std::fs::Permissions::from_mode(0o600))?; | ||
| 34 | } | ||
| 35 | russh_keys::encode_pkcs8_pem(&key, file)?; | ||
| 36 | Ok(key) | ||
| 37 | } | ||
| 38 | } | ||
| 39 | |||
| 40 | /// The russh Server implementation that spawns new SshHandler per connection. | ||
| 41 | struct CollabSshServer { | ||
| 42 | config: Arc<SshServerConfig>, | ||
| 43 | } | ||
| 44 | |||
| 45 | #[async_trait] | ||
| 46 | impl russh::server::Server for CollabSshServer { | ||
| 47 | type Handler = SshHandler; | ||
| 48 | |||
| 49 | fn new_client(&mut self, peer_addr: Option<SocketAddr>) -> SshHandler { | ||
| 50 | info!("New SSH connection from {:?}", peer_addr); | ||
| 51 | SshHandler::new(self.config.clone()) | ||
| 52 | } | ||
| 53 | } | ||
| 54 | |||
| 55 | /// Start the SSH server on the given bind address. | ||
| 56 | pub async fn serve( | ||
| 57 | bind_addr: SocketAddr, | ||
| 58 | host_key: KeyPair, | ||
| 59 | ssh_config: SshServerConfig, | ||
| 60 | ) -> Result<(), std::io::Error> { | ||
| 61 | let russh_config = russh::server::Config { | ||
| 62 | keys: vec![host_key], | ||
| 63 | methods: russh::MethodSet::PUBLICKEY, | ||
| 64 | ..Default::default() | ||
| 65 | }; | ||
| 66 | |||
| 67 | let mut server = CollabSshServer { | ||
| 68 | config: Arc::new(ssh_config), | ||
| 69 | }; | ||
| 70 | |||
| 71 | info!("SSH server listening on {}", bind_addr); | ||
| 72 | match server | ||
| 73 | .run_on_address(Arc::new(russh_config), bind_addr) | ||
| 74 | .await | ||
| 75 | { | ||
| 76 | Ok(()) => Ok(()), | ||
| 77 | Err(e) => { | ||
| 78 | error!("SSH server error: {}", e); | ||
| 79 | Err(e) | ||
| 80 | } | ||
| 81 | } | ||
| 82 | } | ||
src/server/ssh/session.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,342 @@ | |||
| 1 | use std::path::{Path, PathBuf}; | ||
| 2 | use std::sync::Arc; | ||
| 3 | |||
| 4 | use async_trait::async_trait; | ||
| 5 | use russh::server::{Auth, Handler, Msg, Session}; | ||
| 6 | use russh::{Channel, ChannelId, CryptoVec}; | ||
| 7 | use russh_keys::key::PublicKey; | ||
| 8 | use russh_keys::PublicKeyBase64; | ||
| 9 | use tokio::process::Command; | ||
| 10 | use tokio::sync::mpsc; | ||
| 11 | use tracing::{debug, error, info, warn}; | ||
| 12 | |||
| 13 | use super::auth::{is_authorized, load_authorized_keys}; | ||
| 14 | |||
| 15 | /// Configuration shared across all SSH connections. | ||
| 16 | #[derive(Debug, Clone)] | ||
| 17 | pub struct SshServerConfig { | ||
| 18 | pub repos_dir: PathBuf, | ||
| 19 | pub authorized_keys_path: PathBuf, | ||
| 20 | } | ||
| 21 | |||
| 22 | /// Per-connection SSH session handler. | ||
| 23 | pub struct SshHandler { | ||
| 24 | config: Arc<SshServerConfig>, | ||
| 25 | authenticated: bool, | ||
| 26 | /// Sender for forwarding client data (stdin) to the spawned git subprocess. | ||
| 27 | stdin_tx: Option<mpsc::Sender<Vec<u8>>>, | ||
| 28 | } | ||
| 29 | |||
| 30 | impl SshHandler { | ||
| 31 | pub fn new(config: Arc<SshServerConfig>) -> Self { | ||
| 32 | Self { | ||
| 33 | config, | ||
| 34 | authenticated: false, | ||
| 35 | stdin_tx: None, | ||
| 36 | } | ||
| 37 | } | ||
| 38 | } | ||
| 39 | |||
| 40 | /// Parse a git command string like `git-upload-pack '/path/to/repo.git'`. | ||
| 41 | /// Returns (command, repo_path) or None if the command is not allowed. | ||
| 42 | pub fn parse_git_command(data: &str) -> Option<(&str, &str)> { | ||
| 43 | let data = data.trim(); | ||
| 44 | let (cmd, rest) = data.split_once(' ')?; | ||
| 45 | match cmd { | ||
| 46 | "git-upload-pack" | "git-receive-pack" => {} | ||
| 47 | _ => return None, | ||
| 48 | } | ||
| 49 | let rest = rest.trim(); | ||
| 50 | // Strip surrounding quotes (single or double) | ||
| 51 | let path = if (rest.starts_with('\'') && rest.ends_with('\'')) | ||
| 52 | || (rest.starts_with('"') && rest.ends_with('"')) | ||
| 53 | { | ||
| 54 | &rest[1..rest.len() - 1] | ||
| 55 | } else { | ||
| 56 | rest | ||
| 57 | }; | ||
| 58 | if path.is_empty() { | ||
| 59 | return None; | ||
| 60 | } | ||
| 61 | Some((cmd, path)) | ||
| 62 | } | ||
| 63 | |||
| 64 | /// Resolve a requested repo path to a safe absolute path under repos_dir. | ||
| 65 | /// Returns None if the path escapes repos_dir (e.g. via `..` or symlinks). | ||
| 66 | pub fn resolve_repo_path(repos_dir: &Path, requested: &str) -> Option<PathBuf> { | ||
| 67 | let requested = requested.trim_start_matches('/'); | ||
| 68 | // Reject any path component that is ".." | ||
| 69 | for component in Path::new(requested).components() { | ||
| 70 | match component { | ||
| 71 | std::path::Component::ParentDir => return None, | ||
| 72 | std::path::Component::Normal(_) => {} | ||
| 73 | // Allow RootDir at start if present, CurDir is fine | ||
| 74 | std::path::Component::CurDir => {} | ||
| 75 | std::path::Component::RootDir => {} | ||
| 76 | std::path::Component::Prefix(_) => return None, | ||
| 77 | } | ||
| 78 | } | ||
| 79 | let full = repos_dir.join(requested); | ||
| 80 | // Prefix check on the joined path | ||
| 81 | if !full.starts_with(repos_dir) { | ||
| 82 | return None; | ||
| 83 | } | ||
| 84 | // If the path exists, canonicalize both to catch symlink escapes | ||
| 85 | if full.exists() { | ||
| 86 | if let (Ok(canon_repos), Ok(canon_full)) = | ||
| 87 | (repos_dir.canonicalize(), full.canonicalize()) | ||
| 88 | { | ||
| 89 | if !canon_full.starts_with(&canon_repos) { | ||
| 90 | return None; | ||
| 91 | } | ||
| 92 | } | ||
| 93 | } | ||
| 94 | Some(full) | ||
| 95 | } | ||
| 96 | |||
| 97 | #[async_trait] | ||
| 98 | impl Handler for SshHandler { | ||
| 99 | type Error = russh::Error; | ||
| 100 | |||
| 101 | async fn auth_publickey( | ||
| 102 | &mut self, | ||
| 103 | _user: &str, | ||
| 104 | public_key: &PublicKey, | ||
| 105 | ) -> Result<Auth, Self::Error> { | ||
| 106 | // Reload authorized keys from disk each time (changes take effect immediately) | ||
| 107 | let keys = match load_authorized_keys(&self.config.authorized_keys_path) { | ||
| 108 | Ok(keys) => keys, | ||
| 109 | Err(e) => { | ||
| 110 | warn!("Failed to load authorized keys: {}", e); | ||
| 111 | return Ok(Auth::Reject { | ||
| 112 | proceed_with_methods: None, | ||
| 113 | }); | ||
| 114 | } | ||
| 115 | }; | ||
| 116 | |||
| 117 | let key_type = public_key.name(); | ||
| 118 | let key_data = public_key.public_key_base64(); | ||
| 119 | |||
| 120 | if is_authorized(&keys, key_type, &key_data) { | ||
| 121 | info!("Public key auth accepted for key type {}", key_type); | ||
| 122 | self.authenticated = true; | ||
| 123 | Ok(Auth::Accept) | ||
| 124 | } else { | ||
| 125 | debug!("Public key auth rejected for key type {}", key_type); | ||
| 126 | Ok(Auth::Reject { | ||
| 127 | proceed_with_methods: None, | ||
| 128 | }) | ||
| 129 | } | ||
| 130 | } | ||
| 131 | |||
| 132 | async fn channel_open_session( | ||
| 133 | &mut self, | ||
| 134 | channel: Channel<Msg>, | ||
| 135 | _session: &mut Session, | ||
| 136 | ) -> Result<bool, Self::Error> { | ||
| 137 | if self.authenticated { | ||
| 138 | debug!("Session channel opened: {:?}", channel.id()); | ||
| 139 | Ok(true) | ||
| 140 | } else { | ||
| 141 | warn!("Rejected channel open: not authenticated"); | ||
| 142 | Ok(false) | ||
| 143 | } | ||
| 144 | } | ||
| 145 | |||
| 146 | async fn exec_request( | ||
| 147 | &mut self, | ||
| 148 | channel: ChannelId, | ||
| 149 | data: &[u8], | ||
| 150 | session: &mut Session, | ||
| 151 | ) -> Result<(), Self::Error> { | ||
| 152 | let command_str = match std::str::from_utf8(data) { | ||
| 153 | Ok(s) => s, | ||
| 154 | Err(_) => { | ||
| 155 | warn!("Received non-UTF8 exec request"); | ||
| 156 | session.close(channel); | ||
| 157 | return Ok(()); | ||
| 158 | } | ||
| 159 | }; | ||
| 160 | |||
| 161 | info!("Exec request: {}", command_str); | ||
| 162 | |||
| 163 | let (git_cmd, repo_path) = match parse_git_command(command_str).map(|(c, p)| (c.to_owned(), p.to_owned())) { | ||
| 164 | Some((c, p)) => (c, p), | ||
| 165 | None => { | ||
| 166 | warn!("Rejected exec request: not a valid git command"); | ||
| 167 | session.exit_status_request(channel, 1); | ||
| 168 | session.eof(channel); | ||
| 169 | session.close(channel); | ||
| 170 | return Ok(()); | ||
| 171 | } | ||
| 172 | }; | ||
| 173 | |||
| 174 | let resolved_path = match resolve_repo_path(&self.config.repos_dir, &repo_path) { | ||
| 175 | Some(p) => p, | ||
| 176 | None => { | ||
| 177 | warn!("Rejected exec request: path traversal detected"); | ||
| 178 | session.exit_status_request(channel, 1); | ||
| 179 | session.eof(channel); | ||
| 180 | session.close(channel); | ||
| 181 | return Ok(()); | ||
| 182 | } | ||
| 183 | }; | ||
| 184 | |||
| 185 | if !resolved_path.exists() { | ||
| 186 | warn!("Rejected exec request: repo path does not exist: {:?}", resolved_path); | ||
| 187 | session.exit_status_request(channel, 1); | ||
| 188 | session.eof(channel); | ||
| 189 | session.close(channel); | ||
| 190 | return Ok(()); | ||
| 191 | } | ||
| 192 | |||
| 193 | // Create a channel for forwarding client stdin data to the git subprocess | ||
| 194 | let (tx, rx) = mpsc::channel::<Vec<u8>>(64); | ||
| 195 | self.stdin_tx = Some(tx); | ||
| 196 | |||
| 197 | // Spawn the git subprocess | ||
| 198 | let handle = session.handle(); | ||
| 199 | let git_cmd_owned = git_cmd; | ||
| 200 | tokio::spawn(async move { | ||
| 201 | if let Err(e) = run_git_command(handle, channel, &git_cmd_owned, &resolved_path, rx).await { | ||
| 202 | error!("Git subprocess error: {}", e); | ||
| 203 | } | ||
| 204 | }); | ||
| 205 | |||
| 206 | Ok(()) | ||
| 207 | } | ||
| 208 | |||
| 209 | async fn data( | ||
| 210 | &mut self, | ||
| 211 | _channel: ChannelId, | ||
| 212 | data: &[u8], | ||
| 213 | _session: &mut Session, | ||
| 214 | ) -> Result<(), Self::Error> { | ||
| 215 | // Forward client data to the git subprocess's stdin | ||
| 216 | if let Some(ref tx) = self.stdin_tx { | ||
| 217 | if tx.send(data.to_vec()).await.is_err() { | ||
| 218 | debug!("stdin channel closed, dropping data"); | ||
| 219 | } | ||
| 220 | } | ||
| 221 | Ok(()) | ||
| 222 | } | ||
| 223 | } | ||
| 224 | |||
| 225 | async fn run_git_command( | ||
| 226 | handle: russh::server::Handle, | ||
| 227 | channel: ChannelId, | ||
| 228 | git_cmd: &str, | ||
| 229 | repo_path: &Path, | ||
| 230 | mut stdin_rx: mpsc::Receiver<Vec<u8>>, | ||
| 231 | ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { | ||
| 232 | use tokio::io::{AsyncReadExt, AsyncWriteExt}; | ||
| 233 | |||
| 234 | let mut child = Command::new(git_cmd) | ||
| 235 | .arg(repo_path) | ||
| 236 | .stdin(std::process::Stdio::piped()) | ||
| 237 | .stdout(std::process::Stdio::piped()) | ||
| 238 | .stderr(std::process::Stdio::piped()) | ||
| 239 | .spawn()?; | ||
| 240 | |||
| 241 | let mut child_stdin = child.stdin.take().expect("stdin piped"); | ||
| 242 | let mut stdout = child.stdout.take().expect("stdout piped"); | ||
| 243 | |||
| 244 | // Spawn a task to forward client data to the child's stdin | ||
| 245 | tokio::spawn(async move { | ||
| 246 | while let Some(data) = stdin_rx.recv().await { | ||
| 247 | if child_stdin.write_all(&data).await.is_err() { | ||
| 248 | break; | ||
| 249 | } | ||
| 250 | } | ||
| 251 | // EOF: close stdin so the child knows we're done | ||
| 252 | drop(child_stdin); | ||
| 253 | }); | ||
| 254 | |||
| 255 | let mut buf = vec![0u8; 32768]; | ||
| 256 | |||
| 257 | loop { | ||
| 258 | let n = stdout.read(&mut buf).await?; | ||
| 259 | if n == 0 { | ||
| 260 | break; | ||
| 261 | } | ||
| 262 | let data = CryptoVec::from_slice(&buf[..n]); | ||
| 263 | if handle.data(channel, data).await.is_err() { | ||
| 264 | warn!("Failed to send data to SSH channel"); | ||
| 265 | break; | ||
| 266 | } | ||
| 267 | } | ||
| 268 | |||
| 269 | let status = child.wait().await?; | ||
| 270 | let exit_code = status.code().unwrap_or(1) as u32; | ||
| 271 | |||
| 272 | let _ = handle.exit_status_request(channel, exit_code).await; | ||
| 273 | let _ = handle.eof(channel).await; | ||
| 274 | let _ = handle.close(channel).await; | ||
| 275 | |||
| 276 | Ok(()) | ||
| 277 | } | ||
| 278 | |||
| 279 | #[cfg(test)] | ||
| 280 | mod tests { | ||
| 281 | use super::*; | ||
| 282 | |||
| 283 | #[test] | ||
| 284 | fn parse_upload_pack() { | ||
| 285 | let result = parse_git_command("git-upload-pack '/srv/git/repo.git'"); | ||
| 286 | assert_eq!(result, Some(("git-upload-pack", "/srv/git/repo.git"))); | ||
| 287 | } | ||
| 288 | |||
| 289 | #[test] | ||
| 290 | fn parse_receive_pack() { | ||
| 291 | let result = parse_git_command("git-receive-pack '/srv/git/repo.git'"); | ||
| 292 | assert_eq!(result, Some(("git-receive-pack", "/srv/git/repo.git"))); | ||
| 293 | } | ||
| 294 | |||
| 295 | #[test] | ||
| 296 | fn reject_unknown_command() { | ||
| 297 | assert_eq!(parse_git_command("rm -rf /"), None); | ||
| 298 | assert_eq!(parse_git_command("ls /tmp"), None); | ||
| 299 | assert_eq!(parse_git_command("git-push '/repo'"), None); | ||
| 300 | } | ||
| 301 | |||
| 302 | #[test] | ||
| 303 | fn parse_without_quotes() { | ||
| 304 | let result = parse_git_command("git-upload-pack /srv/git/repo.git"); | ||
| 305 | assert_eq!(result, Some(("git-upload-pack", "/srv/git/repo.git"))); | ||
| 306 | } | ||
| 307 | |||
| 308 | #[test] | ||
| 309 | fn parse_double_quotes() { | ||
| 310 | let result = parse_git_command("git-upload-pack \"/srv/git/repo.git\""); | ||
| 311 | assert_eq!(result, Some(("git-upload-pack", "/srv/git/repo.git"))); | ||
| 312 | } | ||
| 313 | |||
| 314 | #[test] | ||
| 315 | fn resolve_simple_path() { | ||
| 316 | let repos_dir = Path::new("/srv/git"); | ||
| 317 | let result = resolve_repo_path(repos_dir, "/repo.git"); | ||
| 318 | assert_eq!(result, Some(PathBuf::from("/srv/git/repo.git"))); | ||
| 319 | } | ||
| 320 | |||
| 321 | #[test] | ||
| 322 | fn resolve_nested_path() { | ||
| 323 | let repos_dir = Path::new("/srv/git"); | ||
| 324 | let result = resolve_repo_path(repos_dir, "/org/repo.git"); | ||
| 325 | assert_eq!(result, Some(PathBuf::from("/srv/git/org/repo.git"))); | ||
| 326 | } | ||
| 327 | |||
| 328 | #[test] | ||
| 329 | fn reject_traversal() { | ||
| 330 | let repos_dir = Path::new("/srv/git"); | ||
| 331 | assert_eq!(resolve_repo_path(repos_dir, "/../etc/passwd"), None); | ||
| 332 | assert_eq!(resolve_repo_path(repos_dir, "/repo/../../../etc/shadow"), None); | ||
| 333 | assert_eq!(resolve_repo_path(repos_dir, ".."), None); | ||
| 334 | } | ||
| 335 | |||
| 336 | #[test] | ||
| 337 | fn resolve_bare_name() { | ||
| 338 | let repos_dir = Path::new("/srv/git"); | ||
| 339 | let result = resolve_repo_path(repos_dir, "myrepo.git"); | ||
| 340 | assert_eq!(result, Some(PathBuf::from("/srv/git/myrepo.git"))); | ||
| 341 | } | ||
| 342 | } | ||