a73x

6d9aea18

Add git-collab-server: read-only web UI and SSH remote

a73x   2026-03-31 17:50

Commit message
Add git-collab-server: read-only web UI and SSH remote

.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 }} &middot; {{ 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 }} &middot; {{ 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 &middot; {{ patch.author }} &middot; {{ patch.branch }} &rarr; {{ patch.base_ref }}
1827 &middot; 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);"> &middot; {{ 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 &middot; {{ 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> &middot; {{ 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 &middot; {{ issue.author }} &middot; created {{ issue.created_at }}
2178 {% if !issue.labels.is_empty() %} &middot; [{{ issue.labels }}]{% endif %}
2179 {% if !issue.assignees.is_empty() %} &middot; 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> &middot; {{ 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> &nbsp; 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 }} &nbsp; {{ 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 &nbsp; 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 &nbsp; <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 &nbsp; by <strong>{{ patch.author }}</strong>
10 &nbsp; <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 &nbsp; <span class="status-{{ review.verdict }}">{{ review.verdict }}</span>
49 &nbsp; <span class="mono" style="color: #666; font-size: 0.85em;">{{ review.timestamp }}</span>
50 {% if let Some(rev) = review.revision %}&nbsp; 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 &nbsp; <span class="mono" style="color: #666;">{{ ic.file }}:{{ ic.line }}</span>
66 &nbsp; <span class="mono" style="color: #666; font-size: 0.85em;">{{ ic.timestamp }}</span>
67 {% if let Some(rev) = ic.revision %}&nbsp; 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 &nbsp; <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 }