anvilsign in

collin/anvil · 2b6114fa

Initial commit: anvil — a minimal git forge in Rust on gitoxide

Collin Richards · 2026-06-09 18:03 UTC · 2b6114fae9e5cf8bb66835db9ec6db2a961651c7 · browse files

added.gitignore+6 −0
1+/target
2+/data
3+*.db
4+*.db-wal
5+*.db-shm
6+anvil.toml
addedCargo.lock+4860 −0
1+# This file is automatically @generated by Cargo.
2+# It is not intended for manual editing.
3+version = 4
4+
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.6.0"
14+source = "registry+https://github.com/rust-lang/crates.io-index"
15+checksum = "ef60ac202874e574ce7a7158cc8bca7313dd344322482e4fadee288bf4a306b8"
16+dependencies = [
17+ "crypto-common 0.2.2",
18+ "inout",
19+]
20+
21+[[package]]
22+name = "aes"
23+version = "0.9.1"
24+source = "registry+https://github.com/rust-lang/crates.io-index"
25+checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138"
26+dependencies = [
27+ "cipher",
28+ "cpubits",
29+ "cpufeatures 0.3.0",
30+ "zeroize",
31+]
32+
33+[[package]]
34+name = "aes-gcm"
35+version = "0.11.0-rc.4"
36+source = "registry+https://github.com/rust-lang/crates.io-index"
37+checksum = "da8c919c118108f144adecad74b425b804ad075580d605d9b33c2d6d1c62a2f8"
38+dependencies = [
39+ "aead",
40+ "aes",
41+ "cipher",
42+ "ctr",
43+ "ghash",
44+ "subtle",
45+ "zeroize",
46+]
47+
48+[[package]]
49+name = "aho-corasick"
50+version = "1.1.4"
51+source = "registry+https://github.com/rust-lang/crates.io-index"
52+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
53+dependencies = [
54+ "memchr",
55+]
56+
57+[[package]]
58+name = "allocator-api2"
59+version = "0.2.21"
60+source = "registry+https://github.com/rust-lang/crates.io-index"
61+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
62+
63+[[package]]
64+name = "android_system_properties"
65+version = "0.1.5"
66+source = "registry+https://github.com/rust-lang/crates.io-index"
67+checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
68+dependencies = [
69+ "libc",
70+]
71+
72+[[package]]
73+name = "anstream"
74+version = "1.0.0"
75+source = "registry+https://github.com/rust-lang/crates.io-index"
76+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
77+dependencies = [
78+ "anstyle",
79+ "anstyle-parse",
80+ "anstyle-query",
81+ "anstyle-wincon",
82+ "colorchoice",
83+ "is_terminal_polyfill",
84+ "utf8parse",
85+]
86+
87+[[package]]
88+name = "anstyle"
89+version = "1.0.14"
90+source = "registry+https://github.com/rust-lang/crates.io-index"
91+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
92+
93+[[package]]
94+name = "anstyle-parse"
95+version = "1.0.0"
96+source = "registry+https://github.com/rust-lang/crates.io-index"
97+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
98+dependencies = [
99+ "utf8parse",
100+]
101+
102+[[package]]
103+name = "anstyle-query"
104+version = "1.1.5"
105+source = "registry+https://github.com/rust-lang/crates.io-index"
106+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
107+dependencies = [
108+ "windows-sys",
109+]
110+
111+[[package]]
112+name = "anstyle-wincon"
113+version = "3.0.11"
114+source = "registry+https://github.com/rust-lang/crates.io-index"
115+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
116+dependencies = [
117+ "anstyle",
118+ "once_cell_polyfill",
119+ "windows-sys",
120+]
121+
122+[[package]]
123+name = "anvil"
124+version = "0.0.0"
125+dependencies = [
126+ "anvil-core",
127+ "anvil-ssh",
128+ "anvil-web",
129+ "anyhow",
130+ "clap",
131+ "tokio",
132+ "tracing",
133+ "tracing-subscriber",
134+]
135+
136+[[package]]
137+name = "anvil-core"
138+version = "0.0.0"
139+dependencies = [
140+ "argon2 0.5.3",
141+ "gix",
142+ "serde",
143+ "ssh-key",
144+ "tempfile",
145+ "thiserror",
146+ "toasty",
147+ "tokio",
148+ "toml",
149+ "tracing",
150+]
151+
152+[[package]]
153+name = "anvil-git"
154+version = "0.0.0"
155+dependencies = [
156+ "anvil-core",
157+ "gitserver-core",
158+ "gix",
159+ "thiserror",
160+ "tokio",
161+ "tracing",
162+]
163+
164+[[package]]
165+name = "anvil-ssh"
166+version = "0.0.0"
167+dependencies = [
168+ "anvil-core",
169+ "anvil-git",
170+ "rand",
171+ "russh",
172+ "tokio",
173+ "tracing",
174+]
175+
176+[[package]]
177+name = "anvil-web"
178+version = "0.0.0"
179+dependencies = [
180+ "anvil-core",
181+ "anvil-git",
182+ "axum",
183+ "axum-extra",
184+ "base64",
185+ "maud",
186+ "serde",
187+ "similar",
188+ "syntect",
189+ "time",
190+ "tokio",
191+ "tokio-util",
192+ "tower-http",
193+ "tracing",
194+]
195+
196+[[package]]
197+name = "anyhow"
198+version = "1.0.102"
199+source = "registry+https://github.com/rust-lang/crates.io-index"
200+checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
201+
202+[[package]]
203+name = "arc-swap"
204+version = "1.9.1"
205+source = "registry+https://github.com/rust-lang/crates.io-index"
206+checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207"
207+dependencies = [
208+ "rustversion",
209+]
210+
211+[[package]]
212+name = "argon2"
213+version = "0.5.3"
214+source = "registry+https://github.com/rust-lang/crates.io-index"
215+checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
216+dependencies = [
217+ "base64ct",
218+ "blake2 0.10.6",
219+ "cpufeatures 0.2.17",
220+ "password-hash 0.5.0",
221+]
222+
223+[[package]]
224+name = "argon2"
225+version = "0.6.0-rc.8"
226+source = "registry+https://github.com/rust-lang/crates.io-index"
227+checksum = "7af50940b73bf4e16c15c448a2b121c63f2d68e3e54b6a8731673cb4aa0cdff5"
228+dependencies = [
229+ "base64ct",
230+ "blake2 0.11.0-rc.6",
231+ "cpufeatures 0.3.0",
232+ "password-hash 0.6.1",
233+]
234+
235+[[package]]
236+name = "arrayvec"
237+version = "0.7.6"
238+source = "registry+https://github.com/rust-lang/crates.io-index"
239+checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
240+
241+[[package]]
242+name = "async-trait"
243+version = "0.1.89"
244+source = "registry+https://github.com/rust-lang/crates.io-index"
245+checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
246+dependencies = [
247+ "proc-macro2",
248+ "quote",
249+ "syn",
250+]
251+
252+[[package]]
253+name = "atomic-waker"
254+version = "1.1.2"
255+source = "registry+https://github.com/rust-lang/crates.io-index"
256+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
257+
258+[[package]]
259+name = "autocfg"
260+version = "1.5.1"
261+source = "registry+https://github.com/rust-lang/crates.io-index"
262+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
263+
264+[[package]]
265+name = "aws-lc-rs"
266+version = "1.17.0"
267+source = "registry+https://github.com/rust-lang/crates.io-index"
268+checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00"
269+dependencies = [
270+ "aws-lc-sys",
271+ "untrusted",
272+ "zeroize",
273+]
274+
275+[[package]]
276+name = "aws-lc-sys"
277+version = "0.41.0"
278+source = "registry+https://github.com/rust-lang/crates.io-index"
279+checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4"
280+dependencies = [
281+ "cc",
282+ "cmake",
283+ "dunce",
284+ "fs_extra",
285+]
286+
287+[[package]]
288+name = "axum"
289+version = "0.8.9"
290+source = "registry+https://github.com/rust-lang/crates.io-index"
291+checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
292+dependencies = [
293+ "axum-core",
294+ "bytes",
295+ "form_urlencoded",
296+ "futures-util",
297+ "http",
298+ "http-body",
299+ "http-body-util",
300+ "hyper",
301+ "hyper-util",
302+ "itoa",
303+ "matchit",
304+ "memchr",
305+ "mime",
306+ "percent-encoding",
307+ "pin-project-lite",
308+ "serde_core",
309+ "serde_json",
310+ "serde_path_to_error",
311+ "serde_urlencoded",
312+ "sync_wrapper",
313+ "tokio",
314+ "tower",
315+ "tower-layer",
316+ "tower-service",
317+ "tracing",
318+]
319+
320+[[package]]
321+name = "axum-core"
322+version = "0.5.6"
323+source = "registry+https://github.com/rust-lang/crates.io-index"
324+checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
325+dependencies = [
326+ "bytes",
327+ "futures-core",
328+ "http",
329+ "http-body",
330+ "http-body-util",
331+ "mime",
332+ "pin-project-lite",
333+ "sync_wrapper",
334+ "tower-layer",
335+ "tower-service",
336+ "tracing",
337+]
338+
339+[[package]]
340+name = "axum-extra"
341+version = "0.10.3"
342+source = "registry+https://github.com/rust-lang/crates.io-index"
343+checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96"
344+dependencies = [
345+ "axum",
346+ "axum-core",
347+ "bytes",
348+ "cookie",
349+ "futures-util",
350+ "http",
351+ "http-body",
352+ "http-body-util",
353+ "mime",
354+ "pin-project-lite",
355+ "rustversion",
356+ "serde_core",
357+ "tower-layer",
358+ "tower-service",
359+ "tracing",
360+]
361+
362+[[package]]
363+name = "base16ct"
364+version = "1.0.0"
365+source = "registry+https://github.com/rust-lang/crates.io-index"
366+checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6"
367+
368+[[package]]
369+name = "base64"
370+version = "0.22.1"
371+source = "registry+https://github.com/rust-lang/crates.io-index"
372+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
373+
374+[[package]]
375+name = "base64ct"
376+version = "1.8.3"
377+source = "registry+https://github.com/rust-lang/crates.io-index"
378+checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
379+
380+[[package]]
381+name = "bcrypt-pbkdf"
382+version = "0.11.0"
383+source = "registry+https://github.com/rust-lang/crates.io-index"
384+checksum = "144e573728da132683b9488acd528274c790e07fc06ff81ee29f9d8f8b1041e0"
385+dependencies = [
386+ "blowfish",
387+ "pbkdf2",
388+ "sha2",
389+]
390+
391+[[package]]
392+name = "bincode"
393+version = "1.3.3"
394+source = "registry+https://github.com/rust-lang/crates.io-index"
395+checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
396+dependencies = [
397+ "serde",
398+]
399+
400+[[package]]
401+name = "bit-set"
402+version = "0.8.0"
403+source = "registry+https://github.com/rust-lang/crates.io-index"
404+checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
405+dependencies = [
406+ "bit-vec 0.8.0",
407+]
408+
409+[[package]]
410+name = "bit-set"
411+version = "0.10.0"
412+source = "registry+https://github.com/rust-lang/crates.io-index"
413+checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d"
414+dependencies = [
415+ "bit-vec 0.9.1",
416+]
417+
418+[[package]]
419+name = "bit-vec"
420+version = "0.8.0"
421+source = "registry+https://github.com/rust-lang/crates.io-index"
422+checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
423+
424+[[package]]
425+name = "bit-vec"
426+version = "0.9.1"
427+source = "registry+https://github.com/rust-lang/crates.io-index"
428+checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
429+dependencies = [
430+ "serde",
431+]
432+
433+[[package]]
434+name = "bitflags"
435+version = "2.13.0"
436+source = "registry+https://github.com/rust-lang/crates.io-index"
437+checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
438+
439+[[package]]
440+name = "blake2"
441+version = "0.10.6"
442+source = "registry+https://github.com/rust-lang/crates.io-index"
443+checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
444+dependencies = [
445+ "digest 0.10.7",
446+]
447+
448+[[package]]
449+name = "blake2"
450+version = "0.11.0-rc.6"
451+source = "registry+https://github.com/rust-lang/crates.io-index"
452+checksum = "061f1a09225e328e1ffbb378d2d49923c0ca5fee19fb5ac1cc9c1e9d52b93690"
453+dependencies = [
454+ "digest 0.11.3",
455+]
456+
457+[[package]]
458+name = "block-buffer"
459+version = "0.10.4"
460+source = "registry+https://github.com/rust-lang/crates.io-index"
461+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
462+dependencies = [
463+ "generic-array 0.14.7",
464+]
465+
466+[[package]]
467+name = "block-buffer"
468+version = "0.12.0"
469+source = "registry+https://github.com/rust-lang/crates.io-index"
470+checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be"
471+dependencies = [
472+ "hybrid-array",
473+ "zeroize",
474+]
475+
476+[[package]]
477+name = "block-padding"
478+version = "0.4.2"
479+source = "registry+https://github.com/rust-lang/crates.io-index"
480+checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b"
481+dependencies = [
482+ "hybrid-array",
483+]
484+
485+[[package]]
486+name = "blowfish"
487+version = "0.10.0"
488+source = "registry+https://github.com/rust-lang/crates.io-index"
489+checksum = "62ce3946557b35e71d1bbe07ec385073ce9eda05043f95de134eb578fcf1a298"
490+dependencies = [
491+ "byteorder",
492+ "cipher",
493+]
494+
495+[[package]]
496+name = "bstr"
497+version = "1.12.1"
498+source = "registry+https://github.com/rust-lang/crates.io-index"
499+checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab"
500+dependencies = [
501+ "memchr",
502+ "regex-automata",
503+ "serde",
504+]
505+
506+[[package]]
507+name = "bumpalo"
508+version = "3.20.3"
509+source = "registry+https://github.com/rust-lang/crates.io-index"
510+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
511+
512+[[package]]
513+name = "by_address"
514+version = "1.2.1"
515+source = "registry+https://github.com/rust-lang/crates.io-index"
516+checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06"
517+
518+[[package]]
519+name = "byteorder"
520+version = "1.5.0"
521+source = "registry+https://github.com/rust-lang/crates.io-index"
522+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
523+
524+[[package]]
525+name = "bytes"
526+version = "1.11.1"
527+source = "registry+https://github.com/rust-lang/crates.io-index"
528+checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
529+
530+[[package]]
531+name = "cbc"
532+version = "0.2.1"
533+source = "registry+https://github.com/rust-lang/crates.io-index"
534+checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896"
535+dependencies = [
536+ "cipher",
537+]
538+
539+[[package]]
540+name = "cc"
541+version = "1.2.63"
542+source = "registry+https://github.com/rust-lang/crates.io-index"
543+checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
544+dependencies = [
545+ "find-msvc-tools",
546+ "jobserver",
547+ "libc",
548+ "shlex",
549+]
550+
551+[[package]]
552+name = "cfg-if"
553+version = "1.0.4"
554+source = "registry+https://github.com/rust-lang/crates.io-index"
555+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
556+
557+[[package]]
558+name = "cfg_aliases"
559+version = "0.2.1"
560+source = "registry+https://github.com/rust-lang/crates.io-index"
561+checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
562+
563+[[package]]
564+name = "chacha20"
565+version = "0.10.0"
566+source = "registry+https://github.com/rust-lang/crates.io-index"
567+checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
568+dependencies = [
569+ "cfg-if",
570+ "cipher",
571+ "cpufeatures 0.3.0",
572+ "rand_core 0.10.1",
573+ "zeroize",
574+]
575+
576+[[package]]
577+name = "chrono"
578+version = "0.4.45"
579+source = "registry+https://github.com/rust-lang/crates.io-index"
580+checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
581+dependencies = [
582+ "iana-time-zone",
583+ "js-sys",
584+ "num-traits",
585+ "wasm-bindgen",
586+ "windows-link",
587+]
588+
589+[[package]]
590+name = "cipher"
591+version = "0.5.2"
592+source = "registry+https://github.com/rust-lang/crates.io-index"
593+checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
594+dependencies = [
595+ "block-buffer 0.12.0",
596+ "crypto-common 0.2.2",
597+ "inout",
598+ "zeroize",
599+]
600+
601+[[package]]
602+name = "clap"
603+version = "4.6.1"
604+source = "registry+https://github.com/rust-lang/crates.io-index"
605+checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
606+dependencies = [
607+ "clap_builder",
608+ "clap_derive",
609+]
610+
611+[[package]]
612+name = "clap_builder"
613+version = "4.6.0"
614+source = "registry+https://github.com/rust-lang/crates.io-index"
615+checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
616+dependencies = [
617+ "anstream",
618+ "anstyle",
619+ "clap_lex",
620+ "strsim",
621+]
622+
623+[[package]]
624+name = "clap_derive"
625+version = "4.6.1"
626+source = "registry+https://github.com/rust-lang/crates.io-index"
627+checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
628+dependencies = [
629+ "heck",
630+ "proc-macro2",
631+ "quote",
632+ "syn",
633+]
634+
635+[[package]]
636+name = "clap_lex"
637+version = "1.1.0"
638+source = "registry+https://github.com/rust-lang/crates.io-index"
639+checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
640+
641+[[package]]
642+name = "clru"
643+version = "0.6.3"
644+source = "registry+https://github.com/rust-lang/crates.io-index"
645+checksum = "197fd99cb113a8d5d9b6376f3aa817f32c1078f2343b714fff7d2ca44fdf67d5"
646+dependencies = [
647+ "hashbrown 0.16.1",
648+]
649+
650+[[package]]
651+name = "cmake"
652+version = "0.1.58"
653+source = "registry+https://github.com/rust-lang/crates.io-index"
654+checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
655+dependencies = [
656+ "cc",
657+]
658+
659+[[package]]
660+name = "cmov"
661+version = "0.5.4"
662+source = "registry+https://github.com/rust-lang/crates.io-index"
663+checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
664+
665+[[package]]
666+name = "colorchoice"
667+version = "1.0.5"
668+source = "registry+https://github.com/rust-lang/crates.io-index"
669+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
670+
671+[[package]]
672+name = "const-oid"
673+version = "0.10.2"
674+source = "registry+https://github.com/rust-lang/crates.io-index"
675+checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
676+
677+[[package]]
678+name = "cookie"
679+version = "0.18.1"
680+source = "registry+https://github.com/rust-lang/crates.io-index"
681+checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
682+dependencies = [
683+ "percent-encoding",
684+ "time",
685+ "version_check",
686+]
687+
688+[[package]]
689+name = "core-foundation-sys"
690+version = "0.8.7"
691+source = "registry+https://github.com/rust-lang/crates.io-index"
692+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
693+
694+[[package]]
695+name = "cpubits"
696+version = "0.1.1"
697+source = "registry+https://github.com/rust-lang/crates.io-index"
698+checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
699+
700+[[package]]
701+name = "cpufeatures"
702+version = "0.2.17"
703+source = "registry+https://github.com/rust-lang/crates.io-index"
704+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
705+dependencies = [
706+ "libc",
707+]
708+
709+[[package]]
710+name = "cpufeatures"
711+version = "0.3.0"
712+source = "registry+https://github.com/rust-lang/crates.io-index"
713+checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
714+dependencies = [
715+ "libc",
716+]
717+
718+[[package]]
719+name = "crc32fast"
720+version = "1.5.0"
721+source = "registry+https://github.com/rust-lang/crates.io-index"
722+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
723+dependencies = [
724+ "cfg-if",
725+]
726+
727+[[package]]
728+name = "crossbeam-channel"
729+version = "0.5.15"
730+source = "registry+https://github.com/rust-lang/crates.io-index"
731+checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
732+dependencies = [
733+ "crossbeam-utils",
734+]
735+
736+[[package]]
737+name = "crossbeam-utils"
738+version = "0.8.21"
739+source = "registry+https://github.com/rust-lang/crates.io-index"
740+checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
741+
742+[[package]]
743+name = "crypto-bigint"
744+version = "0.7.3"
745+source = "registry+https://github.com/rust-lang/crates.io-index"
746+checksum = "42a0d26b245348befa0c121944541476763dcc46ede886c88f9d12e1697d27c3"
747+dependencies = [
748+ "cpubits",
749+ "ctutils",
750+ "getrandom 0.4.2",
751+ "hybrid-array",
752+ "num-traits",
753+ "rand_core 0.10.1",
754+ "serdect",
755+ "subtle",
756+ "zeroize",
757+]
758+
759+[[package]]
760+name = "crypto-common"
761+version = "0.1.7"
762+source = "registry+https://github.com/rust-lang/crates.io-index"
763+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
764+dependencies = [
765+ "generic-array 0.14.7",
766+ "typenum",
767+]
768+
769+[[package]]
770+name = "crypto-common"
771+version = "0.2.2"
772+source = "registry+https://github.com/rust-lang/crates.io-index"
773+checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
774+dependencies = [
775+ "getrandom 0.4.2",
776+ "hybrid-array",
777+ "rand_core 0.10.1",
778+]
779+
780+[[package]]
781+name = "crypto-primes"
782+version = "0.7.0"
783+source = "registry+https://github.com/rust-lang/crates.io-index"
784+checksum = "21f41f23de7d24cdbda7f0c4d9c0351f99a4ceb258ef30e5c1927af8987ffe5a"
785+dependencies = [
786+ "crypto-bigint",
787+ "libm",
788+ "rand_core 0.10.1",
789+]
790+
791+[[package]]
792+name = "ctr"
793+version = "0.10.1"
794+source = "registry+https://github.com/rust-lang/crates.io-index"
795+checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21"
796+dependencies = [
797+ "cipher",
798+]
799+
800+[[package]]
801+name = "ctutils"
802+version = "0.4.2"
803+source = "registry+https://github.com/rust-lang/crates.io-index"
804+checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
805+dependencies = [
806+ "cmov",
807+ "subtle",
808+]
809+
810+[[package]]
811+name = "curve25519-dalek"
812+version = "5.0.0-rc.0"
813+source = "registry+https://github.com/rust-lang/crates.io-index"
814+checksum = "4f359e08ca85e7bd759e1fd933ff2bccd81864c60a8fba0e259c7f822b0924bf"
815+dependencies = [
816+ "cfg-if",
817+ "cpufeatures 0.3.0",
818+ "curve25519-dalek-derive",
819+ "digest 0.11.3",
820+ "fiat-crypto",
821+ "rustc_version",
822+ "subtle",
823+ "zeroize",
824+]
825+
826+[[package]]
827+name = "curve25519-dalek-derive"
828+version = "0.1.1"
829+source = "registry+https://github.com/rust-lang/crates.io-index"
830+checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
831+dependencies = [
832+ "proc-macro2",
833+ "quote",
834+ "syn",
835+]
836+
837+[[package]]
838+name = "dashmap"
839+version = "6.2.1"
840+source = "registry+https://github.com/rust-lang/crates.io-index"
841+checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
842+dependencies = [
843+ "cfg-if",
844+ "crossbeam-utils",
845+ "hashbrown 0.14.5",
846+ "lock_api",
847+ "once_cell",
848+ "parking_lot_core",
849+]
850+
851+[[package]]
852+name = "data-encoding"
853+version = "2.11.0"
854+source = "registry+https://github.com/rust-lang/crates.io-index"
855+checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
856+
857+[[package]]
858+name = "deadpool"
859+version = "0.13.0"
860+source = "registry+https://github.com/rust-lang/crates.io-index"
861+checksum = "883466cb8db62725aee5f4a6011e8a5d42912b42632df32aad57fc91127c6e04"
862+dependencies = [
863+ "deadpool-runtime",
864+ "num_cpus",
865+ "tokio",
866+]
867+
868+[[package]]
869+name = "deadpool-runtime"
870+version = "0.3.1"
871+source = "registry+https://github.com/rust-lang/crates.io-index"
872+checksum = "2657f61fb1dd8bf37a8d51093cc7cee4e77125b22f7753f49b289f831bec2bae"
873+dependencies = [
874+ "tokio",
875+]
876+
877+[[package]]
878+name = "delegate"
879+version = "0.13.5"
880+source = "registry+https://github.com/rust-lang/crates.io-index"
881+checksum = "780eb241654bf097afb00fc5f054a09b687dad862e485fdcf8399bb056565370"
882+dependencies = [
883+ "proc-macro2",
884+ "quote",
885+ "syn",
886+]
887+
888+[[package]]
889+name = "der"
890+version = "0.8.0"
891+source = "registry+https://github.com/rust-lang/crates.io-index"
892+checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b"
893+dependencies = [
894+ "const-oid",
895+ "pem-rfc7468",
896+ "zeroize",
897+]
898+
899+[[package]]
900+name = "deranged"
901+version = "0.5.8"
902+source = "registry+https://github.com/rust-lang/crates.io-index"
903+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
904+dependencies = [
905+ "powerfmt",
906+ "serde_core",
907+]
908+
909+[[package]]
910+name = "des"
911+version = "0.9.0"
912+source = "registry+https://github.com/rust-lang/crates.io-index"
913+checksum = "916a94e407b54f9034d71dd748234cd1e516ced6284009906ae246f177eafe5a"
914+dependencies = [
915+ "cipher",
916+]
917+
918+[[package]]
919+name = "digest"
920+version = "0.10.7"
921+source = "registry+https://github.com/rust-lang/crates.io-index"
922+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
923+dependencies = [
924+ "block-buffer 0.10.4",
925+ "crypto-common 0.1.7",
926+ "subtle",
927+]
928+
929+[[package]]
930+name = "digest"
931+version = "0.11.3"
932+source = "registry+https://github.com/rust-lang/crates.io-index"
933+checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
934+dependencies = [
935+ "block-buffer 0.12.0",
936+ "const-oid",
937+ "crypto-common 0.2.2",
938+ "ctutils",
939+]
940+
941+[[package]]
942+name = "displaydoc"
943+version = "0.2.6"
944+source = "registry+https://github.com/rust-lang/crates.io-index"
945+checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
946+dependencies = [
947+ "proc-macro2",
948+ "quote",
949+ "syn",
950+]
951+
952+[[package]]
953+name = "dunce"
954+version = "1.0.5"
955+source = "registry+https://github.com/rust-lang/crates.io-index"
956+checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
957+
958+[[package]]
959+name = "ecdsa"
960+version = "0.17.0-rc.18"
961+source = "registry+https://github.com/rust-lang/crates.io-index"
962+checksum = "54fb064faabbee66e1fc8e5c5a9458d4269dc2d8b638fe86a425adb2510d1a96"
963+dependencies = [
964+ "der",
965+ "digest 0.11.3",
966+ "elliptic-curve",
967+ "rfc6979",
968+ "signature",
969+ "spki",
970+ "zeroize",
971+]
972+
973+[[package]]
974+name = "ed25519"
975+version = "3.0.0"
976+source = "registry+https://github.com/rust-lang/crates.io-index"
977+checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a"
978+dependencies = [
979+ "pkcs8",
980+ "signature",
981+]
982+
983+[[package]]
984+name = "ed25519-dalek"
985+version = "3.0.0-rc.0"
986+source = "registry+https://github.com/rust-lang/crates.io-index"
987+checksum = "b011170fe4f04665565b4110afef66774fe9ffff278f3eb5b81cc73d26e27d60"
988+dependencies = [
989+ "curve25519-dalek",
990+ "ed25519",
991+ "rand_core 0.10.1",
992+ "serde",
993+ "sha2",
994+ "signature",
995+ "subtle",
996+ "zeroize",
997+]
998+
999+[[package]]
1000+name = "elliptic-curve"
1001+version = "0.14.0-rc.33"
1002+source = "registry+https://github.com/rust-lang/crates.io-index"
1003+checksum = "102d3643d30dd8b559613c5cced68317199597fffb278cdc88daa2ef7fafc935"
1004+dependencies = [
1005+ "base16ct",
1006+ "crypto-bigint",
1007+ "crypto-common 0.2.2",
1008+ "digest 0.11.3",
1009+ "ff",
1010+ "group",
1011+ "hkdf",
1012+ "hybrid-array",
1013+ "once_cell",
1014+ "pem-rfc7468",
1015+ "pkcs8",
1016+ "rand_core 0.10.1",
1017+ "sec1",
1018+ "subtle",
1019+ "zeroize",
1020+]
1021+
1022+[[package]]
1023+name = "encoding_rs"
1024+version = "0.8.35"
1025+source = "registry+https://github.com/rust-lang/crates.io-index"
1026+checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
1027+dependencies = [
1028+ "cfg-if",
1029+]
1030+
1031+[[package]]
1032+name = "enum_dispatch"
1033+version = "0.3.13"
1034+source = "registry+https://github.com/rust-lang/crates.io-index"
1035+checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd"
1036+dependencies = [
1037+ "once_cell",
1038+ "proc-macro2",
1039+ "quote",
1040+ "syn",
1041+]
1042+
1043+[[package]]
1044+name = "equivalent"
1045+version = "1.0.2"
1046+source = "registry+https://github.com/rust-lang/crates.io-index"
1047+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
1048+
1049+[[package]]
1050+name = "errno"
1051+version = "0.3.14"
1052+source = "registry+https://github.com/rust-lang/crates.io-index"
1053+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
1054+dependencies = [
1055+ "libc",
1056+ "windows-sys",
1057+]
1058+
1059+[[package]]
1060+name = "fallible-iterator"
1061+version = "0.3.0"
1062+source = "registry+https://github.com/rust-lang/crates.io-index"
1063+checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
1064+
1065+[[package]]
1066+name = "fallible-streaming-iterator"
1067+version = "0.1.9"
1068+source = "registry+https://github.com/rust-lang/crates.io-index"
1069+checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
1070+
1071+[[package]]
1072+name = "fancy-regex"
1073+version = "0.16.2"
1074+source = "registry+https://github.com/rust-lang/crates.io-index"
1075+checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f"
1076+dependencies = [
1077+ "bit-set 0.8.0",
1078+ "regex-automata",
1079+ "regex-syntax",
1080+]
1081+
1082+[[package]]
1083+name = "faster-hex"
1084+version = "0.10.0"
1085+source = "registry+https://github.com/rust-lang/crates.io-index"
1086+checksum = "7223ae2d2f179b803433d9c830478527e92b8117eab39460edae7f1614d9fb73"
1087+dependencies = [
1088+ "heapless",
1089+ "serde",
1090+]
1091+
1092+[[package]]
1093+name = "fastrand"
1094+version = "2.4.1"
1095+source = "registry+https://github.com/rust-lang/crates.io-index"
1096+checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
1097+
1098+[[package]]
1099+name = "ff"
1100+version = "0.14.0"
1101+source = "registry+https://github.com/rust-lang/crates.io-index"
1102+checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f"
1103+dependencies = [
1104+ "rand_core 0.10.1",
1105+ "subtle",
1106+]
1107+
1108+[[package]]
1109+name = "fiat-crypto"
1110+version = "0.3.0"
1111+source = "registry+https://github.com/rust-lang/crates.io-index"
1112+checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24"
1113+
1114+[[package]]
1115+name = "filetime"
1116+version = "0.2.29"
1117+source = "registry+https://github.com/rust-lang/crates.io-index"
1118+checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
1119+dependencies = [
1120+ "cfg-if",
1121+ "libc",
1122+]
1123+
1124+[[package]]
1125+name = "find-msvc-tools"
1126+version = "0.1.9"
1127+source = "registry+https://github.com/rust-lang/crates.io-index"
1128+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
1129+
1130+[[package]]
1131+name = "flate2"
1132+version = "1.1.9"
1133+source = "registry+https://github.com/rust-lang/crates.io-index"
1134+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
1135+dependencies = [
1136+ "crc32fast",
1137+ "miniz_oxide",
1138+]
1139+
1140+[[package]]
1141+name = "fnv"
1142+version = "1.0.7"
1143+source = "registry+https://github.com/rust-lang/crates.io-index"
1144+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
1145+
1146+[[package]]
1147+name = "foldhash"
1148+version = "0.1.5"
1149+source = "registry+https://github.com/rust-lang/crates.io-index"
1150+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
1151+
1152+[[package]]
1153+name = "foldhash"
1154+version = "0.2.0"
1155+source = "registry+https://github.com/rust-lang/crates.io-index"
1156+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
1157+
1158+[[package]]
1159+name = "form_urlencoded"
1160+version = "1.2.2"
1161+source = "registry+https://github.com/rust-lang/crates.io-index"
1162+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
1163+dependencies = [
1164+ "percent-encoding",
1165+]
1166+
1167+[[package]]
1168+name = "fs_extra"
1169+version = "1.3.0"
1170+source = "registry+https://github.com/rust-lang/crates.io-index"
1171+checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
1172+
1173+[[package]]
1174+name = "futures"
1175+version = "0.3.32"
1176+source = "registry+https://github.com/rust-lang/crates.io-index"
1177+checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
1178+dependencies = [
1179+ "futures-channel",
1180+ "futures-core",
1181+ "futures-executor",
1182+ "futures-io",
1183+ "futures-sink",
1184+ "futures-task",
1185+ "futures-util",
1186+]
1187+
1188+[[package]]
1189+name = "futures-channel"
1190+version = "0.3.32"
1191+source = "registry+https://github.com/rust-lang/crates.io-index"
1192+checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
1193+dependencies = [
1194+ "futures-core",
1195+ "futures-sink",
1196+]
1197+
1198+[[package]]
1199+name = "futures-core"
1200+version = "0.3.32"
1201+source = "registry+https://github.com/rust-lang/crates.io-index"
1202+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
1203+
1204+[[package]]
1205+name = "futures-executor"
1206+version = "0.3.32"
1207+source = "registry+https://github.com/rust-lang/crates.io-index"
1208+checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
1209+dependencies = [
1210+ "futures-core",
1211+ "futures-task",
1212+ "futures-util",
1213+]
1214+
1215+[[package]]
1216+name = "futures-io"
1217+version = "0.3.32"
1218+source = "registry+https://github.com/rust-lang/crates.io-index"
1219+checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
1220+
1221+[[package]]
1222+name = "futures-macro"
1223+version = "0.3.32"
1224+source = "registry+https://github.com/rust-lang/crates.io-index"
1225+checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
1226+dependencies = [
1227+ "proc-macro2",
1228+ "quote",
1229+ "syn",
1230+]
1231+
1232+[[package]]
1233+name = "futures-sink"
1234+version = "0.3.32"
1235+source = "registry+https://github.com/rust-lang/crates.io-index"
1236+checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
1237+
1238+[[package]]
1239+name = "futures-task"
1240+version = "0.3.32"
1241+source = "registry+https://github.com/rust-lang/crates.io-index"
1242+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
1243+
1244+[[package]]
1245+name = "futures-util"
1246+version = "0.3.32"
1247+source = "registry+https://github.com/rust-lang/crates.io-index"
1248+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
1249+dependencies = [
1250+ "futures-channel",
1251+ "futures-core",
1252+ "futures-io",
1253+ "futures-macro",
1254+ "futures-sink",
1255+ "futures-task",
1256+ "memchr",
1257+ "pin-project-lite",
1258+ "slab",
1259+]
1260+
1261+[[package]]
1262+name = "generic-array"
1263+version = "0.14.7"
1264+source = "registry+https://github.com/rust-lang/crates.io-index"
1265+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
1266+dependencies = [
1267+ "typenum",
1268+ "version_check",
1269+]
1270+
1271+[[package]]
1272+name = "generic-array"
1273+version = "1.4.3"
1274+source = "registry+https://github.com/rust-lang/crates.io-index"
1275+checksum = "c2e55f16dcf0e9c00efbe2e655ffe45fc98e7066b52bc92f8a79e64060a79351"
1276+dependencies = [
1277+ "generic-array 0.14.7",
1278+ "rustversion",
1279+ "typenum",
1280+]
1281+
1282+[[package]]
1283+name = "getrandom"
1284+version = "0.2.17"
1285+source = "registry+https://github.com/rust-lang/crates.io-index"
1286+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
1287+dependencies = [
1288+ "cfg-if",
1289+ "libc",
1290+ "wasi",
1291+]
1292+
1293+[[package]]
1294+name = "getrandom"
1295+version = "0.3.4"
1296+source = "registry+https://github.com/rust-lang/crates.io-index"
1297+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
1298+dependencies = [
1299+ "cfg-if",
1300+ "libc",
1301+ "r-efi 5.3.0",
1302+ "wasip2",
1303+]
1304+
1305+[[package]]
1306+name = "getrandom"
1307+version = "0.4.2"
1308+source = "registry+https://github.com/rust-lang/crates.io-index"
1309+checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
1310+dependencies = [
1311+ "cfg-if",
1312+ "js-sys",
1313+ "libc",
1314+ "r-efi 6.0.0",
1315+ "rand_core 0.10.1",
1316+ "wasip2",
1317+ "wasip3",
1318+ "wasm-bindgen",
1319+]
1320+
1321+[[package]]
1322+name = "ghash"
1323+version = "0.6.0"
1324+source = "registry+https://github.com/rust-lang/crates.io-index"
1325+checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5"
1326+dependencies = [
1327+ "polyval",
1328+]
1329+
1330+[[package]]
1331+name = "gitserver-core"
1332+version = "0.0.3"
1333+dependencies = [
1334+ "bytes",
1335+ "gix",
1336+ "gix-pack",
1337+ "miniz_oxide",
1338+ "serde",
1339+ "sha1 0.10.6",
1340+ "tempfile",
1341+ "thiserror",
1342+ "tokio",
1343+ "tokio-stream",
1344+ "tokio-util",
1345+ "tracing",
1346+]
1347+
1348+[[package]]
1349+name = "gix"
1350+version = "0.84.0"
1351+source = "registry+https://github.com/rust-lang/crates.io-index"
1352+checksum = "ae54ae0ebd1a5a3c3f8d95dd3b5ca6e63f4fed9bfd585e13801a97d7bde8f9ce"
1353+dependencies = [
1354+ "gix-actor",
1355+ "gix-attributes",
1356+ "gix-command",
1357+ "gix-commitgraph",
1358+ "gix-config",
1359+ "gix-date",
1360+ "gix-diff",
1361+ "gix-discover",
1362+ "gix-error",
1363+ "gix-features",
1364+ "gix-filter",
1365+ "gix-fs",
1366+ "gix-glob",
1367+ "gix-hash",
1368+ "gix-hashtable",
1369+ "gix-ignore",
1370+ "gix-index",
1371+ "gix-lock",
1372+ "gix-object",
1373+ "gix-odb",
1374+ "gix-pack",
1375+ "gix-path",
1376+ "gix-pathspec",
1377+ "gix-protocol",
1378+ "gix-ref",
1379+ "gix-refspec",
1380+ "gix-revision",
1381+ "gix-revwalk",
1382+ "gix-sec",
1383+ "gix-shallow",
1384+ "gix-submodule",
1385+ "gix-tempfile",
1386+ "gix-trace",
1387+ "gix-traverse",
1388+ "gix-url",
1389+ "gix-utils",
1390+ "gix-validate",
1391+ "gix-worktree",
1392+ "gix-worktree-stream",
1393+ "nonempty",
1394+ "smallvec",
1395+ "thiserror",
1396+]
1397+
1398+[[package]]
1399+name = "gix-actor"
1400+version = "0.41.1"
1401+source = "registry+https://github.com/rust-lang/crates.io-index"
1402+checksum = "8bc998b8f746dda8565450d08a63b792ced9165d8c27a1ed3f02799ec6a7820f"
1403+dependencies = [
1404+ "bstr",
1405+ "gix-date",
1406+ "gix-error",
1407+]
1408+
1409+[[package]]
1410+name = "gix-attributes"
1411+version = "0.33.1"
1412+source = "registry+https://github.com/rust-lang/crates.io-index"
1413+checksum = "8d43f12e246d3bf7ec624c8fc15ac4a4b62b7c4c6f586cb82be6c90bf84c9d02"
1414+dependencies = [
1415+ "bstr",
1416+ "gix-glob",
1417+ "gix-path",
1418+ "gix-quote",
1419+ "gix-trace",
1420+ "kstring",
1421+ "smallvec",
1422+ "thiserror",
1423+ "unicode-bom",
1424+]
1425+
1426+[[package]]
1427+name = "gix-bitmap"
1428+version = "0.3.2"
1429+source = "registry+https://github.com/rust-lang/crates.io-index"
1430+checksum = "52ebef0c26ad305747649e727bbcd56a7b7910754eb7cea88f6dff6f93c51283"
1431+dependencies = [
1432+ "gix-error",
1433+]
1434+
1435+[[package]]
1436+name = "gix-chunk"
1437+version = "0.7.2"
1438+source = "registry+https://github.com/rust-lang/crates.io-index"
1439+checksum = "9faee47943b638e58ddd5e275a4906ad3e4b6c8584f1d41bd18ab9032ec52afb"
1440+dependencies = [
1441+ "gix-error",
1442+]
1443+
1444+[[package]]
1445+name = "gix-command"
1446+version = "0.9.1"
1447+source = "registry+https://github.com/rust-lang/crates.io-index"
1448+checksum = "00706d4fef135ef4b01680d5218c6ee40cda8baf697b864296cbc887d19118f6"
1449+dependencies = [
1450+ "bstr",
1451+ "gix-path",
1452+ "gix-quote",
1453+ "gix-trace",
1454+ "shell-words",
1455+]
1456+
1457+[[package]]
1458+name = "gix-commitgraph"
1459+version = "0.37.1"
1460+source = "registry+https://github.com/rust-lang/crates.io-index"
1461+checksum = "7f675d0df484a7f6a47e64bd6f311af489d947c0323b0564f36d14f3d7762abb"
1462+dependencies = [
1463+ "bstr",
1464+ "gix-chunk",
1465+ "gix-error",
1466+ "gix-hash",
1467+ "memmap2",
1468+ "nonempty",
1469+]
1470+
1471+[[package]]
1472+name = "gix-config"
1473+version = "0.57.0"
1474+source = "registry+https://github.com/rust-lang/crates.io-index"
1475+checksum = "4f2372d4b49ca28431e7d150cab9d25edc1890f0184bd57eb0e917c7799e63de"
1476+dependencies = [
1477+ "bstr",
1478+ "gix-config-value",
1479+ "gix-features",
1480+ "gix-glob",
1481+ "gix-path",
1482+ "gix-ref",
1483+ "gix-sec",
1484+ "smallvec",
1485+ "thiserror",
1486+ "unicode-bom",
1487+]
1488+
1489+[[package]]
1490+name = "gix-config-value"
1491+version = "0.18.1"
1492+source = "registry+https://github.com/rust-lang/crates.io-index"
1493+checksum = "ed42168329552f6c2e5df09665c104199d45d84bedb53683738a49b57fe1baab"
1494+dependencies = [
1495+ "bitflags",
1496+ "bstr",
1497+ "gix-path",
1498+ "libc",
1499+ "thiserror",
1500+]
1501+
1502+[[package]]
1503+name = "gix-date"
1504+version = "0.15.4"
1505+source = "registry+https://github.com/rust-lang/crates.io-index"
1506+checksum = "a3ecab64a98bbac9f8e02990a9ea5e3c974a7d49b95f2bd70ad94ad22fa6b48c"
1507+dependencies = [
1508+ "bstr",
1509+ "gix-error",
1510+ "itoa",
1511+ "jiff",
1512+]
1513+
1514+[[package]]
1515+name = "gix-diff"
1516+version = "0.64.0"
1517+source = "registry+https://github.com/rust-lang/crates.io-index"
1518+checksum = "3b6d9528f32d94cef2edf39a1ac01fe5a0fc44ddbb18d9e44099936047c3302b"
1519+dependencies = [
1520+ "bstr",
1521+ "gix-command",
1522+ "gix-filter",
1523+ "gix-fs",
1524+ "gix-hash",
1525+ "gix-imara-diff",
1526+ "gix-object",
1527+ "gix-path",
1528+ "gix-tempfile",
1529+ "gix-trace",
1530+ "gix-traverse",
1531+ "gix-worktree",
1532+ "thiserror",
1533+]
1534+
1535+[[package]]
1536+name = "gix-discover"
1537+version = "0.52.0"
1538+source = "registry+https://github.com/rust-lang/crates.io-index"
1539+checksum = "77bacdd12b7879d2178a80c58c2f319995e4654e1a7a23e3181e5c8a12b824f7"
1540+dependencies = [
1541+ "bstr",
1542+ "dunce",
1543+ "gix-fs",
1544+ "gix-path",
1545+ "gix-ref",
1546+ "gix-sec",
1547+ "thiserror",
1548+]
1549+
1550+[[package]]
1551+name = "gix-error"
1552+version = "0.2.4"
1553+source = "registry+https://github.com/rust-lang/crates.io-index"
1554+checksum = "e57831e199be480af90dcd7e459abed8a174c09ec9a6e2cc8f7ca6c54598b06b"
1555+dependencies = [
1556+ "bstr",
1557+]
1558+
1559+[[package]]
1560+name = "gix-features"
1561+version = "0.48.1"
1562+source = "registry+https://github.com/rust-lang/crates.io-index"
1563+checksum = "1849ae154d38bc403185be14fa871e38e3c93ee606875d94e207fdb9fba52dbc"
1564+dependencies = [
1565+ "bytes",
1566+ "crc32fast",
1567+ "crossbeam-channel",
1568+ "gix-path",
1569+ "gix-trace",
1570+ "gix-utils",
1571+ "libc",
1572+ "once_cell",
1573+ "parking_lot",
1574+ "prodash",
1575+ "thiserror",
1576+ "walkdir",
1577+ "zlib-rs",
1578+]
1579+
1580+[[package]]
1581+name = "gix-filter"
1582+version = "0.31.0"
1583+source = "registry+https://github.com/rust-lang/crates.io-index"
1584+checksum = "ecf74b7d16f6694ce4a3049074c41be0c7987105743674f1671807bd6dce09fa"
1585+dependencies = [
1586+ "bstr",
1587+ "encoding_rs",
1588+ "gix-attributes",
1589+ "gix-command",
1590+ "gix-hash",
1591+ "gix-object",
1592+ "gix-packetline",
1593+ "gix-path",
1594+ "gix-quote",
1595+ "gix-trace",
1596+ "gix-utils",
1597+ "smallvec",
1598+ "thiserror",
1599+]
1600+
1601+[[package]]
1602+name = "gix-fs"
1603+version = "0.21.2"
1604+source = "registry+https://github.com/rust-lang/crates.io-index"
1605+checksum = "6cdff46db8798e47e2f727d84b9379aac5add3dd3d9d0b07bb4d7d5d640771fe"
1606+dependencies = [
1607+ "bstr",
1608+ "fastrand",
1609+ "gix-features",
1610+ "gix-path",
1611+ "gix-utils",
1612+ "thiserror",
1613+]
1614+
1615+[[package]]
1616+name = "gix-glob"
1617+version = "0.26.1"
1618+source = "registry+https://github.com/rust-lang/crates.io-index"
1619+checksum = "d1fcb8ef5b16bcf874abe9b68d8abb3c0493c876d367ab824151f30a0f3f3756"
1620+dependencies = [
1621+ "bitflags",
1622+ "bstr",
1623+ "gix-features",
1624+ "gix-path",
1625+]
1626+
1627+[[package]]
1628+name = "gix-hash"
1629+version = "0.25.1"
1630+source = "registry+https://github.com/rust-lang/crates.io-index"
1631+checksum = "cb0926d3819c837750b4e03c7754901e73f68b8c9b690753a6372a1bed4eedce"
1632+dependencies = [
1633+ "faster-hex",
1634+ "gix-features",
1635+ "sha1-checked",
1636+ "thiserror",
1637+]
1638+
1639+[[package]]
1640+name = "gix-hashtable"
1641+version = "0.15.1"
1642+source = "registry+https://github.com/rust-lang/crates.io-index"
1643+checksum = "b0e30b93eea8718baf7d8153fcb938e2926175bbf18097c09f1c01b6f0be0563"
1644+dependencies = [
1645+ "gix-hash",
1646+ "hashbrown 0.17.1",
1647+ "parking_lot",
1648+]
1649+
1650+[[package]]
1651+name = "gix-ignore"
1652+version = "0.21.1"
1653+source = "registry+https://github.com/rust-lang/crates.io-index"
1654+checksum = "d491bab9bf2c9f341dc754f425c31d5d3f63aca615312167b82e1deeaca97d8d"
1655+dependencies = [
1656+ "bstr",
1657+ "gix-glob",
1658+ "gix-path",
1659+ "gix-trace",
1660+ "unicode-bom",
1661+]
1662+
1663+[[package]]
1664+name = "gix-imara-diff"
1665+version = "0.2.2"
1666+source = "registry+https://github.com/rust-lang/crates.io-index"
1667+checksum = "19753d40da53d0ec41604750eeb969097a90fb2d7f7992730d904541c04e2c19"
1668+dependencies = [
1669+ "bstr",
1670+ "hashbrown 0.17.1",
1671+]
1672+
1673+[[package]]
1674+name = "gix-index"
1675+version = "0.52.0"
1676+source = "registry+https://github.com/rust-lang/crates.io-index"
1677+checksum = "4e6b28cc592dc753adb58302bb14a64e412ee591a3bec77aa4df87bff74fa80d"
1678+dependencies = [
1679+ "bitflags",
1680+ "bstr",
1681+ "filetime",
1682+ "fnv",
1683+ "gix-bitmap",
1684+ "gix-features",
1685+ "gix-fs",
1686+ "gix-hash",
1687+ "gix-lock",
1688+ "gix-object",
1689+ "gix-traverse",
1690+ "gix-utils",
1691+ "gix-validate",
1692+ "hashbrown 0.17.1",
1693+ "itoa",
1694+ "libc",
1695+ "memmap2",
1696+ "rustix",
1697+ "smallvec",
1698+ "thiserror",
1699+]
1700+
1701+[[package]]
1702+name = "gix-lock"
1703+version = "23.0.1"
1704+source = "registry+https://github.com/rust-lang/crates.io-index"
1705+checksum = "65c9dedd9e90b0d47624d2ed241d394e09294118364e87b9b7e5f1fe755f3c2c"
1706+dependencies = [
1707+ "gix-tempfile",
1708+ "gix-utils",
1709+ "thiserror",
1710+]
1711+
1712+[[package]]
1713+name = "gix-object"
1714+version = "0.61.0"
1715+source = "registry+https://github.com/rust-lang/crates.io-index"
1716+checksum = "d5cd857e29429c7213bdef3f5aef83f8cc124774fe8ae0d27b1607d218d6d525"
1717+dependencies = [
1718+ "bstr",
1719+ "gix-actor",
1720+ "gix-date",
1721+ "gix-features",
1722+ "gix-hash",
1723+ "gix-hashtable",
1724+ "gix-utils",
1725+ "gix-validate",
1726+ "itoa",
1727+ "smallvec",
1728+ "thiserror",
1729+]
1730+
1731+[[package]]
1732+name = "gix-odb"
1733+version = "0.81.0"
1734+source = "registry+https://github.com/rust-lang/crates.io-index"
1735+checksum = "7d004c32858b1556f2d7874405edb3c97dc78fc09beaa87d57bb077ee2858a7d"
1736+dependencies = [
1737+ "arc-swap",
1738+ "gix-features",
1739+ "gix-fs",
1740+ "gix-hash",
1741+ "gix-hashtable",
1742+ "gix-object",
1743+ "gix-pack",
1744+ "gix-path",
1745+ "gix-quote",
1746+ "memmap2",
1747+ "parking_lot",
1748+ "tempfile",
1749+ "thiserror",
1750+]
1751+
1752+[[package]]
1753+name = "gix-pack"
1754+version = "0.71.0"
1755+source = "registry+https://github.com/rust-lang/crates.io-index"
1756+checksum = "e43626f2a27d1033674ec1a196b845614231e6bbd949d5e21c133045ff56b174"
1757+dependencies = [
1758+ "clru",
1759+ "gix-chunk",
1760+ "gix-diff",
1761+ "gix-error",
1762+ "gix-features",
1763+ "gix-hash",
1764+ "gix-hashtable",
1765+ "gix-object",
1766+ "gix-path",
1767+ "gix-tempfile",
1768+ "gix-traverse",
1769+ "memmap2",
1770+ "parking_lot",
1771+ "smallvec",
1772+ "thiserror",
1773+ "uluru",
1774+]
1775+
1776+[[package]]
1777+name = "gix-packetline"
1778+version = "0.21.4"
1779+source = "registry+https://github.com/rust-lang/crates.io-index"
1780+checksum = "bb18337ba2830bb43367d1af43819c8c78f31337f079fc76d0f1f1750a173126"
1781+dependencies = [
1782+ "bstr",
1783+ "faster-hex",
1784+ "gix-trace",
1785+ "thiserror",
1786+]
1787+
1788+[[package]]
1789+name = "gix-path"
1790+version = "0.12.1"
1791+source = "registry+https://github.com/rust-lang/crates.io-index"
1792+checksum = "afa6ac14cd14939ea94a496ce7460daa6511c09f5b84757e9cfc6f9c8d0f93a6"
1793+dependencies = [
1794+ "bstr",
1795+ "gix-trace",
1796+ "gix-validate",
1797+ "thiserror",
1798+]
1799+
1800+[[package]]
1801+name = "gix-pathspec"
1802+version = "0.18.1"
1803+source = "registry+https://github.com/rust-lang/crates.io-index"
1804+checksum = "3050783b41ee11511e1e8fb35623df81806194f4030395f14f48ea37c2798c9f"
1805+dependencies = [
1806+ "bitflags",
1807+ "bstr",
1808+ "gix-attributes",
1809+ "gix-config-value",
1810+ "gix-glob",
1811+ "gix-path",
1812+ "thiserror",
1813+]
1814+
1815+[[package]]
1816+name = "gix-protocol"
1817+version = "0.62.0"
1818+source = "registry+https://github.com/rust-lang/crates.io-index"
1819+checksum = "51dea3acb390707ab868f1f9584f18449eb95d869deffae96768e47d303595ee"
1820+dependencies = [
1821+ "bstr",
1822+ "gix-date",
1823+ "gix-features",
1824+ "gix-hash",
1825+ "gix-ref",
1826+ "gix-shallow",
1827+ "gix-transport",
1828+ "gix-utils",
1829+ "maybe-async",
1830+ "nonempty",
1831+ "thiserror",
1832+]
1833+
1834+[[package]]
1835+name = "gix-quote"
1836+version = "0.7.2"
1837+source = "registry+https://github.com/rust-lang/crates.io-index"
1838+checksum = "a6e541fc33cc2b783b7979040d445a0c86a2eca747c8faea4ca84230d06ae6ef"
1839+dependencies = [
1840+ "bstr",
1841+ "gix-error",
1842+ "gix-utils",
1843+]
1844+
1845+[[package]]
1846+name = "gix-ref"
1847+version = "0.64.0"
1848+source = "registry+https://github.com/rust-lang/crates.io-index"
1849+checksum = "4c04f64c37eb7e6feb73c7060f8dc6f381cc5de5d53249bfd450bc48a86b2e8b"
1850+dependencies = [
1851+ "gix-actor",
1852+ "gix-features",
1853+ "gix-fs",
1854+ "gix-hash",
1855+ "gix-lock",
1856+ "gix-object",
1857+ "gix-path",
1858+ "gix-tempfile",
1859+ "gix-utils",
1860+ "gix-validate",
1861+ "memmap2",
1862+ "thiserror",
1863+]
1864+
1865+[[package]]
1866+name = "gix-refspec"
1867+version = "0.42.0"
1868+source = "registry+https://github.com/rust-lang/crates.io-index"
1869+checksum = "b216ae06ec74b5f24ad0142026a997fb0a935b7410eaf9c1616fc3f0e6c5a6d3"
1870+dependencies = [
1871+ "bstr",
1872+ "gix-error",
1873+ "gix-glob",
1874+ "gix-hash",
1875+ "gix-revision",
1876+ "gix-validate",
1877+ "smallvec",
1878+ "thiserror",
1879+]
1880+
1881+[[package]]
1882+name = "gix-revision"
1883+version = "0.46.0"
1884+source = "registry+https://github.com/rust-lang/crates.io-index"
1885+checksum = "0b47c88884dd3c1a19a39da19d10211fcdea2809aadc86869b6e824a1774340f"
1886+dependencies = [
1887+ "bitflags",
1888+ "bstr",
1889+ "gix-commitgraph",
1890+ "gix-date",
1891+ "gix-error",
1892+ "gix-hash",
1893+ "gix-hashtable",
1894+ "gix-object",
1895+ "gix-revwalk",
1896+ "gix-trace",
1897+ "nonempty",
1898+]
1899+
1900+[[package]]
1901+name = "gix-revwalk"
1902+version = "0.32.0"
1903+source = "registry+https://github.com/rust-lang/crates.io-index"
1904+checksum = "85f5756abffe0917827aac683b13684ed99875bc398fa1f9b8f479b0681ef9e6"
1905+dependencies = [
1906+ "gix-commitgraph",
1907+ "gix-date",
1908+ "gix-error",
1909+ "gix-hash",
1910+ "gix-hashtable",
1911+ "gix-object",
1912+ "smallvec",
1913+ "thiserror",
1914+]
1915+
1916+[[package]]
1917+name = "gix-sec"
1918+version = "0.14.1"
1919+source = "registry+https://github.com/rust-lang/crates.io-index"
1920+checksum = "ab8519976e4c7e486270740a5400369f37940779b80bd1377d94cfa1125d01b3"
1921+dependencies = [
1922+ "bitflags",
1923+ "gix-path",
1924+ "libc",
1925+ "windows-sys",
1926+]
1927+
1928+[[package]]
1929+name = "gix-shallow"
1930+version = "0.12.1"
1931+source = "registry+https://github.com/rust-lang/crates.io-index"
1932+checksum = "a292fc2fe548c5dfa575479d16b445b0ddf1dd2f56f1fec6aed386f82553cd97"
1933+dependencies = [
1934+ "bstr",
1935+ "gix-hash",
1936+ "gix-lock",
1937+ "nonempty",
1938+ "thiserror",
1939+]
1940+
1941+[[package]]
1942+name = "gix-submodule"
1943+version = "0.31.0"
1944+source = "registry+https://github.com/rust-lang/crates.io-index"
1945+checksum = "3059890ef054066c22a94bfc6a3eaba0d806aedcd630a0bc9e5783fd88884781"
1946+dependencies = [
1947+ "bstr",
1948+ "gix-config",
1949+ "gix-path",
1950+ "gix-pathspec",
1951+ "gix-refspec",
1952+ "gix-url",
1953+ "thiserror",
1954+]
1955+
1956+[[package]]
1957+name = "gix-tempfile"
1958+version = "23.0.1"
1959+source = "registry+https://github.com/rust-lang/crates.io-index"
1960+checksum = "27850097e1ff9515f46a0dad0f5f9c9d020e972727772dabab9450690c4adb22"
1961+dependencies = [
1962+ "dashmap",
1963+ "gix-fs",
1964+ "libc",
1965+ "parking_lot",
1966+ "tempfile",
1967+]
1968+
1969+[[package]]
1970+name = "gix-trace"
1971+version = "0.1.20"
1972+source = "registry+https://github.com/rust-lang/crates.io-index"
1973+checksum = "44dc45eae785c0eb14173e0f152e6e224dcf4d45b6a6999a3aed22af541ad678"
1974+
1975+[[package]]
1976+name = "gix-transport"
1977+version = "0.57.1"
1978+source = "registry+https://github.com/rust-lang/crates.io-index"
1979+checksum = "7cd0e34995b1aab0fa8dff2af8db726a0bfad3e119c89302604463264046e7ff"
1980+dependencies = [
1981+ "bstr",
1982+ "gix-command",
1983+ "gix-features",
1984+ "gix-packetline",
1985+ "gix-quote",
1986+ "gix-sec",
1987+ "gix-url",
1988+ "thiserror",
1989+]
1990+
1991+[[package]]
1992+name = "gix-traverse"
1993+version = "0.58.0"
1994+source = "registry+https://github.com/rust-lang/crates.io-index"
1995+checksum = "e8de590ecc86a3b2870665f2288324fa9f7f8672c7fc2d4e020fdd81cd1f7aed"
1996+dependencies = [
1997+ "bitflags",
1998+ "gix-commitgraph",
1999+ "gix-date",
2000+ "gix-hash",
2001+ "gix-hashtable",
2002+ "gix-object",
2003+ "gix-revwalk",
2004+ "smallvec",
2005+ "thiserror",
2006+]
2007+
2008+[[package]]
2009+name = "gix-url"
2010+version = "0.36.1"
2011+source = "registry+https://github.com/rust-lang/crates.io-index"
2012+checksum = "65bb01ec69d55e82ccb7a19e264501ead4e6aac38463a8cebfdd81e22bb67ab2"
2013+dependencies = [
2014+ "bstr",
2015+ "gix-path",
2016+ "percent-encoding",
2017+ "thiserror",
2018+]
2019+
2020+[[package]]
2021+name = "gix-utils"
2022+version = "0.3.3"
2023+source = "registry+https://github.com/rust-lang/crates.io-index"
2024+checksum = "66c50966184123caf580ffa64e28031a878597f1c7fceb8fe19566c38eb1b771"
2025+dependencies = [
2026+ "fastrand",
2027+ "unicode-normalization",
2028+]
2029+
2030+[[package]]
2031+name = "gix-validate"
2032+version = "0.11.2"
2033+source = "registry+https://github.com/rust-lang/crates.io-index"
2034+checksum = "7bc6fc771c4063ba7cd2f47b91fb6076251c6a823b64b7fe7b8874b0fe4afae3"
2035+dependencies = [
2036+ "bstr",
2037+]
2038+
2039+[[package]]
2040+name = "gix-worktree"
2041+version = "0.53.0"
2042+source = "registry+https://github.com/rust-lang/crates.io-index"
2043+checksum = "cef414ed275e8407cd5d53d301e83be19700b0dd3f859d2434417b58f454a2d1"
2044+dependencies = [
2045+ "bstr",
2046+ "gix-attributes",
2047+ "gix-fs",
2048+ "gix-glob",
2049+ "gix-hash",
2050+ "gix-ignore",
2051+ "gix-index",
2052+ "gix-object",
2053+ "gix-path",
2054+ "gix-validate",
2055+]
2056+
2057+[[package]]
2058+name = "gix-worktree-stream"
2059+version = "0.33.0"
2060+source = "registry+https://github.com/rust-lang/crates.io-index"
2061+checksum = "d25e9ed30100c63f7590bc581c225e53f731a53e06aa79a245739c07f7dcc557"
2062+dependencies = [
2063+ "gix-attributes",
2064+ "gix-error",
2065+ "gix-features",
2066+ "gix-filter",
2067+ "gix-fs",
2068+ "gix-hash",
2069+ "gix-object",
2070+ "gix-path",
2071+ "gix-traverse",
2072+ "parking_lot",
2073+]
2074+
2075+[[package]]
2076+name = "group"
2077+version = "0.14.0"
2078+source = "registry+https://github.com/rust-lang/crates.io-index"
2079+checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4"
2080+dependencies = [
2081+ "ff",
2082+ "rand_core 0.10.1",
2083+ "subtle",
2084+]
2085+
2086+[[package]]
2087+name = "hash32"
2088+version = "0.3.1"
2089+source = "registry+https://github.com/rust-lang/crates.io-index"
2090+checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606"
2091+dependencies = [
2092+ "byteorder",
2093+]
2094+
2095+[[package]]
2096+name = "hashbrown"
2097+version = "0.14.5"
2098+source = "registry+https://github.com/rust-lang/crates.io-index"
2099+checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
2100+
2101+[[package]]
2102+name = "hashbrown"
2103+version = "0.15.5"
2104+source = "registry+https://github.com/rust-lang/crates.io-index"
2105+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
2106+dependencies = [
2107+ "foldhash 0.1.5",
2108+]
2109+
2110+[[package]]
2111+name = "hashbrown"
2112+version = "0.16.1"
2113+source = "registry+https://github.com/rust-lang/crates.io-index"
2114+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
2115+dependencies = [
2116+ "allocator-api2",
2117+ "equivalent",
2118+ "foldhash 0.2.0",
2119+]
2120+
2121+[[package]]
2122+name = "hashbrown"
2123+version = "0.17.1"
2124+source = "registry+https://github.com/rust-lang/crates.io-index"
2125+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
2126+dependencies = [
2127+ "allocator-api2",
2128+ "equivalent",
2129+ "foldhash 0.2.0",
2130+]
2131+
2132+[[package]]
2133+name = "hashlink"
2134+version = "0.11.1"
2135+source = "registry+https://github.com/rust-lang/crates.io-index"
2136+checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f"
2137+dependencies = [
2138+ "hashbrown 0.16.1",
2139+]
2140+
2141+[[package]]
2142+name = "heapless"
2143+version = "0.8.0"
2144+source = "registry+https://github.com/rust-lang/crates.io-index"
2145+checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad"
2146+dependencies = [
2147+ "hash32",
2148+ "stable_deref_trait",
2149+]
2150+
2151+[[package]]
2152+name = "heck"
2153+version = "0.5.0"
2154+source = "registry+https://github.com/rust-lang/crates.io-index"
2155+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
2156+
2157+[[package]]
2158+name = "hermit-abi"
2159+version = "0.5.2"
2160+source = "registry+https://github.com/rust-lang/crates.io-index"
2161+checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
2162+
2163+[[package]]
2164+name = "hex"
2165+version = "0.4.3"
2166+source = "registry+https://github.com/rust-lang/crates.io-index"
2167+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
2168+
2169+[[package]]
2170+name = "hex-literal"
2171+version = "1.1.0"
2172+source = "registry+https://github.com/rust-lang/crates.io-index"
2173+checksum = "e712f64ec3850b98572bffac52e2c6f282b29fe6c5fa6d42334b30be438d95c1"
2174+
2175+[[package]]
2176+name = "hkdf"
2177+version = "0.13.0"
2178+source = "registry+https://github.com/rust-lang/crates.io-index"
2179+checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018"
2180+dependencies = [
2181+ "hmac",
2182+]
2183+
2184+[[package]]
2185+name = "hmac"
2186+version = "0.13.0"
2187+source = "registry+https://github.com/rust-lang/crates.io-index"
2188+checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
2189+dependencies = [
2190+ "digest 0.11.3",
2191+]
2192+
2193+[[package]]
2194+name = "http"
2195+version = "1.4.2"
2196+source = "registry+https://github.com/rust-lang/crates.io-index"
2197+checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
2198+dependencies = [
2199+ "bytes",
2200+ "itoa",
2201+]
2202+
2203+[[package]]
2204+name = "http-body"
2205+version = "1.0.1"
2206+source = "registry+https://github.com/rust-lang/crates.io-index"
2207+checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
2208+dependencies = [
2209+ "bytes",
2210+ "http",
2211+]
2212+
2213+[[package]]
2214+name = "http-body-util"
2215+version = "0.1.3"
2216+source = "registry+https://github.com/rust-lang/crates.io-index"
2217+checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
2218+dependencies = [
2219+ "bytes",
2220+ "futures-core",
2221+ "http",
2222+ "http-body",
2223+ "pin-project-lite",
2224+]
2225+
2226+[[package]]
2227+name = "http-range-header"
2228+version = "0.4.2"
2229+source = "registry+https://github.com/rust-lang/crates.io-index"
2230+checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
2231+
2232+[[package]]
2233+name = "httparse"
2234+version = "1.10.1"
2235+source = "registry+https://github.com/rust-lang/crates.io-index"
2236+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
2237+
2238+[[package]]
2239+name = "httpdate"
2240+version = "1.0.3"
2241+source = "registry+https://github.com/rust-lang/crates.io-index"
2242+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
2243+
2244+[[package]]
2245+name = "hybrid-array"
2246+version = "0.4.12"
2247+source = "registry+https://github.com/rust-lang/crates.io-index"
2248+checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da"
2249+dependencies = [
2250+ "ctutils",
2251+ "subtle",
2252+ "typenum",
2253+ "zeroize",
2254+]
2255+
2256+[[package]]
2257+name = "hyper"
2258+version = "1.10.1"
2259+source = "registry+https://github.com/rust-lang/crates.io-index"
2260+checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
2261+dependencies = [
2262+ "atomic-waker",
2263+ "bytes",
2264+ "futures-channel",
2265+ "futures-core",
2266+ "http",
2267+ "http-body",
2268+ "httparse",
2269+ "httpdate",
2270+ "itoa",
2271+ "pin-project-lite",
2272+ "smallvec",
2273+ "tokio",
2274+]
2275+
2276+[[package]]
2277+name = "hyper-util"
2278+version = "0.1.20"
2279+source = "registry+https://github.com/rust-lang/crates.io-index"
2280+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
2281+dependencies = [
2282+ "bytes",
2283+ "http",
2284+ "http-body",
2285+ "hyper",
2286+ "pin-project-lite",
2287+ "tokio",
2288+ "tower-service",
2289+]
2290+
2291+[[package]]
2292+name = "iana-time-zone"
2293+version = "0.1.65"
2294+source = "registry+https://github.com/rust-lang/crates.io-index"
2295+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
2296+dependencies = [
2297+ "android_system_properties",
2298+ "core-foundation-sys",
2299+ "iana-time-zone-haiku",
2300+ "js-sys",
2301+ "log",
2302+ "wasm-bindgen",
2303+ "windows-core",
2304+]
2305+
2306+[[package]]
2307+name = "iana-time-zone-haiku"
2308+version = "0.1.2"
2309+source = "registry+https://github.com/rust-lang/crates.io-index"
2310+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
2311+dependencies = [
2312+ "cc",
2313+]
2314+
2315+[[package]]
2316+name = "icu_collections"
2317+version = "2.2.0"
2318+source = "registry+https://github.com/rust-lang/crates.io-index"
2319+checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
2320+dependencies = [
2321+ "displaydoc",
2322+ "potential_utf",
2323+ "utf8_iter",
2324+ "yoke",
2325+ "zerofrom",
2326+ "zerovec",
2327+]
2328+
2329+[[package]]
2330+name = "icu_locale_core"
2331+version = "2.2.0"
2332+source = "registry+https://github.com/rust-lang/crates.io-index"
2333+checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
2334+dependencies = [
2335+ "displaydoc",
2336+ "litemap",
2337+ "tinystr",
2338+ "writeable",
2339+ "zerovec",
2340+]
2341+
2342+[[package]]
2343+name = "icu_normalizer"
2344+version = "2.2.0"
2345+source = "registry+https://github.com/rust-lang/crates.io-index"
2346+checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
2347+dependencies = [
2348+ "icu_collections",
2349+ "icu_normalizer_data",
2350+ "icu_properties",
2351+ "icu_provider",
2352+ "smallvec",
2353+ "zerovec",
2354+]
2355+
2356+[[package]]
2357+name = "icu_normalizer_data"
2358+version = "2.2.0"
2359+source = "registry+https://github.com/rust-lang/crates.io-index"
2360+checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
2361+
2362+[[package]]
2363+name = "icu_properties"
2364+version = "2.2.0"
2365+source = "registry+https://github.com/rust-lang/crates.io-index"
2366+checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
2367+dependencies = [
2368+ "icu_collections",
2369+ "icu_locale_core",
2370+ "icu_properties_data",
2371+ "icu_provider",
2372+ "zerotrie",
2373+ "zerovec",
2374+]
2375+
2376+[[package]]
2377+name = "icu_properties_data"
2378+version = "2.2.0"
2379+source = "registry+https://github.com/rust-lang/crates.io-index"
2380+checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
2381+
2382+[[package]]
2383+name = "icu_provider"
2384+version = "2.2.0"
2385+source = "registry+https://github.com/rust-lang/crates.io-index"
2386+checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
2387+dependencies = [
2388+ "displaydoc",
2389+ "icu_locale_core",
2390+ "writeable",
2391+ "yoke",
2392+ "zerofrom",
2393+ "zerotrie",
2394+ "zerovec",
2395+]
2396+
2397+[[package]]
2398+name = "id-arena"
2399+version = "2.3.0"
2400+source = "registry+https://github.com/rust-lang/crates.io-index"
2401+checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
2402+
2403+[[package]]
2404+name = "idna"
2405+version = "1.1.0"
2406+source = "registry+https://github.com/rust-lang/crates.io-index"
2407+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
2408+dependencies = [
2409+ "idna_adapter",
2410+ "smallvec",
2411+ "utf8_iter",
2412+]
2413+
2414+[[package]]
2415+name = "idna_adapter"
2416+version = "1.2.2"
2417+source = "registry+https://github.com/rust-lang/crates.io-index"
2418+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
2419+dependencies = [
2420+ "icu_normalizer",
2421+ "icu_properties",
2422+]
2423+
2424+[[package]]
2425+name = "index_vec"
2426+version = "0.1.4"
2427+source = "registry+https://github.com/rust-lang/crates.io-index"
2428+checksum = "44faf5bb8861a9c72e20d3fb0fdbd59233e43056e2b80475ab0aacdc2e781355"
2429+
2430+[[package]]
2431+name = "indexmap"
2432+version = "2.14.0"
2433+source = "registry+https://github.com/rust-lang/crates.io-index"
2434+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
2435+dependencies = [
2436+ "equivalent",
2437+ "hashbrown 0.17.1",
2438+ "serde",
2439+ "serde_core",
2440+]
2441+
2442+[[package]]
2443+name = "inout"
2444+version = "0.2.2"
2445+source = "registry+https://github.com/rust-lang/crates.io-index"
2446+checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
2447+dependencies = [
2448+ "block-padding",
2449+ "hybrid-array",
2450+]
2451+
2452+[[package]]
2453+name = "internal-russh-num-bigint"
2454+version = "0.5.0"
2455+source = "registry+https://github.com/rust-lang/crates.io-index"
2456+checksum = "ae8e22120c32fb4d19ec55fba35015f57095cd95a2e3b732e44457f5915b2ee8"
2457+dependencies = [
2458+ "num-integer",
2459+ "num-traits",
2460+ "rand",
2461+ "rand_core 0.10.1",
2462+]
2463+
2464+[[package]]
2465+name = "inventory"
2466+version = "0.3.24"
2467+source = "registry+https://github.com/rust-lang/crates.io-index"
2468+checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b"
2469+dependencies = [
2470+ "rustversion",
2471+]
2472+
2473+[[package]]
2474+name = "is_terminal_polyfill"
2475+version = "1.70.2"
2476+source = "registry+https://github.com/rust-lang/crates.io-index"
2477+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
2478+
2479+[[package]]
2480+name = "itoa"
2481+version = "1.0.18"
2482+source = "registry+https://github.com/rust-lang/crates.io-index"
2483+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
2484+
2485+[[package]]
2486+name = "jiff"
2487+version = "0.2.28"
2488+source = "registry+https://github.com/rust-lang/crates.io-index"
2489+checksum = "4603d3033e49e2b0e31229fcab20a5d40089c607d975cd9c80551dc69eed9102"
2490+dependencies = [
2491+ "jiff-static",
2492+ "jiff-tzdb-platform",
2493+ "log",
2494+ "portable-atomic",
2495+ "portable-atomic-util",
2496+ "serde_core",
2497+ "windows-link",
2498+]
2499+
2500+[[package]]
2501+name = "jiff-static"
2502+version = "0.2.28"
2503+source = "registry+https://github.com/rust-lang/crates.io-index"
2504+checksum = "782d32378dddf207193ac91cefb848ad41abb58195c95168e1291227a0832b47"
2505+dependencies = [
2506+ "proc-macro2",
2507+ "quote",
2508+ "syn",
2509+]
2510+
2511+[[package]]
2512+name = "jiff-tzdb"
2513+version = "0.1.6"
2514+source = "registry+https://github.com/rust-lang/crates.io-index"
2515+checksum = "c900ef84826f1338a557697dc8fc601df9ca9af4ac137c7fb61d4c6f2dfd3076"
2516+
2517+[[package]]
2518+name = "jiff-tzdb-platform"
2519+version = "0.1.3"
2520+source = "registry+https://github.com/rust-lang/crates.io-index"
2521+checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8"
2522+dependencies = [
2523+ "jiff-tzdb",
2524+]
2525+
2526+[[package]]
2527+name = "jobserver"
2528+version = "0.1.34"
2529+source = "registry+https://github.com/rust-lang/crates.io-index"
2530+checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
2531+dependencies = [
2532+ "getrandom 0.3.4",
2533+ "libc",
2534+]
2535+
2536+[[package]]
2537+name = "js-sys"
2538+version = "0.3.100"
2539+source = "registry+https://github.com/rust-lang/crates.io-index"
2540+checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162"
2541+dependencies = [
2542+ "cfg-if",
2543+ "futures-util",
2544+ "wasm-bindgen",
2545+]
2546+
2547+[[package]]
2548+name = "keccak"
2549+version = "0.2.0"
2550+source = "registry+https://github.com/rust-lang/crates.io-index"
2551+checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa"
2552+dependencies = [
2553+ "cfg-if",
2554+ "cpufeatures 0.3.0",
2555+]
2556+
2557+[[package]]
2558+name = "kem"
2559+version = "0.3.0"
2560+source = "registry+https://github.com/rust-lang/crates.io-index"
2561+checksum = "01737161ba802849cfd486b5bd209d38ba4943494c249a8126005170c7621edd"
2562+dependencies = [
2563+ "crypto-common 0.2.2",
2564+ "rand_core 0.10.1",
2565+]
2566+
2567+[[package]]
2568+name = "kstring"
2569+version = "2.0.2"
2570+source = "registry+https://github.com/rust-lang/crates.io-index"
2571+checksum = "558bf9508a558512042d3095138b1f7b8fe90c5467d94f9f1da28b3731c5dbd1"
2572+dependencies = [
2573+ "static_assertions",
2574+]
2575+
2576+[[package]]
2577+name = "lazy_static"
2578+version = "1.5.0"
2579+source = "registry+https://github.com/rust-lang/crates.io-index"
2580+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
2581+
2582+[[package]]
2583+name = "leb128fmt"
2584+version = "0.1.0"
2585+source = "registry+https://github.com/rust-lang/crates.io-index"
2586+checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
2587+
2588+[[package]]
2589+name = "libc"
2590+version = "0.2.186"
2591+source = "registry+https://github.com/rust-lang/crates.io-index"
2592+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
2593+
2594+[[package]]
2595+name = "libm"
2596+version = "0.2.16"
2597+source = "registry+https://github.com/rust-lang/crates.io-index"
2598+checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
2599+
2600+[[package]]
2601+name = "libsqlite3-sys"
2602+version = "0.37.0"
2603+source = "registry+https://github.com/rust-lang/crates.io-index"
2604+checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1"
2605+dependencies = [
2606+ "cc",
2607+ "pkg-config",
2608+ "vcpkg",
2609+]
2610+
2611+[[package]]
2612+name = "linked-hash-map"
2613+version = "0.5.6"
2614+source = "registry+https://github.com/rust-lang/crates.io-index"
2615+checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
2616+
2617+[[package]]
2618+name = "linux-raw-sys"
2619+version = "0.12.1"
2620+source = "registry+https://github.com/rust-lang/crates.io-index"
2621+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
2622+
2623+[[package]]
2624+name = "litemap"
2625+version = "0.8.2"
2626+source = "registry+https://github.com/rust-lang/crates.io-index"
2627+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
2628+
2629+[[package]]
2630+name = "lock_api"
2631+version = "0.4.14"
2632+source = "registry+https://github.com/rust-lang/crates.io-index"
2633+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
2634+dependencies = [
2635+ "scopeguard",
2636+]
2637+
2638+[[package]]
2639+name = "log"
2640+version = "0.4.32"
2641+source = "registry+https://github.com/rust-lang/crates.io-index"
2642+checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
2643+
2644+[[package]]
2645+name = "matchers"
2646+version = "0.2.0"
2647+source = "registry+https://github.com/rust-lang/crates.io-index"
2648+checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
2649+dependencies = [
2650+ "regex-automata",
2651+]
2652+
2653+[[package]]
2654+name = "matchit"
2655+version = "0.8.4"
2656+source = "registry+https://github.com/rust-lang/crates.io-index"
2657+checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
2658+
2659+[[package]]
2660+name = "maud"
2661+version = "0.27.0"
2662+source = "registry+https://github.com/rust-lang/crates.io-index"
2663+checksum = "8156733e27020ea5c684db5beac5d1d611e1272ab17901a49466294b84fc217e"
2664+dependencies = [
2665+ "axum-core",
2666+ "http",
2667+ "itoa",
2668+ "maud_macros",
2669+]
2670+
2671+[[package]]
2672+name = "maud_macros"
2673+version = "0.27.0"
2674+source = "registry+https://github.com/rust-lang/crates.io-index"
2675+checksum = "7261b00f3952f617899bc012e3dbd56e4f0110a038175929fa5d18e5a19913ca"
2676+dependencies = [
2677+ "proc-macro2",
2678+ "proc-macro2-diagnostics",
2679+ "quote",
2680+ "syn",
2681+]
2682+
2683+[[package]]
2684+name = "maybe-async"
2685+version = "0.2.11"
2686+source = "registry+https://github.com/rust-lang/crates.io-index"
2687+checksum = "746873a384ad60adc5db74471dfaba74bd278afbdcfd81db93fafcdfc8b5ca0c"
2688+dependencies = [
2689+ "proc-macro2",
2690+ "quote",
2691+ "syn",
2692+]
2693+
2694+[[package]]
2695+name = "md5"
2696+version = "0.8.0"
2697+source = "registry+https://github.com/rust-lang/crates.io-index"
2698+checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0"
2699+
2700+[[package]]
2701+name = "memchr"
2702+version = "2.8.1"
2703+source = "registry+https://github.com/rust-lang/crates.io-index"
2704+checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
2705+
2706+[[package]]
2707+name = "memmap2"
2708+version = "0.9.10"
2709+source = "registry+https://github.com/rust-lang/crates.io-index"
2710+checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3"
2711+dependencies = [
2712+ "libc",
2713+]
2714+
2715+[[package]]
2716+name = "mime"
2717+version = "0.3.17"
2718+source = "registry+https://github.com/rust-lang/crates.io-index"
2719+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
2720+
2721+[[package]]
2722+name = "mime_guess"
2723+version = "2.0.5"
2724+source = "registry+https://github.com/rust-lang/crates.io-index"
2725+checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
2726+dependencies = [
2727+ "mime",
2728+ "unicase",
2729+]
2730+
2731+[[package]]
2732+name = "miniz_oxide"
2733+version = "0.8.9"
2734+source = "registry+https://github.com/rust-lang/crates.io-index"
2735+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
2736+dependencies = [
2737+ "adler2",
2738+ "simd-adler32",
2739+]
2740+
2741+[[package]]
2742+name = "mio"
2743+version = "1.2.1"
2744+source = "registry+https://github.com/rust-lang/crates.io-index"
2745+checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
2746+dependencies = [
2747+ "libc",
2748+ "wasi",
2749+ "windows-sys",
2750+]
2751+
2752+[[package]]
2753+name = "ml-kem"
2754+version = "0.3.2"
2755+source = "registry+https://github.com/rust-lang/crates.io-index"
2756+checksum = "5e15f3e5b957493873e396a66914e83e616b6afe335cdef7efe5c6e1216aba66"
2757+dependencies = [
2758+ "hybrid-array",
2759+ "kem",
2760+ "module-lattice",
2761+ "pkcs8",
2762+ "rand_core 0.10.1",
2763+ "sha3",
2764+]
2765+
2766+[[package]]
2767+name = "module-lattice"
2768+version = "0.2.3"
2769+source = "registry+https://github.com/rust-lang/crates.io-index"
2770+checksum = "0c61b87c9683ab7cb1c6871d261ad5479b6b10ceb52c4352aaca3b5d35a8febe"
2771+dependencies = [
2772+ "ctutils",
2773+ "hybrid-array",
2774+ "num-traits",
2775+]
2776+
2777+[[package]]
2778+name = "nix"
2779+version = "0.31.3"
2780+source = "registry+https://github.com/rust-lang/crates.io-index"
2781+checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
2782+dependencies = [
2783+ "bitflags",
2784+ "cfg-if",
2785+ "cfg_aliases",
2786+ "libc",
2787+]
2788+
2789+[[package]]
2790+name = "nonempty"
2791+version = "0.12.0"
2792+source = "registry+https://github.com/rust-lang/crates.io-index"
2793+checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6"
2794+
2795+[[package]]
2796+name = "nu-ansi-term"
2797+version = "0.50.3"
2798+source = "registry+https://github.com/rust-lang/crates.io-index"
2799+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
2800+dependencies = [
2801+ "windows-sys",
2802+]
2803+
2804+[[package]]
2805+name = "num-bigint"
2806+version = "0.4.6"
2807+source = "registry+https://github.com/rust-lang/crates.io-index"
2808+checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
2809+dependencies = [
2810+ "num-integer",
2811+ "num-traits",
2812+]
2813+
2814+[[package]]
2815+name = "num-conv"
2816+version = "0.2.2"
2817+source = "registry+https://github.com/rust-lang/crates.io-index"
2818+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
2819+
2820+[[package]]
2821+name = "num-integer"
2822+version = "0.1.46"
2823+source = "registry+https://github.com/rust-lang/crates.io-index"
2824+checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
2825+dependencies = [
2826+ "num-traits",
2827+]
2828+
2829+[[package]]
2830+name = "num-traits"
2831+version = "0.2.19"
2832+source = "registry+https://github.com/rust-lang/crates.io-index"
2833+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
2834+dependencies = [
2835+ "autocfg",
2836+]
2837+
2838+[[package]]
2839+name = "num_cpus"
2840+version = "1.17.0"
2841+source = "registry+https://github.com/rust-lang/crates.io-index"
2842+checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
2843+dependencies = [
2844+ "hermit-abi",
2845+ "libc",
2846+]
2847+
2848+[[package]]
2849+name = "once_cell"
2850+version = "1.21.4"
2851+source = "registry+https://github.com/rust-lang/crates.io-index"
2852+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
2853+
2854+[[package]]
2855+name = "once_cell_polyfill"
2856+version = "1.70.2"
2857+source = "registry+https://github.com/rust-lang/crates.io-index"
2858+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
2859+
2860+[[package]]
2861+name = "p256"
2862+version = "0.14.0-rc.10"
2863+source = "registry+https://github.com/rust-lang/crates.io-index"
2864+checksum = "41adc63effe99d48837a8cc0e6d7a77e32ae6a07f6000df466178dbc2193093e"
2865+dependencies = [
2866+ "ecdsa",
2867+ "elliptic-curve",
2868+ "primefield",
2869+ "primeorder",
2870+ "sha2",
2871+]
2872+
2873+[[package]]
2874+name = "p384"
2875+version = "0.14.0-rc.10"
2876+source = "registry+https://github.com/rust-lang/crates.io-index"
2877+checksum = "9bd5333afa5ae0347f39e6a0f2c9c155da431583fd71fe5555bd0521b4ccaf02"
2878+dependencies = [
2879+ "ecdsa",
2880+ "elliptic-curve",
2881+ "fiat-crypto",
2882+ "primefield",
2883+ "primeorder",
2884+ "sha2",
2885+]
2886+
2887+[[package]]
2888+name = "p521"
2889+version = "0.14.0-rc.10"
2890+source = "registry+https://github.com/rust-lang/crates.io-index"
2891+checksum = "a3a5297f53dc16d35909060ba3032cff7867e8809f01e273ff325579d5f0ceae"
2892+dependencies = [
2893+ "base16ct",
2894+ "ecdsa",
2895+ "elliptic-curve",
2896+ "primefield",
2897+ "primeorder",
2898+ "sha2",
2899+]
2900+
2901+[[package]]
2902+name = "pageant"
2903+version = "0.2.1"
2904+source = "registry+https://github.com/rust-lang/crates.io-index"
2905+checksum = "4f3a5ae18f65a85c67a77d18d42d3606c07948e3c17c1e5f74852b26589e88a5"
2906+dependencies = [
2907+ "base16ct",
2908+ "byteorder",
2909+ "bytes",
2910+ "delegate",
2911+ "futures",
2912+ "log",
2913+ "rand",
2914+ "sha2",
2915+ "thiserror",
2916+ "tokio",
2917+ "windows",
2918+ "windows-strings",
2919+]
2920+
2921+[[package]]
2922+name = "parking_lot"
2923+version = "0.12.5"
2924+source = "registry+https://github.com/rust-lang/crates.io-index"
2925+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
2926+dependencies = [
2927+ "lock_api",
2928+ "parking_lot_core",
2929+]
2930+
2931+[[package]]
2932+name = "parking_lot_core"
2933+version = "0.9.12"
2934+source = "registry+https://github.com/rust-lang/crates.io-index"
2935+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
2936+dependencies = [
2937+ "cfg-if",
2938+ "libc",
2939+ "redox_syscall",
2940+ "smallvec",
2941+ "windows-link",
2942+]
2943+
2944+[[package]]
2945+name = "password-hash"
2946+version = "0.5.0"
2947+source = "registry+https://github.com/rust-lang/crates.io-index"
2948+checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
2949+dependencies = [
2950+ "base64ct",
2951+ "rand_core 0.6.4",
2952+ "subtle",
2953+]
2954+
2955+[[package]]
2956+name = "password-hash"
2957+version = "0.6.1"
2958+source = "registry+https://github.com/rust-lang/crates.io-index"
2959+checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b"
2960+dependencies = [
2961+ "phc",
2962+]
2963+
2964+[[package]]
2965+name = "pbkdf2"
2966+version = "0.13.0"
2967+source = "registry+https://github.com/rust-lang/crates.io-index"
2968+checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629"
2969+dependencies = [
2970+ "digest 0.11.3",
2971+ "hmac",
2972+]
2973+
2974+[[package]]
2975+name = "pem-rfc7468"
2976+version = "1.0.0"
2977+source = "registry+https://github.com/rust-lang/crates.io-index"
2978+checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9"
2979+dependencies = [
2980+ "base64ct",
2981+]
2982+
2983+[[package]]
2984+name = "percent-encoding"
2985+version = "2.3.2"
2986+source = "registry+https://github.com/rust-lang/crates.io-index"
2987+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
2988+
2989+[[package]]
2990+name = "phc"
2991+version = "0.6.1"
2992+source = "registry+https://github.com/rust-lang/crates.io-index"
2993+checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892"
2994+dependencies = [
2995+ "base64ct",
2996+ "ctutils",
2997+]
2998+
2999+[[package]]
3000+name = "pin-project-lite"
3001+version = "0.2.17"
3002+source = "registry+https://github.com/rust-lang/crates.io-index"
3003+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
3004+
3005+[[package]]
3006+name = "pkcs1"
3007+version = "0.8.0-rc.4"
3008+source = "registry+https://github.com/rust-lang/crates.io-index"
3009+checksum = "986d2e952779af96ea048f160fd9194e1751b4faea78bcf3ceb456efe008088e"
3010+dependencies = [
3011+ "der",
3012+ "spki",
3013+]
3014+
3015+[[package]]
3016+name = "pkcs5"
3017+version = "0.8.0"
3018+source = "registry+https://github.com/rust-lang/crates.io-index"
3019+checksum = "279a91971a1d8eb1260a30938eae3be9cb67b472dffecb222fbbbe2fd2dc1453"
3020+dependencies = [
3021+ "aes",
3022+ "cbc",
3023+ "der",
3024+ "pbkdf2",
3025+ "rand_core 0.10.1",
3026+ "scrypt",
3027+ "sha2",
3028+ "spki",
3029+]
3030+
3031+[[package]]
3032+name = "pkcs8"
3033+version = "0.11.0"
3034+source = "registry+https://github.com/rust-lang/crates.io-index"
3035+checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7"
3036+dependencies = [
3037+ "der",
3038+ "pkcs5",
3039+ "rand_core 0.10.1",
3040+ "spki",
3041+]
3042+
3043+[[package]]
3044+name = "pkg-config"
3045+version = "0.3.33"
3046+source = "registry+https://github.com/rust-lang/crates.io-index"
3047+checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
3048+
3049+[[package]]
3050+name = "plist"
3051+version = "1.9.0"
3052+source = "registry+https://github.com/rust-lang/crates.io-index"
3053+checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"
3054+dependencies = [
3055+ "base64",
3056+ "indexmap",
3057+ "quick-xml",
3058+ "serde",
3059+ "time",
3060+]
3061+
3062+[[package]]
3063+name = "pluralizer"
3064+version = "0.5.0"
3065+source = "registry+https://github.com/rust-lang/crates.io-index"
3066+checksum = "4b3eba432a00a1f6c16f39147847a870e94e2e9b992759b503e330efec778cbe"
3067+dependencies = [
3068+ "once_cell",
3069+ "regex",
3070+]
3071+
3072+[[package]]
3073+name = "poly1305"
3074+version = "0.9.0"
3075+source = "registry+https://github.com/rust-lang/crates.io-index"
3076+checksum = "a00baa632505d05512f48a963e16051c54fda9a95cc9acea1a4e3c90991c4a2e"
3077+dependencies = [
3078+ "cpufeatures 0.3.0",
3079+ "universal-hash",
3080+ "zeroize",
3081+]
3082+
3083+[[package]]
3084+name = "polyval"
3085+version = "0.7.1"
3086+source = "registry+https://github.com/rust-lang/crates.io-index"
3087+checksum = "7dfc63250416fea14f5749b90725916a6c903f599d51cb635aa7a52bfd03eede"
3088+dependencies = [
3089+ "cpubits",
3090+ "cpufeatures 0.3.0",
3091+ "universal-hash",
3092+]
3093+
3094+[[package]]
3095+name = "portable-atomic"
3096+version = "1.13.1"
3097+source = "registry+https://github.com/rust-lang/crates.io-index"
3098+checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
3099+
3100+[[package]]
3101+name = "portable-atomic-util"
3102+version = "0.2.7"
3103+source = "registry+https://github.com/rust-lang/crates.io-index"
3104+checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
3105+dependencies = [
3106+ "portable-atomic",
3107+]
3108+
3109+[[package]]
3110+name = "potential_utf"
3111+version = "0.1.5"
3112+source = "registry+https://github.com/rust-lang/crates.io-index"
3113+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
3114+dependencies = [
3115+ "zerovec",
3116+]
3117+
3118+[[package]]
3119+name = "powerfmt"
3120+version = "0.2.0"
3121+source = "registry+https://github.com/rust-lang/crates.io-index"
3122+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
3123+
3124+[[package]]
3125+name = "prettyplease"
3126+version = "0.2.37"
3127+source = "registry+https://github.com/rust-lang/crates.io-index"
3128+checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
3129+dependencies = [
3130+ "proc-macro2",
3131+ "syn",
3132+]
3133+
3134+[[package]]
3135+name = "primefield"
3136+version = "0.14.0-rc.10"
3137+source = "registry+https://github.com/rust-lang/crates.io-index"
3138+checksum = "f845ec3240cd5ed5e1e31cf3ff633a5bf47c698dc4092ba9e767415b3d393406"
3139+dependencies = [
3140+ "crypto-bigint",
3141+ "crypto-common 0.2.2",
3142+ "ff",
3143+ "rand_core 0.10.1",
3144+ "subtle",
3145+ "zeroize",
3146+]
3147+
3148+[[package]]
3149+name = "primeorder"
3150+version = "0.14.0-rc.10"
3151+source = "registry+https://github.com/rust-lang/crates.io-index"
3152+checksum = "7d2793f22b9b6fd11ef3ac1d59bf003c2573593e4968702341605c2748fd90bf"
3153+dependencies = [
3154+ "elliptic-curve",
3155+]
3156+
3157+[[package]]
3158+name = "proc-macro2"
3159+version = "1.0.106"
3160+source = "registry+https://github.com/rust-lang/crates.io-index"
3161+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
3162+dependencies = [
3163+ "unicode-ident",
3164+]
3165+
3166+[[package]]
3167+name = "proc-macro2-diagnostics"
3168+version = "0.10.1"
3169+source = "registry+https://github.com/rust-lang/crates.io-index"
3170+checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8"
3171+dependencies = [
3172+ "proc-macro2",
3173+ "quote",
3174+ "syn",
3175+ "version_check",
3176+]
3177+
3178+[[package]]
3179+name = "prodash"
3180+version = "31.0.0"
3181+source = "registry+https://github.com/rust-lang/crates.io-index"
3182+checksum = "962200e2d7d551451297d9fdce85138374019ada198e30ea9ede38034e27604c"
3183+dependencies = [
3184+ "parking_lot",
3185+]
3186+
3187+[[package]]
3188+name = "quick-xml"
3189+version = "0.39.4"
3190+source = "registry+https://github.com/rust-lang/crates.io-index"
3191+checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e"
3192+dependencies = [
3193+ "memchr",
3194+]
3195+
3196+[[package]]
3197+name = "quote"
3198+version = "1.0.45"
3199+source = "registry+https://github.com/rust-lang/crates.io-index"
3200+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
3201+dependencies = [
3202+ "proc-macro2",
3203+]
3204+
3205+[[package]]
3206+name = "r-efi"
3207+version = "5.3.0"
3208+source = "registry+https://github.com/rust-lang/crates.io-index"
3209+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
3210+
3211+[[package]]
3212+name = "r-efi"
3213+version = "6.0.0"
3214+source = "registry+https://github.com/rust-lang/crates.io-index"
3215+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
3216+
3217+[[package]]
3218+name = "rand"
3219+version = "0.10.1"
3220+source = "registry+https://github.com/rust-lang/crates.io-index"
3221+checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207"
3222+dependencies = [
3223+ "chacha20",
3224+ "getrandom 0.4.2",
3225+ "rand_core 0.10.1",
3226+]
3227+
3228+[[package]]
3229+name = "rand_core"
3230+version = "0.6.4"
3231+source = "registry+https://github.com/rust-lang/crates.io-index"
3232+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
3233+dependencies = [
3234+ "getrandom 0.2.17",
3235+]
3236+
3237+[[package]]
3238+name = "rand_core"
3239+version = "0.10.1"
3240+source = "registry+https://github.com/rust-lang/crates.io-index"
3241+checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
3242+
3243+[[package]]
3244+name = "redox_syscall"
3245+version = "0.5.18"
3246+source = "registry+https://github.com/rust-lang/crates.io-index"
3247+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
3248+dependencies = [
3249+ "bitflags",
3250+]
3251+
3252+[[package]]
3253+name = "regex"
3254+version = "1.12.4"
3255+source = "registry+https://github.com/rust-lang/crates.io-index"
3256+checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba"
3257+dependencies = [
3258+ "aho-corasick",
3259+ "memchr",
3260+ "regex-automata",
3261+ "regex-syntax",
3262+]
3263+
3264+[[package]]
3265+name = "regex-automata"
3266+version = "0.4.14"
3267+source = "registry+https://github.com/rust-lang/crates.io-index"
3268+checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
3269+dependencies = [
3270+ "aho-corasick",
3271+ "memchr",
3272+ "regex-syntax",
3273+]
3274+
3275+[[package]]
3276+name = "regex-syntax"
3277+version = "0.8.11"
3278+source = "registry+https://github.com/rust-lang/crates.io-index"
3279+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
3280+
3281+[[package]]
3282+name = "rfc6979"
3283+version = "0.5.0"
3284+source = "registry+https://github.com/rust-lang/crates.io-index"
3285+checksum = "5236ce872cac07e0fb3969b0cbf468c7d2f37d432f1b627dcb7b8d34563fb0c3"
3286+dependencies = [
3287+ "hmac",
3288+ "subtle",
3289+]
3290+
3291+[[package]]
3292+name = "rsa"
3293+version = "0.10.0-rc.18"
3294+source = "registry+https://github.com/rust-lang/crates.io-index"
3295+checksum = "30b2aa4ba0d89f73d1e332df05be0eeab8840351c36ca5654341dfdb57bb3caf"
3296+dependencies = [
3297+ "const-oid",
3298+ "crypto-bigint",
3299+ "crypto-primes",
3300+ "digest 0.11.3",
3301+ "pkcs1",
3302+ "pkcs8",
3303+ "rand_core 0.10.1",
3304+ "sha2",
3305+ "signature",
3306+ "spki",
3307+ "zeroize",
3308+]
3309+
3310+[[package]]
3311+name = "rsqlite-vfs"
3312+version = "0.1.1"
3313+source = "registry+https://github.com/rust-lang/crates.io-index"
3314+checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
3315+dependencies = [
3316+ "hashbrown 0.16.1",
3317+ "thiserror",
3318+]
3319+
3320+[[package]]
3321+name = "rusqlite"
3322+version = "0.39.0"
3323+source = "registry+https://github.com/rust-lang/crates.io-index"
3324+checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e"
3325+dependencies = [
3326+ "bitflags",
3327+ "fallible-iterator",
3328+ "fallible-streaming-iterator",
3329+ "hashlink",
3330+ "libsqlite3-sys",
3331+ "smallvec",
3332+ "sqlite-wasm-rs",
3333+]
3334+
3335+[[package]]
3336+name = "russh"
3337+version = "0.61.2"
3338+source = "registry+https://github.com/rust-lang/crates.io-index"
3339+checksum = "bbf893f64684e58da8a68d56a5e84d1cf0440226274c515770fe267707a7d0b0"
3340+dependencies = [
3341+ "aes",
3342+ "aws-lc-rs",
3343+ "bitflags",
3344+ "block-padding",
3345+ "byteorder",
3346+ "bytes",
3347+ "cbc",
3348+ "cipher",
3349+ "crypto-bigint",
3350+ "ctr",
3351+ "curve25519-dalek",
3352+ "data-encoding",
3353+ "delegate",
3354+ "der",
3355+ "digest 0.11.3",
3356+ "ecdsa",
3357+ "ed25519-dalek",
3358+ "elliptic-curve",
3359+ "enum_dispatch",
3360+ "flate2",
3361+ "futures",
3362+ "generic-array 1.4.3",
3363+ "getrandom 0.4.2",
3364+ "ghash",
3365+ "hex-literal",
3366+ "hmac",
3367+ "inout",
3368+ "internal-russh-num-bigint",
3369+ "keccak",
3370+ "log",
3371+ "md5",
3372+ "ml-kem",
3373+ "module-lattice",
3374+ "num-bigint",
3375+ "p256",
3376+ "p384",
3377+ "p521",
3378+ "pageant",
3379+ "pbkdf2",
3380+ "pkcs1",
3381+ "pkcs5",
3382+ "pkcs8",
3383+ "polyval",
3384+ "rand",
3385+ "rand_core 0.10.1",
3386+ "rsa",
3387+ "russh-cryptovec",
3388+ "russh-util",
3389+ "salsa20",
3390+ "scrypt",
3391+ "sec1",
3392+ "sha1 0.11.0",
3393+ "sha2",
3394+ "sha3",
3395+ "signature",
3396+ "spki",
3397+ "ssh-encoding",
3398+ "ssh-key",
3399+ "subtle",
3400+ "thiserror",
3401+ "tokio",
3402+ "typenum",
3403+ "universal-hash",
3404+ "zeroize",
3405+]
3406+
3407+[[package]]
3408+name = "russh-cryptovec"
3409+version = "0.61.0"
3410+source = "registry+https://github.com/rust-lang/crates.io-index"
3411+checksum = "443f6bbcfacb34a1aab2b12b99bf08e0c63abdc5a0db261901365df9d57fff51"
3412+dependencies = [
3413+ "log",
3414+ "nix",
3415+ "ssh-encoding",
3416+ "windows-sys",
3417+]
3418+
3419+[[package]]
3420+name = "russh-util"
3421+version = "0.52.0"
3422+source = "registry+https://github.com/rust-lang/crates.io-index"
3423+checksum = "668424a5dde0bcb45b55ba7de8476b93831b4aa2fa6947e145f3b053e22c60b6"
3424+dependencies = [
3425+ "chrono",
3426+ "tokio",
3427+ "wasm-bindgen",
3428+ "wasm-bindgen-futures",
3429+]
3430+
3431+[[package]]
3432+name = "rustc_version"
3433+version = "0.4.1"
3434+source = "registry+https://github.com/rust-lang/crates.io-index"
3435+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
3436+dependencies = [
3437+ "semver",
3438+]
3439+
3440+[[package]]
3441+name = "rustix"
3442+version = "1.1.4"
3443+source = "registry+https://github.com/rust-lang/crates.io-index"
3444+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
3445+dependencies = [
3446+ "bitflags",
3447+ "errno",
3448+ "libc",
3449+ "linux-raw-sys",
3450+ "windows-sys",
3451+]
3452+
3453+[[package]]
3454+name = "rustversion"
3455+version = "1.0.22"
3456+source = "registry+https://github.com/rust-lang/crates.io-index"
3457+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
3458+
3459+[[package]]
3460+name = "ryu"
3461+version = "1.0.23"
3462+source = "registry+https://github.com/rust-lang/crates.io-index"
3463+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
3464+
3465+[[package]]
3466+name = "salsa20"
3467+version = "0.11.0"
3468+source = "registry+https://github.com/rust-lang/crates.io-index"
3469+checksum = "2f874456e72520ff1375a06c588eaf074b0f01f9e9e1aada45bd9b7954a6e42c"
3470+dependencies = [
3471+ "cfg-if",
3472+ "cipher",
3473+]
3474+
3475+[[package]]
3476+name = "same-file"
3477+version = "1.0.6"
3478+source = "registry+https://github.com/rust-lang/crates.io-index"
3479+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
3480+dependencies = [
3481+ "winapi-util",
3482+]
3483+
3484+[[package]]
3485+name = "scopeguard"
3486+version = "1.2.0"
3487+source = "registry+https://github.com/rust-lang/crates.io-index"
3488+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
3489+
3490+[[package]]
3491+name = "scrypt"
3492+version = "0.12.0"
3493+source = "registry+https://github.com/rust-lang/crates.io-index"
3494+checksum = "d87af57419b594aa23fa95f09f0e06d80d84ba01c26148c43844cad6ff4485f0"
3495+dependencies = [
3496+ "cfg-if",
3497+ "pbkdf2",
3498+ "salsa20",
3499+ "sha2",
3500+]
3501+
3502+[[package]]
3503+name = "sec1"
3504+version = "0.8.1"
3505+source = "registry+https://github.com/rust-lang/crates.io-index"
3506+checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d"
3507+dependencies = [
3508+ "base16ct",
3509+ "ctutils",
3510+ "der",
3511+ "hybrid-array",
3512+ "subtle",
3513+ "zeroize",
3514+]
3515+
3516+[[package]]
3517+name = "semver"
3518+version = "1.0.28"
3519+source = "registry+https://github.com/rust-lang/crates.io-index"
3520+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
3521+
3522+[[package]]
3523+name = "serde"
3524+version = "1.0.228"
3525+source = "registry+https://github.com/rust-lang/crates.io-index"
3526+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
3527+dependencies = [
3528+ "serde_core",
3529+ "serde_derive",
3530+]
3531+
3532+[[package]]
3533+name = "serde_core"
3534+version = "1.0.228"
3535+source = "registry+https://github.com/rust-lang/crates.io-index"
3536+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
3537+dependencies = [
3538+ "serde_derive",
3539+]
3540+
3541+[[package]]
3542+name = "serde_derive"
3543+version = "1.0.228"
3544+source = "registry+https://github.com/rust-lang/crates.io-index"
3545+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
3546+dependencies = [
3547+ "proc-macro2",
3548+ "quote",
3549+ "syn",
3550+]
3551+
3552+[[package]]
3553+name = "serde_json"
3554+version = "1.0.150"
3555+source = "registry+https://github.com/rust-lang/crates.io-index"
3556+checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
3557+dependencies = [
3558+ "itoa",
3559+ "memchr",
3560+ "serde",
3561+ "serde_core",
3562+ "zmij",
3563+]
3564+
3565+[[package]]
3566+name = "serde_path_to_error"
3567+version = "0.1.20"
3568+source = "registry+https://github.com/rust-lang/crates.io-index"
3569+checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
3570+dependencies = [
3571+ "itoa",
3572+ "serde",
3573+ "serde_core",
3574+]
3575+
3576+[[package]]
3577+name = "serde_spanned"
3578+version = "0.6.9"
3579+source = "registry+https://github.com/rust-lang/crates.io-index"
3580+checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
3581+dependencies = [
3582+ "serde",
3583+]
3584+
3585+[[package]]
3586+name = "serde_urlencoded"
3587+version = "0.7.1"
3588+source = "registry+https://github.com/rust-lang/crates.io-index"
3589+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
3590+dependencies = [
3591+ "form_urlencoded",
3592+ "itoa",
3593+ "ryu",
3594+ "serde",
3595+]
3596+
3597+[[package]]
3598+name = "serdect"
3599+version = "0.4.3"
3600+source = "registry+https://github.com/rust-lang/crates.io-index"
3601+checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e"
3602+dependencies = [
3603+ "base16ct",
3604+ "serde",
3605+]
3606+
3607+[[package]]
3608+name = "sha1"
3609+version = "0.10.6"
3610+source = "registry+https://github.com/rust-lang/crates.io-index"
3611+checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
3612+dependencies = [
3613+ "cfg-if",
3614+ "cpufeatures 0.2.17",
3615+ "digest 0.10.7",
3616+]
3617+
3618+[[package]]
3619+name = "sha1"
3620+version = "0.11.0"
3621+source = "registry+https://github.com/rust-lang/crates.io-index"
3622+checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
3623+dependencies = [
3624+ "cfg-if",
3625+ "cpufeatures 0.3.0",
3626+ "digest 0.11.3",
3627+]
3628+
3629+[[package]]
3630+name = "sha1-checked"
3631+version = "0.10.0"
3632+source = "registry+https://github.com/rust-lang/crates.io-index"
3633+checksum = "89f599ac0c323ebb1c6082821a54962b839832b03984598375bff3975b804423"
3634+dependencies = [
3635+ "digest 0.10.7",
3636+ "sha1 0.10.6",
3637+]
3638+
3639+[[package]]
3640+name = "sha2"
3641+version = "0.11.0"
3642+source = "registry+https://github.com/rust-lang/crates.io-index"
3643+checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
3644+dependencies = [
3645+ "cfg-if",
3646+ "cpufeatures 0.3.0",
3647+ "digest 0.11.3",
3648+]
3649+
3650+[[package]]
3651+name = "sha3"
3652+version = "0.11.0"
3653+source = "registry+https://github.com/rust-lang/crates.io-index"
3654+checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1"
3655+dependencies = [
3656+ "digest 0.11.3",
3657+ "keccak",
3658+]
3659+
3660+[[package]]
3661+name = "sharded-slab"
3662+version = "0.1.7"
3663+source = "registry+https://github.com/rust-lang/crates.io-index"
3664+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
3665+dependencies = [
3666+ "lazy_static",
3667+]
3668+
3669+[[package]]
3670+name = "shell-words"
3671+version = "1.1.1"
3672+source = "registry+https://github.com/rust-lang/crates.io-index"
3673+checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
3674+
3675+[[package]]
3676+name = "shlex"
3677+version = "2.0.1"
3678+source = "registry+https://github.com/rust-lang/crates.io-index"
3679+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
3680+
3681+[[package]]
3682+name = "signal-hook-registry"
3683+version = "1.4.8"
3684+source = "registry+https://github.com/rust-lang/crates.io-index"
3685+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
3686+dependencies = [
3687+ "errno",
3688+ "libc",
3689+]
3690+
3691+[[package]]
3692+name = "signature"
3693+version = "3.0.0"
3694+source = "registry+https://github.com/rust-lang/crates.io-index"
3695+checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5"
3696+dependencies = [
3697+ "digest 0.11.3",
3698+ "rand_core 0.10.1",
3699+]
3700+
3701+[[package]]
3702+name = "simd-adler32"
3703+version = "0.3.9"
3704+source = "registry+https://github.com/rust-lang/crates.io-index"
3705+checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
3706+
3707+[[package]]
3708+name = "similar"
3709+version = "2.7.0"
3710+source = "registry+https://github.com/rust-lang/crates.io-index"
3711+checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
3712+
3713+[[package]]
3714+name = "slab"
3715+version = "0.4.12"
3716+source = "registry+https://github.com/rust-lang/crates.io-index"
3717+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
3718+
3719+[[package]]
3720+name = "smallvec"
3721+version = "1.15.1"
3722+source = "registry+https://github.com/rust-lang/crates.io-index"
3723+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
3724+
3725+[[package]]
3726+name = "socket2"
3727+version = "0.6.4"
3728+source = "registry+https://github.com/rust-lang/crates.io-index"
3729+checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
3730+dependencies = [
3731+ "libc",
3732+ "windows-sys",
3733+]
3734+
3735+[[package]]
3736+name = "spki"
3737+version = "0.8.0"
3738+source = "registry+https://github.com/rust-lang/crates.io-index"
3739+checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f"
3740+dependencies = [
3741+ "base64ct",
3742+ "der",
3743+]
3744+
3745+[[package]]
3746+name = "sqlite-wasm-rs"
3747+version = "0.5.5"
3748+source = "registry+https://github.com/rust-lang/crates.io-index"
3749+checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75"
3750+dependencies = [
3751+ "cc",
3752+ "js-sys",
3753+ "rsqlite-vfs",
3754+ "wasm-bindgen",
3755+]
3756+
3757+[[package]]
3758+name = "ssh-cipher"
3759+version = "0.3.0-rc.9"
3760+source = "registry+https://github.com/rust-lang/crates.io-index"
3761+checksum = "10db6f219196a8528f9ec904d9d45cdad692d65b0e57e72be4dedd1c5fddce36"
3762+dependencies = [
3763+ "aead",
3764+ "aes",
3765+ "aes-gcm",
3766+ "cbc",
3767+ "chacha20",
3768+ "cipher",
3769+ "ctr",
3770+ "ctutils",
3771+ "des",
3772+ "poly1305",
3773+ "ssh-encoding",
3774+ "zeroize",
3775+]
3776+
3777+[[package]]
3778+name = "ssh-encoding"
3779+version = "0.3.0-rc.9"
3780+source = "registry+https://github.com/rust-lang/crates.io-index"
3781+checksum = "7abf34aa716da5d5b4c496936d042ea282ab392092cd68a72ef6a8863ff8c96a"
3782+dependencies = [
3783+ "base64ct",
3784+ "bytes",
3785+ "crypto-bigint",
3786+ "ctutils",
3787+ "digest 0.11.3",
3788+ "pem-rfc7468",
3789+ "zeroize",
3790+]
3791+
3792+[[package]]
3793+name = "ssh-key"
3794+version = "0.7.0-rc.10"
3795+source = "registry+https://github.com/rust-lang/crates.io-index"
3796+checksum = "45735ce3dea95690e4a9e414c4cfde7f79835063c3dcd35881df85a84118e74b"
3797+dependencies = [
3798+ "argon2 0.6.0-rc.8",
3799+ "bcrypt-pbkdf",
3800+ "ctutils",
3801+ "ed25519-dalek",
3802+ "hex",
3803+ "hmac",
3804+ "p256",
3805+ "p384",
3806+ "p521",
3807+ "rand_core 0.10.1",
3808+ "rsa",
3809+ "sec1",
3810+ "sha1 0.11.0",
3811+ "sha2",
3812+ "signature",
3813+ "ssh-cipher",
3814+ "ssh-encoding",
3815+ "zeroize",
3816+]
3817+
3818+[[package]]
3819+name = "stable_deref_trait"
3820+version = "1.2.1"
3821+source = "registry+https://github.com/rust-lang/crates.io-index"
3822+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
3823+
3824+[[package]]
3825+name = "static_assertions"
3826+version = "1.1.0"
3827+source = "registry+https://github.com/rust-lang/crates.io-index"
3828+checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
3829+
3830+[[package]]
3831+name = "strsim"
3832+version = "0.11.1"
3833+source = "registry+https://github.com/rust-lang/crates.io-index"
3834+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
3835+
3836+[[package]]
3837+name = "subtle"
3838+version = "2.6.1"
3839+source = "registry+https://github.com/rust-lang/crates.io-index"
3840+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
3841+
3842+[[package]]
3843+name = "syn"
3844+version = "2.0.117"
3845+source = "registry+https://github.com/rust-lang/crates.io-index"
3846+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
3847+dependencies = [
3848+ "proc-macro2",
3849+ "quote",
3850+ "unicode-ident",
3851+]
3852+
3853+[[package]]
3854+name = "sync_wrapper"
3855+version = "1.0.2"
3856+source = "registry+https://github.com/rust-lang/crates.io-index"
3857+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
3858+
3859+[[package]]
3860+name = "synstructure"
3861+version = "0.13.2"
3862+source = "registry+https://github.com/rust-lang/crates.io-index"
3863+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
3864+dependencies = [
3865+ "proc-macro2",
3866+ "quote",
3867+ "syn",
3868+]
3869+
3870+[[package]]
3871+name = "syntect"
3872+version = "5.3.0"
3873+source = "registry+https://github.com/rust-lang/crates.io-index"
3874+checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925"
3875+dependencies = [
3876+ "bincode",
3877+ "fancy-regex",
3878+ "flate2",
3879+ "fnv",
3880+ "once_cell",
3881+ "plist",
3882+ "regex-syntax",
3883+ "serde",
3884+ "serde_derive",
3885+ "serde_json",
3886+ "thiserror",
3887+ "walkdir",
3888+ "yaml-rust",
3889+]
3890+
3891+[[package]]
3892+name = "tempfile"
3893+version = "3.27.0"
3894+source = "registry+https://github.com/rust-lang/crates.io-index"
3895+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
3896+dependencies = [
3897+ "fastrand",
3898+ "getrandom 0.4.2",
3899+ "once_cell",
3900+ "rustix",
3901+ "windows-sys",
3902+]
3903+
3904+[[package]]
3905+name = "thiserror"
3906+version = "2.0.18"
3907+source = "registry+https://github.com/rust-lang/crates.io-index"
3908+checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
3909+dependencies = [
3910+ "thiserror-impl",
3911+]
3912+
3913+[[package]]
3914+name = "thiserror-impl"
3915+version = "2.0.18"
3916+source = "registry+https://github.com/rust-lang/crates.io-index"
3917+checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
3918+dependencies = [
3919+ "proc-macro2",
3920+ "quote",
3921+ "syn",
3922+]
3923+
3924+[[package]]
3925+name = "thread_local"
3926+version = "1.1.9"
3927+source = "registry+https://github.com/rust-lang/crates.io-index"
3928+checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
3929+dependencies = [
3930+ "cfg-if",
3931+]
3932+
3933+[[package]]
3934+name = "time"
3935+version = "0.3.47"
3936+source = "registry+https://github.com/rust-lang/crates.io-index"
3937+checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
3938+dependencies = [
3939+ "deranged",
3940+ "itoa",
3941+ "num-conv",
3942+ "powerfmt",
3943+ "serde_core",
3944+ "time-core",
3945+ "time-macros",
3946+]
3947+
3948+[[package]]
3949+name = "time-core"
3950+version = "0.1.8"
3951+source = "registry+https://github.com/rust-lang/crates.io-index"
3952+checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
3953+
3954+[[package]]
3955+name = "time-macros"
3956+version = "0.2.27"
3957+source = "registry+https://github.com/rust-lang/crates.io-index"
3958+checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
3959+dependencies = [
3960+ "num-conv",
3961+ "time-core",
3962+]
3963+
3964+[[package]]
3965+name = "tinystr"
3966+version = "0.8.3"
3967+source = "registry+https://github.com/rust-lang/crates.io-index"
3968+checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
3969+dependencies = [
3970+ "displaydoc",
3971+ "zerovec",
3972+]
3973+
3974+[[package]]
3975+name = "tinyvec"
3976+version = "1.11.0"
3977+source = "registry+https://github.com/rust-lang/crates.io-index"
3978+checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
3979+dependencies = [
3980+ "tinyvec_macros",
3981+]
3982+
3983+[[package]]
3984+name = "tinyvec_macros"
3985+version = "0.1.1"
3986+source = "registry+https://github.com/rust-lang/crates.io-index"
3987+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
3988+
3989+[[package]]
3990+name = "toasty"
3991+version = "0.7.0"
3992+source = "registry+https://github.com/rust-lang/crates.io-index"
3993+checksum = "17bd9e42ba3c1aa195101d150ba281f3d426439e0d2b5991b4c7c6290204348f"
3994+dependencies = [
3995+ "async-trait",
3996+ "bit-set 0.10.0",
3997+ "by_address",
3998+ "deadpool",
3999+ "hashbrown 0.17.1",
4000+ "index_vec",
4001+ "indexmap",
4002+ "inventory",
4003+ "toasty-core",
4004+ "toasty-driver-sqlite",
4005+ "toasty-macros",
4006+ "tokio",
4007+ "tokio-stream",
4008+ "tracing",
4009+ "url",
4010+ "uuid",
4011+]
4012+
4013+[[package]]
4014+name = "toasty-core"
4015+version = "0.7.0"
4016+source = "registry+https://github.com/rust-lang/crates.io-index"
4017+checksum = "7f06ecf2a9091a814d7cfa7edc39c24a791460431f55a0ee19c1b2cb4a15b98c"
4018+dependencies = [
4019+ "async-trait",
4020+ "bit-set 0.10.0",
4021+ "hashbrown 0.17.1",
4022+ "heck",
4023+ "indexmap",
4024+ "pluralizer",
4025+ "tokio-stream",
4026+ "uuid",
4027+]
4028+
4029+[[package]]
4030+name = "toasty-driver-sqlite"
4031+version = "0.7.0"
4032+source = "registry+https://github.com/rust-lang/crates.io-index"
4033+checksum = "fd09e84ccfbd608949bc501c30e1c62334ddf07eb1c30976b2c470dfd19aef3e"
4034+dependencies = [
4035+ "async-trait",
4036+ "rusqlite",
4037+ "serde",
4038+ "serde_json",
4039+ "toasty-core",
4040+ "toasty-sql",
4041+ "tracing",
4042+ "url",
4043+ "uuid",
4044+]
4045+
4046+[[package]]
4047+name = "toasty-macros"
4048+version = "0.7.0"
4049+source = "registry+https://github.com/rust-lang/crates.io-index"
4050+checksum = "1d27469a13c35b53f3080ca24e4585f5cd2d1ba4dd71432125aa376759439131"
4051+dependencies = [
4052+ "hashbrown 0.17.1",
4053+ "heck",
4054+ "pluralizer",
4055+ "proc-macro2",
4056+ "quote",
4057+ "syn",
4058+ "toasty-core",
4059+]
4060+
4061+[[package]]
4062+name = "toasty-sql"
4063+version = "0.7.0"
4064+source = "registry+https://github.com/rust-lang/crates.io-index"
4065+checksum = "2848a892303d9d63485a8f16d9c1b692b145c09940d2cdeefaf0f837e1bf6a49"
4066+dependencies = [
4067+ "serde_json",
4068+ "toasty-core",
4069+]
4070+
4071+[[package]]
4072+name = "tokio"
4073+version = "1.52.3"
4074+source = "registry+https://github.com/rust-lang/crates.io-index"
4075+checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
4076+dependencies = [
4077+ "bytes",
4078+ "libc",
4079+ "mio",
4080+ "parking_lot",
4081+ "pin-project-lite",
4082+ "signal-hook-registry",
4083+ "socket2",
4084+ "tokio-macros",
4085+ "windows-sys",
4086+]
4087+
4088+[[package]]
4089+name = "tokio-macros"
4090+version = "2.7.0"
4091+source = "registry+https://github.com/rust-lang/crates.io-index"
4092+checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
4093+dependencies = [
4094+ "proc-macro2",
4095+ "quote",
4096+ "syn",
4097+]
4098+
4099+[[package]]
4100+name = "tokio-stream"
4101+version = "0.1.18"
4102+source = "registry+https://github.com/rust-lang/crates.io-index"
4103+checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
4104+dependencies = [
4105+ "futures-core",
4106+ "pin-project-lite",
4107+ "tokio",
4108+]
4109+
4110+[[package]]
4111+name = "tokio-util"
4112+version = "0.7.18"
4113+source = "registry+https://github.com/rust-lang/crates.io-index"
4114+checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
4115+dependencies = [
4116+ "bytes",
4117+ "futures-core",
4118+ "futures-sink",
4119+ "pin-project-lite",
4120+ "tokio",
4121+]
4122+
4123+[[package]]
4124+name = "toml"
4125+version = "0.8.23"
4126+source = "registry+https://github.com/rust-lang/crates.io-index"
4127+checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
4128+dependencies = [
4129+ "serde",
4130+ "serde_spanned",
4131+ "toml_datetime",
4132+ "toml_edit",
4133+]
4134+
4135+[[package]]
4136+name = "toml_datetime"
4137+version = "0.6.11"
4138+source = "registry+https://github.com/rust-lang/crates.io-index"
4139+checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
4140+dependencies = [
4141+ "serde",
4142+]
4143+
4144+[[package]]
4145+name = "toml_edit"
4146+version = "0.22.27"
4147+source = "registry+https://github.com/rust-lang/crates.io-index"
4148+checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
4149+dependencies = [
4150+ "indexmap",
4151+ "serde",
4152+ "serde_spanned",
4153+ "toml_datetime",
4154+ "toml_write",
4155+ "winnow",
4156+]
4157+
4158+[[package]]
4159+name = "toml_write"
4160+version = "0.1.2"
4161+source = "registry+https://github.com/rust-lang/crates.io-index"
4162+checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
4163+
4164+[[package]]
4165+name = "tower"
4166+version = "0.5.3"
4167+source = "registry+https://github.com/rust-lang/crates.io-index"
4168+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
4169+dependencies = [
4170+ "futures-core",
4171+ "futures-util",
4172+ "pin-project-lite",
4173+ "sync_wrapper",
4174+ "tokio",
4175+ "tower-layer",
4176+ "tower-service",
4177+ "tracing",
4178+]
4179+
4180+[[package]]
4181+name = "tower-http"
4182+version = "0.6.11"
4183+source = "registry+https://github.com/rust-lang/crates.io-index"
4184+checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
4185+dependencies = [
4186+ "bitflags",
4187+ "bytes",
4188+ "futures-core",
4189+ "futures-util",
4190+ "http",
4191+ "http-body",
4192+ "http-body-util",
4193+ "http-range-header",
4194+ "httpdate",
4195+ "mime",
4196+ "mime_guess",
4197+ "percent-encoding",
4198+ "pin-project-lite",
4199+ "tokio",
4200+ "tokio-util",
4201+ "tower-layer",
4202+ "tower-service",
4203+ "tracing",
4204+]
4205+
4206+[[package]]
4207+name = "tower-layer"
4208+version = "0.3.3"
4209+source = "registry+https://github.com/rust-lang/crates.io-index"
4210+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
4211+
4212+[[package]]
4213+name = "tower-service"
4214+version = "0.3.3"
4215+source = "registry+https://github.com/rust-lang/crates.io-index"
4216+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
4217+
4218+[[package]]
4219+name = "tracing"
4220+version = "0.1.44"
4221+source = "registry+https://github.com/rust-lang/crates.io-index"
4222+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
4223+dependencies = [
4224+ "log",
4225+ "pin-project-lite",
4226+ "tracing-attributes",
4227+ "tracing-core",
4228+]
4229+
4230+[[package]]
4231+name = "tracing-attributes"
4232+version = "0.1.31"
4233+source = "registry+https://github.com/rust-lang/crates.io-index"
4234+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
4235+dependencies = [
4236+ "proc-macro2",
4237+ "quote",
4238+ "syn",
4239+]
4240+
4241+[[package]]
4242+name = "tracing-core"
4243+version = "0.1.36"
4244+source = "registry+https://github.com/rust-lang/crates.io-index"
4245+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
4246+dependencies = [
4247+ "once_cell",
4248+ "valuable",
4249+]
4250+
4251+[[package]]
4252+name = "tracing-log"
4253+version = "0.2.0"
4254+source = "registry+https://github.com/rust-lang/crates.io-index"
4255+checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
4256+dependencies = [
4257+ "log",
4258+ "once_cell",
4259+ "tracing-core",
4260+]
4261+
4262+[[package]]
4263+name = "tracing-subscriber"
4264+version = "0.3.23"
4265+source = "registry+https://github.com/rust-lang/crates.io-index"
4266+checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
4267+dependencies = [
4268+ "matchers",
4269+ "nu-ansi-term",
4270+ "once_cell",
4271+ "regex-automata",
4272+ "sharded-slab",
4273+ "smallvec",
4274+ "thread_local",
4275+ "tracing",
4276+ "tracing-core",
4277+ "tracing-log",
4278+]
4279+
4280+[[package]]
4281+name = "typenum"
4282+version = "1.20.1"
4283+source = "registry+https://github.com/rust-lang/crates.io-index"
4284+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
4285+
4286+[[package]]
4287+name = "uluru"
4288+version = "3.1.0"
4289+source = "registry+https://github.com/rust-lang/crates.io-index"
4290+checksum = "7c8a2469e56e6e5095c82ccd3afb98dad95f7af7929aab6d8ba8d6e0f73657da"
4291+dependencies = [
4292+ "arrayvec",
4293+]
4294+
4295+[[package]]
4296+name = "unicase"
4297+version = "2.9.0"
4298+source = "registry+https://github.com/rust-lang/crates.io-index"
4299+checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
4300+
4301+[[package]]
4302+name = "unicode-bom"
4303+version = "2.0.3"
4304+source = "registry+https://github.com/rust-lang/crates.io-index"
4305+checksum = "7eec5d1121208364f6793f7d2e222bf75a915c19557537745b195b253dd64217"
4306+
4307+[[package]]
4308+name = "unicode-ident"
4309+version = "1.0.24"
4310+source = "registry+https://github.com/rust-lang/crates.io-index"
4311+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
4312+
4313+[[package]]
4314+name = "unicode-normalization"
4315+version = "0.1.25"
4316+source = "registry+https://github.com/rust-lang/crates.io-index"
4317+checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
4318+dependencies = [
4319+ "tinyvec",
4320+]
4321+
4322+[[package]]
4323+name = "unicode-xid"
4324+version = "0.2.6"
4325+source = "registry+https://github.com/rust-lang/crates.io-index"
4326+checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
4327+
4328+[[package]]
4329+name = "universal-hash"
4330+version = "0.6.1"
4331+source = "registry+https://github.com/rust-lang/crates.io-index"
4332+checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96"
4333+dependencies = [
4334+ "crypto-common 0.2.2",
4335+ "ctutils",
4336+]
4337+
4338+[[package]]
4339+name = "untrusted"
4340+version = "0.7.1"
4341+source = "registry+https://github.com/rust-lang/crates.io-index"
4342+checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
4343+
4344+[[package]]
4345+name = "url"
4346+version = "2.5.8"
4347+source = "registry+https://github.com/rust-lang/crates.io-index"
4348+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
4349+dependencies = [
4350+ "form_urlencoded",
4351+ "idna",
4352+ "percent-encoding",
4353+ "serde",
4354+]
4355+
4356+[[package]]
4357+name = "utf8_iter"
4358+version = "1.0.4"
4359+source = "registry+https://github.com/rust-lang/crates.io-index"
4360+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
4361+
4362+[[package]]
4363+name = "utf8parse"
4364+version = "0.2.2"
4365+source = "registry+https://github.com/rust-lang/crates.io-index"
4366+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
4367+
4368+[[package]]
4369+name = "uuid"
4370+version = "1.23.3"
4371+source = "registry+https://github.com/rust-lang/crates.io-index"
4372+checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7"
4373+dependencies = [
4374+ "getrandom 0.4.2",
4375+ "js-sys",
4376+ "rand",
4377+ "wasm-bindgen",
4378+]
4379+
4380+[[package]]
4381+name = "valuable"
4382+version = "0.1.1"
4383+source = "registry+https://github.com/rust-lang/crates.io-index"
4384+checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
4385+
4386+[[package]]
4387+name = "vcpkg"
4388+version = "0.2.15"
4389+source = "registry+https://github.com/rust-lang/crates.io-index"
4390+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
4391+
4392+[[package]]
4393+name = "version_check"
4394+version = "0.9.5"
4395+source = "registry+https://github.com/rust-lang/crates.io-index"
4396+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
4397+
4398+[[package]]
4399+name = "walkdir"
4400+version = "2.5.0"
4401+source = "registry+https://github.com/rust-lang/crates.io-index"
4402+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
4403+dependencies = [
4404+ "same-file",
4405+ "winapi-util",
4406+]
4407+
4408+[[package]]
4409+name = "wasi"
4410+version = "0.11.1+wasi-snapshot-preview1"
4411+source = "registry+https://github.com/rust-lang/crates.io-index"
4412+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
4413+
4414+[[package]]
4415+name = "wasip2"
4416+version = "1.0.3+wasi-0.2.9"
4417+source = "registry+https://github.com/rust-lang/crates.io-index"
4418+checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
4419+dependencies = [
4420+ "wit-bindgen 0.57.1",
4421+]
4422+
4423+[[package]]
4424+name = "wasip3"
4425+version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
4426+source = "registry+https://github.com/rust-lang/crates.io-index"
4427+checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
4428+dependencies = [
4429+ "wit-bindgen 0.51.0",
4430+]
4431+
4432+[[package]]
4433+name = "wasm-bindgen"
4434+version = "0.2.123"
4435+source = "registry+https://github.com/rust-lang/crates.io-index"
4436+checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563"
4437+dependencies = [
4438+ "cfg-if",
4439+ "once_cell",
4440+ "rustversion",
4441+ "wasm-bindgen-macro",
4442+ "wasm-bindgen-shared",
4443+]
4444+
4445+[[package]]
4446+name = "wasm-bindgen-futures"
4447+version = "0.4.73"
4448+source = "registry+https://github.com/rust-lang/crates.io-index"
4449+checksum = "54568702fabf5d4849ce2b90fadfa64168a097eaf4b351ce9df8b687a0086aaf"
4450+dependencies = [
4451+ "js-sys",
4452+ "wasm-bindgen",
4453+]
4454+
4455+[[package]]
4456+name = "wasm-bindgen-macro"
4457+version = "0.2.123"
4458+source = "registry+https://github.com/rust-lang/crates.io-index"
4459+checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc"
4460+dependencies = [
4461+ "quote",
4462+ "wasm-bindgen-macro-support",
4463+]
4464+
4465+[[package]]
4466+name = "wasm-bindgen-macro-support"
4467+version = "0.2.123"
4468+source = "registry+https://github.com/rust-lang/crates.io-index"
4469+checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b"
4470+dependencies = [
4471+ "bumpalo",
4472+ "proc-macro2",
4473+ "quote",
4474+ "syn",
4475+ "wasm-bindgen-shared",
4476+]
4477+
4478+[[package]]
4479+name = "wasm-bindgen-shared"
4480+version = "0.2.123"
4481+source = "registry+https://github.com/rust-lang/crates.io-index"
4482+checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92"
4483+dependencies = [
4484+ "unicode-ident",
4485+]
4486+
4487+[[package]]
4488+name = "wasm-encoder"
4489+version = "0.244.0"
4490+source = "registry+https://github.com/rust-lang/crates.io-index"
4491+checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
4492+dependencies = [
4493+ "leb128fmt",
4494+ "wasmparser",
4495+]
4496+
4497+[[package]]
4498+name = "wasm-metadata"
4499+version = "0.244.0"
4500+source = "registry+https://github.com/rust-lang/crates.io-index"
4501+checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
4502+dependencies = [
4503+ "anyhow",
4504+ "indexmap",
4505+ "wasm-encoder",
4506+ "wasmparser",
4507+]
4508+
4509+[[package]]
4510+name = "wasmparser"
4511+version = "0.244.0"
4512+source = "registry+https://github.com/rust-lang/crates.io-index"
4513+checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
4514+dependencies = [
4515+ "bitflags",
4516+ "hashbrown 0.15.5",
4517+ "indexmap",
4518+ "semver",
4519+]
4520+
4521+[[package]]
4522+name = "winapi-util"
4523+version = "0.1.11"
4524+source = "registry+https://github.com/rust-lang/crates.io-index"
4525+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
4526+dependencies = [
4527+ "windows-sys",
4528+]
4529+
4530+[[package]]
4531+name = "windows"
4532+version = "0.62.2"
4533+source = "registry+https://github.com/rust-lang/crates.io-index"
4534+checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
4535+dependencies = [
4536+ "windows-collections",
4537+ "windows-core",
4538+ "windows-future",
4539+ "windows-numerics",
4540+]
4541+
4542+[[package]]
4543+name = "windows-collections"
4544+version = "0.3.2"
4545+source = "registry+https://github.com/rust-lang/crates.io-index"
4546+checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
4547+dependencies = [
4548+ "windows-core",
4549+]
4550+
4551+[[package]]
4552+name = "windows-core"
4553+version = "0.62.2"
4554+source = "registry+https://github.com/rust-lang/crates.io-index"
4555+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
4556+dependencies = [
4557+ "windows-implement",
4558+ "windows-interface",
4559+ "windows-link",
4560+ "windows-result",
4561+ "windows-strings",
4562+]
4563+
4564+[[package]]
4565+name = "windows-future"
4566+version = "0.3.2"
4567+source = "registry+https://github.com/rust-lang/crates.io-index"
4568+checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
4569+dependencies = [
4570+ "windows-core",
4571+ "windows-link",
4572+ "windows-threading",
4573+]
4574+
4575+[[package]]
4576+name = "windows-implement"
4577+version = "0.60.2"
4578+source = "registry+https://github.com/rust-lang/crates.io-index"
4579+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
4580+dependencies = [
4581+ "proc-macro2",
4582+ "quote",
4583+ "syn",
4584+]
4585+
4586+[[package]]
4587+name = "windows-interface"
4588+version = "0.59.3"
4589+source = "registry+https://github.com/rust-lang/crates.io-index"
4590+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
4591+dependencies = [
4592+ "proc-macro2",
4593+ "quote",
4594+ "syn",
4595+]
4596+
4597+[[package]]
4598+name = "windows-link"
4599+version = "0.2.1"
4600+source = "registry+https://github.com/rust-lang/crates.io-index"
4601+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
4602+
4603+[[package]]
4604+name = "windows-numerics"
4605+version = "0.3.1"
4606+source = "registry+https://github.com/rust-lang/crates.io-index"
4607+checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
4608+dependencies = [
4609+ "windows-core",
4610+ "windows-link",
4611+]
4612+
4613+[[package]]
4614+name = "windows-result"
4615+version = "0.4.1"
4616+source = "registry+https://github.com/rust-lang/crates.io-index"
4617+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
4618+dependencies = [
4619+ "windows-link",
4620+]
4621+
4622+[[package]]
4623+name = "windows-strings"
4624+version = "0.5.1"
4625+source = "registry+https://github.com/rust-lang/crates.io-index"
4626+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
4627+dependencies = [
4628+ "windows-link",
4629+]
4630+
4631+[[package]]
4632+name = "windows-sys"
4633+version = "0.61.2"
4634+source = "registry+https://github.com/rust-lang/crates.io-index"
4635+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
4636+dependencies = [
4637+ "windows-link",
4638+]
4639+
4640+[[package]]
4641+name = "windows-threading"
4642+version = "0.2.1"
4643+source = "registry+https://github.com/rust-lang/crates.io-index"
4644+checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
4645+dependencies = [
4646+ "windows-link",
4647+]
4648+
4649+[[package]]
4650+name = "winnow"
4651+version = "0.7.15"
4652+source = "registry+https://github.com/rust-lang/crates.io-index"
4653+checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
4654+dependencies = [
4655+ "memchr",
4656+]
4657+
4658+[[package]]
4659+name = "wit-bindgen"
4660+version = "0.51.0"
4661+source = "registry+https://github.com/rust-lang/crates.io-index"
4662+checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
4663+dependencies = [
4664+ "wit-bindgen-rust-macro",
4665+]
4666+
4667+[[package]]
4668+name = "wit-bindgen"
4669+version = "0.57.1"
4670+source = "registry+https://github.com/rust-lang/crates.io-index"
4671+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
4672+
4673+[[package]]
4674+name = "wit-bindgen-core"
4675+version = "0.51.0"
4676+source = "registry+https://github.com/rust-lang/crates.io-index"
4677+checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
4678+dependencies = [
4679+ "anyhow",
4680+ "heck",
4681+ "wit-parser",
4682+]
4683+
4684+[[package]]
4685+name = "wit-bindgen-rust"
4686+version = "0.51.0"
4687+source = "registry+https://github.com/rust-lang/crates.io-index"
4688+checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
4689+dependencies = [
4690+ "anyhow",
4691+ "heck",
4692+ "indexmap",
4693+ "prettyplease",
4694+ "syn",
4695+ "wasm-metadata",
4696+ "wit-bindgen-core",
4697+ "wit-component",
4698+]
4699+
4700+[[package]]
4701+name = "wit-bindgen-rust-macro"
4702+version = "0.51.0"
4703+source = "registry+https://github.com/rust-lang/crates.io-index"
4704+checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
4705+dependencies = [
4706+ "anyhow",
4707+ "prettyplease",
4708+ "proc-macro2",
4709+ "quote",
4710+ "syn",
4711+ "wit-bindgen-core",
4712+ "wit-bindgen-rust",
4713+]
4714+
4715+[[package]]
4716+name = "wit-component"
4717+version = "0.244.0"
4718+source = "registry+https://github.com/rust-lang/crates.io-index"
4719+checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
4720+dependencies = [
4721+ "anyhow",
4722+ "bitflags",
4723+ "indexmap",
4724+ "log",
4725+ "serde",
4726+ "serde_derive",
4727+ "serde_json",
4728+ "wasm-encoder",
4729+ "wasm-metadata",
4730+ "wasmparser",
4731+ "wit-parser",
4732+]
4733+
4734+[[package]]
4735+name = "wit-parser"
4736+version = "0.244.0"
4737+source = "registry+https://github.com/rust-lang/crates.io-index"
4738+checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
4739+dependencies = [
4740+ "anyhow",
4741+ "id-arena",
4742+ "indexmap",
4743+ "log",
4744+ "semver",
4745+ "serde",
4746+ "serde_derive",
4747+ "serde_json",
4748+ "unicode-xid",
4749+ "wasmparser",
4750+]
4751+
4752+[[package]]
4753+name = "writeable"
4754+version = "0.6.3"
4755+source = "registry+https://github.com/rust-lang/crates.io-index"
4756+checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
4757+
4758+[[package]]
4759+name = "yaml-rust"
4760+version = "0.4.5"
4761+source = "registry+https://github.com/rust-lang/crates.io-index"
4762+checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85"
4763+dependencies = [
4764+ "linked-hash-map",
4765+]
4766+
4767+[[package]]
4768+name = "yoke"
4769+version = "0.8.3"
4770+source = "registry+https://github.com/rust-lang/crates.io-index"
4771+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
4772+dependencies = [
4773+ "stable_deref_trait",
4774+ "yoke-derive",
4775+ "zerofrom",
4776+]
4777+
4778+[[package]]
4779+name = "yoke-derive"
4780+version = "0.8.2"
4781+source = "registry+https://github.com/rust-lang/crates.io-index"
4782+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
4783+dependencies = [
4784+ "proc-macro2",
4785+ "quote",
4786+ "syn",
4787+ "synstructure",
4788+]
4789+
4790+[[package]]
4791+name = "zerofrom"
4792+version = "0.1.8"
4793+source = "registry+https://github.com/rust-lang/crates.io-index"
4794+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
4795+dependencies = [
4796+ "zerofrom-derive",
4797+]
4798+
4799+[[package]]
4800+name = "zerofrom-derive"
4801+version = "0.1.7"
4802+source = "registry+https://github.com/rust-lang/crates.io-index"
4803+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
4804+dependencies = [
4805+ "proc-macro2",
4806+ "quote",
4807+ "syn",
4808+ "synstructure",
4809+]
4810+
4811+[[package]]
4812+name = "zeroize"
4813+version = "1.8.2"
4814+source = "registry+https://github.com/rust-lang/crates.io-index"
4815+checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
4816+
4817+[[package]]
4818+name = "zerotrie"
4819+version = "0.2.4"
4820+source = "registry+https://github.com/rust-lang/crates.io-index"
4821+checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
4822+dependencies = [
4823+ "displaydoc",
4824+ "yoke",
4825+ "zerofrom",
4826+]
4827+
4828+[[package]]
4829+name = "zerovec"
4830+version = "0.11.6"
4831+source = "registry+https://github.com/rust-lang/crates.io-index"
4832+checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
4833+dependencies = [
4834+ "yoke",
4835+ "zerofrom",
4836+ "zerovec-derive",
4837+]
4838+
4839+[[package]]
4840+name = "zerovec-derive"
4841+version = "0.11.3"
4842+source = "registry+https://github.com/rust-lang/crates.io-index"
4843+checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
4844+dependencies = [
4845+ "proc-macro2",
4846+ "quote",
4847+ "syn",
4848+]
4849+
4850+[[package]]
4851+name = "zlib-rs"
4852+version = "0.6.3"
4853+source = "registry+https://github.com/rust-lang/crates.io-index"
4854+checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513"
4855+
4856+[[package]]
4857+name = "zmij"
4858+version = "1.0.21"
4859+source = "registry+https://github.com/rust-lang/crates.io-index"
4860+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
addedCargo.toml+51 −0
1+[workspace]
2+resolver = "2"
3+members = [
4+ "crates/*",
5+ "vendor/gitserver-core",
6+]
7+
8+[workspace.package]
9+version = "0.0.0"
10+edition = "2024"
11+license = "MIT OR Apache-2.0"
12+repository = "https://github.com/richardscollin/anvil"
13+rust-version = "1.95"
14+
15+[workspace.dependencies]
16+# Internal crates
17+anvil-core = { path = "crates/anvil-core" }
18+anvil-git = { path = "crates/anvil-git" }
19+anvil-web = { path = "crates/anvil-web" }
20+anvil-ssh = { path = "crates/anvil-ssh" }
21+
22+anyhow = "1"
23+argon2 = { version = "0.5", features = ["std"] }
24+axum = "0.8"
25+axum-extra = { version = "0.10", features = ["cookie"] }
26+base64 = "0.22"
27+clap = { version = "4", features = ["derive"] }
28+gitserver-core = { path = "vendor/gitserver-core" }
29+gix = { version = "0.84", default-features = false, features = ["sha1", "max-performance-safe", "blob-diff", "revision"] }
30+gix-pack = { version = "0.71", features = ["sha1"] }
31+maud = { version = "0.27", features = ["axum"] }
32+rand = "0.10"
33+serde = { version = "1", features = ["derive"] }
34+similar = "2"
35+syntect = { version = "5", default-features = false, features = ["default-fancy"] }
36+thiserror = "2"
37+time = { version = "0.3", features = ["serde", "formatting"] }
38+toasty = { version = "0.7", features = ["sqlite"] }
39+tokio = { version = "1", features = ["full"] }
40+tokio-util = { version = "0.7", features = ["io"] }
41+toml = "0.8"
42+tower = "0.5"
43+tower-http = { version = "0.6", features = ["trace", "fs"] }
44+tracing = "0.1"
45+tracing-subscriber = { version = "0.3", features = ["env-filter"] }
46+
47+# ssh
48+russh = "0.61"
49+# ssh-key parsing/fingerprinting, shared by anvil-core (storage) and anvil-ssh
50+# (auth). Pinned to match russh's transitive ssh-key so fingerprints agree.
51+ssh-key = "0.7.0-rc.10"
addedREADME.md+41 −0
1+# anvil
2+
3+A minimal self-hosted git forge in Rust, built on [gix (gitoxide)](https://github.com/GitoxideLabs/gitoxide).
4+Modeled on Gitea's *basic* feature set: repository hosting, web code browsing,
5+the git smart protocols (HTTP and SSH), issues, pull requests, users/orgs with
6+access control, and webhooks. No wiki, no CI/CD.
7+
8+## Status
9+
10+Early development. Milestone 1 (workspace scaffold + storage) is in place:
11+`anvild` can initialize its database and create users and bare repositories on
12+disk. The git wire protocol, web UI, and SSH transport follow.
13+
14+## Architecture
15+
16+A Cargo workspace. The keystone is **`anvil-git`**: gix is a *client* library
17+with no server-side protocol, so anvil supplies its own transport-agnostic
18+`upload-pack`/`receive-pack` (smart-HTTP and SSH share one implementation).
19+
20+| Crate | Responsibility |
21+|--------------|----------------|
22+| `anvil-core` | Domain model, SQLite persistence, on-disk bare-repo storage |
23+| `anvil-git` | Server-side git wire protocol on gix (upstream-candidate) |
24+| `anvil-web` | axum HTTP server: web UI + smart-HTTP git endpoints |
25+| `anvil-ssh` | git-over-SSH (russh) |
26+| `anvil` | `anvild` daemon + admin CLI |
27+
28+## Quick start
29+
30+```sh
31+cargo run -- migrate # create data/ + database
32+cargo run -- user create alice --password secret # create a user
33+cargo run -- repo create alice/hello # create a bare repo on disk
34+cargo run -- serve # start the server
35+```
36+
37+Configuration is optional; see [`anvil.example.toml`](anvil.example.toml).
38+
39+## License
40+
41+MIT OR Apache-2.0.
addedTODO.md+6 −0
1+- [ ] don't expose a users email on their profile page
2+- [ ] implement github action style CI feature
3+ - I want to get this to a sufficient state that we could bootstrap this app using this CI and automate deployments if we wished
4+ - probably might want to have other isolated anvil workers or something running for CI jobs
5+- [ ] implement github pages style hosting feature
6+- [ ] security audit
addedanvil.example.toml+19 −0
1+# anvil configuration. Copy to `anvil.toml` and edit. All fields are optional;
2+# omitted fields fall back to the defaults shown here.
3+
4+# Root directory for all server state (database + repositories).
5+data_dir = "data"
6+
7+[http]
8+listen = "127.0.0.1:3000"
9+base_url = "http://localhost:3000"
10+
11+[ssh]
12+enabled = false
13+# Internal bind address. Under Docker, set host = "0.0.0.0" and forward the port.
14+listen = "127.0.0.1:2222"
15+# What to show users in SSH clone URLs. Set clone_port to the externally
16+# forwarded port if it differs from the internal bind (e.g. Docker -p 2200:2222).
17+clone_host = "localhost"
18+clone_port = 2222
19+clone_user = "git"
addedcrates/anvil-core/Cargo.toml+22 −0
1+[package]
2+name = "anvil-core"
3+version.workspace = true
4+edition.workspace = true
5+license.workspace = true
6+repository.workspace = true
7+rust-version.workspace = true
8+description = "Domain model, persistence, and on-disk repository storage for the anvil git forge."
9+
10+[dependencies]
11+gix.workspace = true
12+toasty.workspace = true
13+argon2.workspace = true
14+ssh-key.workspace = true
15+serde.workspace = true
16+toml.workspace = true
17+thiserror.workspace = true
18+tracing.workspace = true
19+
20+[dev-dependencies]
21+tokio = { workspace = true }
22+tempfile = "3"
addedcrates/anvil-core/src/access.rs+23 −0
1+//! Authorization rules for repositories.
2+//!
3+//! The model is deliberately simple, matching a personal / small-team forge:
4+//! a repo is owned by one user; private repos are visible only to their owner;
5+//! writes (push) and settings changes are owner-only. Admins may do anything.
6+
7+use crate::models::{
8+ Repository,
9+ User,
10+};
11+
12+/// Whether `viewer` may read (browse / clone) `repo`.
13+pub fn can_read(repo: &Repository, viewer: Option<&User>) -> bool {
14+ !repo.is_private || can_write(repo, viewer)
15+}
16+
17+/// Whether `viewer` may write (push) to / administer `repo`.
18+pub fn can_write(repo: &Repository, viewer: Option<&User>) -> bool {
19+ match viewer {
20+ Some(user) => user.id == repo.owner_id || user.is_admin,
21+ None => false,
22+ }
23+}
addedcrates/anvil-core/src/config.rs+145 −0
1+//! Server configuration: loaded from a TOML file with sensible defaults.
2+
3+use std::path::{
4+ Path,
5+ PathBuf,
6+};
7+
8+use serde::{
9+ Deserialize,
10+ Serialize,
11+};
12+
13+use crate::error::{
14+ Error,
15+ Result,
16+};
17+
18+/// Top-level anvil configuration.
19+///
20+/// Load with [`Config::load`] (from a TOML file) or [`Config::default`].
21+#[derive(Debug, Clone, Serialize, Deserialize)]
22+#[serde(default)]
23+pub struct Config {
24+ /// Root directory holding all server state (database + repositories).
25+ pub data_dir: PathBuf,
26+ /// HTTP server settings.
27+ pub http: HttpConfig,
28+ /// SSH server settings.
29+ pub ssh: SshConfig,
30+}
31+
32+#[derive(Debug, Clone, Serialize, Deserialize)]
33+#[serde(default)]
34+pub struct HttpConfig {
35+ /// Address the HTTP server binds to, e.g. `127.0.0.1:3000`.
36+ pub listen: String,
37+ /// Externally visible base URL, used when constructing clone URLs.
38+ pub base_url: String,
39+}
40+
41+#[derive(Debug, Clone, Serialize, Deserialize)]
42+#[serde(default)]
43+pub struct SshConfig {
44+ /// Whether the SSH git transport is enabled.
45+ pub enabled: bool,
46+ /// Address the SSH server binds to internally, e.g. `0.0.0.0:2222`. Under
47+ /// Docker this is the in-container bind, which may differ from the
48+ /// externally forwarded port — see the `clone_*` fields below.
49+ pub listen: String,
50+ /// Hostname shown in SSH clone URLs (what users actually connect to).
51+ pub clone_host: String,
52+ /// Port shown in SSH clone URLs. Set this to the *externally forwarded*
53+ /// port when it differs from the internal bind (e.g. Docker `-p 2200:2222`).
54+ pub clone_port: u16,
55+ /// Username shown in SSH clone URLs (conventionally `git`).
56+ pub clone_user: String,
57+}
58+
59+impl Default for Config {
60+ fn default() -> Self {
61+ Self {
62+ data_dir: PathBuf::from("data"),
63+ http: HttpConfig::default(),
64+ ssh: SshConfig::default(),
65+ }
66+ }
67+}
68+
69+impl Default for HttpConfig {
70+ fn default() -> Self {
71+ Self {
72+ listen: "127.0.0.1:3000".to_string(),
73+ base_url: "http://localhost:3000".to_string(),
74+ }
75+ }
76+}
77+
78+impl Default for SshConfig {
79+ fn default() -> Self {
80+ Self {
81+ enabled: false,
82+ listen: "127.0.0.1:2222".to_string(),
83+ clone_host: "localhost".to_string(),
84+ clone_port: 2222,
85+ clone_user: "git".to_string(),
86+ }
87+ }
88+}
89+
90+impl Config {
91+ /// Load configuration from a TOML file. Missing fields fall back to defaults.
92+ pub fn load(path: impl AsRef<Path>) -> Result<Self> {
93+ let path = path.as_ref();
94+ let text = std::fs::read_to_string(path)
95+ .map_err(|e| Error::Config(format!("reading {}: {e}", path.display())))?;
96+ toml::from_str(&text).map_err(|e| Error::Config(format!("parsing {}: {e}", path.display())))
97+ }
98+
99+ /// Load from `path` if it exists, otherwise return defaults.
100+ pub fn load_or_default(path: impl AsRef<Path>) -> Result<Self> {
101+ let path = path.as_ref();
102+ if path.exists() {
103+ Self::load(path)
104+ } else {
105+ Ok(Self::default())
106+ }
107+ }
108+
109+ /// Filesystem path to the SQLite database file.
110+ pub fn database_path(&self) -> PathBuf {
111+ self.data_dir.join("anvil.db")
112+ }
113+
114+ /// Root directory under which bare repositories are stored.
115+ pub fn repositories_dir(&self) -> PathBuf {
116+ self.data_dir.join("repositories")
117+ }
118+
119+ /// The HTTP clone URL for `<owner>/<name>`, e.g.
120+ /// `http://localhost:3000/alice/hello.git`.
121+ pub fn http_clone_url(&self, owner: &str, name: &str) -> String {
122+ format!(
123+ "{}/{owner}/{name}.git",
124+ self.http.base_url.trim_end_matches('/')
125+ )
126+ }
127+
128+ /// The SSH clone URL for `<owner>/<name>`, using the externally advertised
129+ /// host/port/user (which may differ from the internal bind under Docker).
130+ /// The port is omitted when it is the SSH default (22).
131+ pub fn ssh_clone_url(&self, owner: &str, name: &str) -> String {
132+ let ssh = &self.ssh;
133+ if ssh.clone_port == 22 {
134+ format!(
135+ "ssh://{}@{}/{owner}/{name}.git",
136+ ssh.clone_user, ssh.clone_host
137+ )
138+ } else {
139+ format!(
140+ "ssh://{}@{}:{}/{owner}/{name}.git",
141+ ssh.clone_user, ssh.clone_host, ssh.clone_port
142+ )
143+ }
144+ }
145+}
addedcrates/anvil-core/src/db.rs+36 −0
1+//! Database connection and schema setup via Toasty.
2+
3+use std::path::Path;
4+
5+use crate::error::Result;
6+use crate::models::{
7+ Repository,
8+ Session,
9+ SshKey,
10+ User,
11+};
12+
13+/// Open (creating if necessary) the SQLite database at `path`, creating the
14+/// schema on first run.
15+///
16+/// Note: Toasty 0.7's `push_schema` emits plain `CREATE TABLE` (no
17+/// `IF NOT EXISTS`) for model tables, so it is *not* idempotent — calling it on
18+/// an existing database errors. We therefore only push the schema when the
19+/// database file does not yet exist. Skipping it on an existing database issues
20+/// no DDL, so data is never touched. Schema evolution will move to Toasty's
21+/// migration system when models start changing.
22+pub async fn connect(path: impl AsRef<Path>) -> Result<toasty::Db> {
23+ let path = path.as_ref();
24+ let fresh = !path.exists();
25+ let url = format!("sqlite:{}", path.display());
26+
27+ let db = toasty::Db::builder()
28+ .models(toasty::models!(User, Repository, SshKey, Session))
29+ .connect(&url)
30+ .await?;
31+
32+ if fresh {
33+ db.push_schema().await?;
34+ }
35+ Ok(db)
36+}
addedcrates/anvil-core/src/error.rs+32 −0
1+//! Crate-wide error type.
2+
3+/// Errors produced by the core domain and storage layers.
4+#[derive(Debug, thiserror::Error)]
5+pub enum Error {
6+ #[error("database error: {0}")]
7+ Db(#[from] toasty::Error),
8+
9+ #[error("repository storage error: {0}")]
10+ Storage(String),
11+
12+ #[error("password hashing error: {0}")]
13+ Password(String),
14+
15+ #[error("config error: {0}")]
16+ Config(String),
17+
18+ #[error("not found: {0}")]
19+ NotFound(String),
20+
21+ #[error("already exists: {0}")]
22+ AlreadyExists(String),
23+
24+ #[error("invalid input: {0}")]
25+ Invalid(String),
26+
27+ #[error(transparent)]
28+ Io(#[from] std::io::Error),
29+}
30+
31+/// Convenience alias used throughout the crate.
32+pub type Result<T> = std::result::Result<T, Error>;
addedcrates/anvil-core/src/lib.rs+60 −0
1+//! Core domain model, persistence, and on-disk repository storage for anvil.
2+//!
3+//! This crate is transport-agnostic: it knows about users, repositories, the
4+//! SQLite database, and bare git repositories on disk, but nothing about HTTP,
5+//! SSH, or the git wire protocol. Higher layers (`anvil-web`, `anvil-ssh`,
6+//! `anvil-git`) build on top of it.
7+
8+pub mod access;
9+pub mod config;
10+pub mod db;
11+pub mod error;
12+pub mod models;
13+pub mod repos;
14+pub mod sessions;
15+pub mod ssh_keys;
16+pub mod storage;
17+pub mod users;
18+
19+pub use config::Config;
20+pub use error::{
21+ Error,
22+ Result,
23+};
24+pub use models::{
25+ Repository,
26+ Session,
27+ SshKey,
28+ User,
29+};
30+
31+/// Current Unix time in seconds, for `created_at` columns.
32+pub(crate) fn now() -> i64 {
33+ std::time::SystemTime::now()
34+ .duration_since(std::time::UNIX_EPOCH)
35+ .map(|d| d.as_secs() as i64)
36+ .unwrap_or(0)
37+}
38+
39+/// Shared application state: configuration plus a database handle.
40+///
41+/// Cloneable and cheap to pass around — `toasty::Db` is internally reference
42+/// counted and backed by a connection pool.
43+#[derive(Clone)]
44+pub struct App {
45+ pub config: Config,
46+ pub db: toasty::Db,
47+}
48+
49+impl App {
50+ /// Initialize application state from a config: ensure the data directories
51+ /// exist, then open the database and create the schema.
52+ pub async fn bootstrap(config: Config) -> Result<Self> {
53+ std::fs::create_dir_all(&config.data_dir)?;
54+ std::fs::create_dir_all(config.repositories_dir())?;
55+
56+ let db = db::connect(config.database_path()).await?;
57+
58+ Ok(Self { config, db })
59+ }
60+}
addedcrates/anvil-core/src/models.rs+67 −0
1+//! Persisted domain types, defined as Toasty models. Tables are created from
2+//! these definitions via [`crate::db`]'s `push_schema`.
3+//!
4+//! Foreign keys are kept as plain scalar fields (`owner_id`, `user_id`) and
5+//! queried explicitly, rather than declaring Toasty relations — simpler and a
6+//! good fit for our small schema.
7+
8+/// A registered user account.
9+#[derive(Debug, toasty::Model)]
10+pub struct User {
11+ #[key]
12+ #[auto]
13+ pub id: i64,
14+ #[unique]
15+ pub username: String,
16+ pub email: String,
17+ /// Argon2 PHC-format password hash.
18+ pub password_hash: String,
19+ pub is_admin: bool,
20+ /// Unix timestamp (seconds) of account creation.
21+ pub created_at: i64,
22+}
23+
24+/// A hosted repository, owned by a [`User`].
25+#[derive(Debug, toasty::Model)]
26+pub struct Repository {
27+ #[key]
28+ #[auto]
29+ pub id: i64,
30+ #[index]
31+ pub owner_id: i64,
32+ pub name: String,
33+ pub description: String,
34+ pub is_private: bool,
35+ /// Short name of the default branch, e.g. `main`.
36+ pub default_branch: String,
37+ pub created_at: i64,
38+}
39+
40+/// A web login session, keyed by an opaque random token stored in a cookie.
41+#[derive(Debug, toasty::Model)]
42+pub struct Session {
43+ #[key]
44+ pub token: String,
45+ #[index]
46+ pub user_id: i64,
47+ pub created_at: i64,
48+ /// Unix timestamp (seconds) after which the session is invalid.
49+ pub expires_at: i64,
50+}
51+
52+/// A registered SSH public key, used to authenticate git-over-SSH connections.
53+#[derive(Debug, toasty::Model)]
54+pub struct SshKey {
55+ #[key]
56+ #[auto]
57+ pub id: i64,
58+ #[index]
59+ pub user_id: i64,
60+ pub title: String,
61+ /// Canonical SHA256 fingerprint, e.g. `SHA256:…`.
62+ #[unique]
63+ pub fingerprint: String,
64+ /// Normalized OpenSSH public-key line.
65+ pub content: String,
66+ pub created_at: i64,
67+}
addedcrates/anvil-core/src/repos.rs+156 −0
1+//! Repository records: creation (DB row + on-disk bare repo) and lookup.
2+
3+use std::path::Path;
4+
5+use crate::error::{
6+ Error,
7+ Result,
8+};
9+use crate::models::{
10+ Repository,
11+ User,
12+};
13+use crate::storage;
14+
15+/// A repository joined with its owner's username, for listing pages.
16+pub struct RepoWithOwner {
17+ pub owner_id: i64,
18+ pub owner: String,
19+ pub name: String,
20+ pub description: String,
21+ pub is_private: bool,
22+ pub default_branch: String,
23+}
24+
25+/// Create a repository owned by `owner`: insert the database row and initialize
26+/// the bare repository on disk. If the on-disk init fails, the row is rolled
27+/// back (deleted) so no orphan remains.
28+pub async fn create(
29+ db: &toasty::Db,
30+ repositories_dir: &Path,
31+ owner: &User,
32+ name: &str,
33+ description: &str,
34+ is_private: bool,
35+) -> Result<Repository> {
36+ validate_name(name)?;
37+ if find(db, owner.id, name).await?.is_some() {
38+ return Err(Error::AlreadyExists(format!(
39+ "repository {}/{name}",
40+ owner.username
41+ )));
42+ }
43+
44+ let mut conn = db.clone();
45+ let repo = toasty::create!(Repository {
46+ owner_id: owner.id,
47+ name: name,
48+ description: description,
49+ is_private: is_private,
50+ default_branch: "main",
51+ created_at: crate::now(),
52+ })
53+ .exec(&mut conn)
54+ .await?;
55+
56+ // Create the bare repo on disk; on failure, remove the row we just wrote.
57+ if let Err(e) = storage::create_bare(
58+ repositories_dir,
59+ &owner.username,
60+ name,
61+ &repo.default_branch,
62+ ) {
63+ let _ = repo.delete().exec(&mut conn).await;
64+ return Err(e);
65+ }
66+ Ok(repo)
67+}
68+
69+/// Find a repository by its id.
70+pub async fn find_by_id(db: &toasty::Db, id: i64) -> Result<Option<Repository>> {
71+ let mut db = db.clone();
72+ let repo = Repository::filter(Repository::fields().id().eq(id))
73+ .first()
74+ .exec(&mut db)
75+ .await?;
76+ Ok(repo)
77+}
78+
79+/// Update a repository's description and visibility.
80+pub async fn update_settings(
81+ db: &toasty::Db,
82+ repo_id: i64,
83+ description: &str,
84+ is_private: bool,
85+) -> Result<()> {
86+ let mut conn = db.clone();
87+ let Some(mut repo) = Repository::filter(Repository::fields().id().eq(repo_id))
88+ .first()
89+ .exec(&mut conn)
90+ .await?
91+ else {
92+ return Err(Error::NotFound(format!("repository id {repo_id}")));
93+ };
94+ let mut conn = db.clone();
95+ repo.update()
96+ .description(description)
97+ .is_private(is_private)
98+ .exec(&mut conn)
99+ .await?;
100+ Ok(())
101+}
102+
103+/// Find a repository by owner id and name.
104+pub async fn find(db: &toasty::Db, owner_id: i64, name: &str) -> Result<Option<Repository>> {
105+ let mut db = db.clone();
106+ let repo = Repository::filter(Repository::fields().owner_id().eq(owner_id))
107+ .filter(Repository::fields().name().eq(name))
108+ .first()
109+ .exec(&mut db)
110+ .await?;
111+ Ok(repo)
112+}
113+
114+/// List all repositories owned by `owner_id`.
115+pub async fn list_by_owner(db: &toasty::Db, owner_id: i64) -> Result<Vec<Repository>> {
116+ let mut db = db.clone();
117+ let repos = Repository::filter(Repository::fields().owner_id().eq(owner_id))
118+ .exec(&mut db)
119+ .await?;
120+ Ok(repos)
121+}
122+
123+/// List all repositories with their owner usernames, ordered by owner then name.
124+pub async fn list_all_with_owner(db: &toasty::Db) -> Result<Vec<RepoWithOwner>> {
125+ let mut conn = db.clone();
126+ let users = User::all().exec(&mut conn).await?;
127+ let owner_name: std::collections::HashMap<i64, String> =
128+ users.into_iter().map(|u| (u.id, u.username)).collect();
129+
130+ let mut conn = db.clone();
131+ let repos = Repository::all().exec(&mut conn).await?;
132+ let mut out: Vec<RepoWithOwner> = repos
133+ .into_iter()
134+ .map(|r| RepoWithOwner {
135+ owner: owner_name.get(&r.owner_id).cloned().unwrap_or_default(),
136+ owner_id: r.owner_id,
137+ name: r.name,
138+ description: r.description,
139+ is_private: r.is_private,
140+ default_branch: r.default_branch,
141+ })
142+ .collect();
143+ out.sort_by(|a, b| (&a.owner, &a.name).cmp(&(&b.owner, &b.name)));
144+ Ok(out)
145+}
146+
147+/// Reject names that are empty or contain path separators / traversal.
148+fn validate_name(name: &str) -> Result<()> {
149+ if name.is_empty() {
150+ return Err(Error::Invalid("repository name must not be empty".into()));
151+ }
152+ if name.contains('/') || name.contains('\\') || name.contains("..") {
153+ return Err(Error::Invalid(format!("invalid repository name: {name:?}")));
154+ }
155+ Ok(())
156+}
addedcrates/anvil-core/src/sessions.rs+79 −0
1+//! Web login sessions: opaque random tokens stored server-side and referenced
2+//! by a cookie.
3+
4+use argon2::password_hash::rand_core::{
5+ OsRng,
6+ RngCore,
7+};
8+
9+use crate::error::Result;
10+use crate::models::{
11+ Session,
12+ User,
13+};
14+
15+/// Default session lifetime: 30 days.
16+const SESSION_TTL_SECS: i64 = 60 * 60 * 24 * 30;
17+
18+/// Generate a 256-bit random session token, hex-encoded.
19+fn generate_token() -> String {
20+ let mut bytes = [0u8; 32];
21+ let mut rng = OsRng;
22+ rng.fill_bytes(&mut bytes);
23+ bytes.iter().map(|b| format!("{b:02x}")).collect()
24+}
25+
26+/// Create a new session for `user_id`, returning it (the `token` goes in a cookie).
27+pub async fn create(db: &toasty::Db, user_id: i64) -> Result<Session> {
28+ let mut conn = db.clone();
29+ let now = crate::now();
30+ let session = toasty::create!(Session {
31+ token: generate_token(),
32+ user_id: user_id,
33+ created_at: now,
34+ expires_at: now + SESSION_TTL_SECS,
35+ })
36+ .exec(&mut conn)
37+ .await?;
38+ Ok(session)
39+}
40+
41+/// Resolve a session token to its user, or `None` if missing/expired.
42+/// Expired sessions are deleted as a side effect.
43+pub async fn lookup_user(db: &toasty::Db, token: &str) -> Result<Option<User>> {
44+ let mut conn = db.clone();
45+ let Some(session) = Session::filter(Session::fields().token().eq(token))
46+ .first()
47+ .exec(&mut conn)
48+ .await?
49+ else {
50+ return Ok(None);
51+ };
52+
53+ if session.expires_at < crate::now() {
54+ let mut conn = db.clone();
55+ let _ = session.delete().exec(&mut conn).await;
56+ return Ok(None);
57+ }
58+
59+ let mut conn = db.clone();
60+ let user = User::filter(User::fields().id().eq(session.user_id))
61+ .first()
62+ .exec(&mut conn)
63+ .await?;
64+ Ok(user)
65+}
66+
67+/// Delete a session (logout). No-op if the token is unknown.
68+pub async fn delete(db: &toasty::Db, token: &str) -> Result<()> {
69+ let mut conn = db.clone();
70+ if let Some(session) = Session::filter(Session::fields().token().eq(token))
71+ .first()
72+ .exec(&mut conn)
73+ .await?
74+ {
75+ let mut conn = db.clone();
76+ session.delete().exec(&mut conn).await?;
77+ }
78+ Ok(())
79+}
addedcrates/anvil-core/src/ssh_keys.rs+98 −0
1+//! SSH public keys: parsing, registration, and lookup for git-over-SSH
2+//! authentication.
3+
4+use ssh_key::{
5+ HashAlg,
6+ PublicKey,
7+};
8+
9+use crate::error::{
10+ Error,
11+ Result,
12+};
13+use crate::models::SshKey;
14+
15+/// Parse an OpenSSH public-key line (`ssh-ed25519 AAAA… comment`) into its
16+/// canonical SHA256 fingerprint and normalized key line, for registration.
17+pub fn parse_public_key(openssh: &str) -> Result<(String, String)> {
18+ let key = PublicKey::from_openssh(openssh.trim())
19+ .map_err(|e| Error::Invalid(format!("invalid ssh public key: {e}")))?;
20+ let fingerprint = key.fingerprint(HashAlg::Sha256).to_string();
21+ let normalized = key
22+ .to_openssh()
23+ .map_err(|e| Error::Invalid(format!("encoding ssh public key: {e}")))?;
24+ Ok((fingerprint, normalized))
25+}
26+
27+/// Register an SSH public key for a user.
28+///
29+/// `fingerprint` must be the canonical SHA256 fingerprint and `content` the
30+/// normalized OpenSSH key line. Returns [`Error::AlreadyExists`] if the
31+/// fingerprint is already registered.
32+pub async fn add(
33+ db: &toasty::Db,
34+ user_id: i64,
35+ title: &str,
36+ fingerprint: &str,
37+ content: &str,
38+) -> Result<SshKey> {
39+ if find_by_fingerprint(db, fingerprint).await?.is_some() {
40+ return Err(Error::AlreadyExists(format!("ssh key {fingerprint}")));
41+ }
42+ let mut db = db.clone();
43+ let key = toasty::create!(SshKey {
44+ user_id: user_id,
45+ title: title,
46+ fingerprint: fingerprint,
47+ content: content,
48+ created_at: crate::now(),
49+ })
50+ .exec(&mut db)
51+ .await?;
52+ Ok(key)
53+}
54+
55+/// Look up a key by its fingerprint.
56+pub async fn find_by_fingerprint(db: &toasty::Db, fingerprint: &str) -> Result<Option<SshKey>> {
57+ let mut db = db.clone();
58+ let key = SshKey::filter(SshKey::fields().fingerprint().eq(fingerprint))
59+ .first()
60+ .exec(&mut db)
61+ .await?;
62+ Ok(key)
63+}
64+
65+/// Find the user id that owns the key with this fingerprint, if any.
66+pub async fn find_user_id_by_fingerprint(
67+ db: &toasty::Db,
68+ fingerprint: &str,
69+) -> Result<Option<i64>> {
70+ Ok(find_by_fingerprint(db, fingerprint)
71+ .await?
72+ .map(|k| k.user_id))
73+}
74+
75+/// Delete one of `user_id`'s keys by id. No-op if the key is missing or owned
76+/// by someone else.
77+pub async fn delete(db: &toasty::Db, id: i64, user_id: i64) -> Result<()> {
78+ let mut conn = db.clone();
79+ if let Some(key) = SshKey::filter(SshKey::fields().id().eq(id))
80+ .first()
81+ .exec(&mut conn)
82+ .await?
83+ && key.user_id == user_id
84+ {
85+ let mut conn = db.clone();
86+ key.delete().exec(&mut conn).await?;
87+ }
88+ Ok(())
89+}
90+
91+/// List a user's registered SSH keys.
92+pub async fn list_by_user(db: &toasty::Db, user_id: i64) -> Result<Vec<SshKey>> {
93+ let mut db = db.clone();
94+ let keys = SshKey::filter(SshKey::fields().user_id().eq(user_id))
95+ .exec(&mut db)
96+ .await?;
97+ Ok(keys)
98+}
addedcrates/anvil-core/src/storage.rs+87 −0
1+//! On-disk repository storage.
2+//!
3+//! Repositories are bare git repositories laid out as
4+//! `<repositories_dir>/<owner>/<name>.git`. This module owns the mapping
5+//! between forge identities and filesystem paths, plus creation/opening via
6+//! `gix`. No database access happens here.
7+
8+use std::path::{
9+ Path,
10+ PathBuf,
11+};
12+
13+use crate::error::{
14+ Error,
15+ Result,
16+};
17+
18+/// Compute the on-disk path of a bare repository.
19+pub fn repo_path(repositories_dir: &Path, owner: &str, name: &str) -> PathBuf {
20+ repositories_dir.join(owner).join(format!("{name}.git"))
21+}
22+
23+/// Create a new bare repository on disk, returning the opened handle.
24+///
25+/// `HEAD` is pointed at `refs/heads/<default_branch>` so the on-disk repository
26+/// agrees with the forge's recorded default branch (gix otherwise defaults to
27+/// whatever `init.defaultBranch` resolves to, often `master`).
28+///
29+/// Fails if a repository already exists at the target path. The owner
30+/// directory is created as needed.
31+pub fn create_bare(
32+ repositories_dir: &Path,
33+ owner: &str,
34+ name: &str,
35+ default_branch: &str,
36+) -> Result<gix::Repository> {
37+ let path = repo_path(repositories_dir, owner, name);
38+ if path.exists() {
39+ return Err(Error::AlreadyExists(format!(
40+ "repository on disk at {}",
41+ path.display()
42+ )));
43+ }
44+ if let Some(parent) = path.parent() {
45+ std::fs::create_dir_all(parent)?;
46+ }
47+ let repo = gix::init_bare(&path)
48+ .map_err(|e| Error::Storage(format!("init bare {}: {e}", path.display())))?;
49+ set_head_branch(&repo, default_branch)?;
50+ Ok(repo)
51+}
52+
53+/// Point `HEAD` at `refs/heads/<branch>` as a symbolic reference.
54+fn set_head_branch(repo: &gix::Repository, branch: &str) -> Result<()> {
55+ use gix::refs::Target;
56+ use gix::refs::transaction::{
57+ Change,
58+ LogChange,
59+ PreviousValue,
60+ RefEdit,
61+ };
62+
63+ let target_name: gix::refs::FullName = format!("refs/heads/{branch}")
64+ .try_into()
65+ .map_err(|e| Error::Storage(format!("invalid branch name {branch:?}: {e}")))?;
66+ let head_name: gix::refs::FullName = "HEAD"
67+ .try_into()
68+ .map_err(|e| Error::Storage(format!("HEAD ref name: {e}")))?;
69+
70+ repo.edit_reference(RefEdit {
71+ change: Change::Update {
72+ log: LogChange::default(),
73+ expected: PreviousValue::Any,
74+ new: Target::Symbolic(target_name),
75+ },
76+ name: head_name,
77+ deref: false,
78+ })
79+ .map_err(|e| Error::Storage(format!("set HEAD to {branch}: {e}")))?;
80+ Ok(())
81+}
82+
83+/// Open an existing bare repository.
84+pub fn open(repositories_dir: &Path, owner: &str, name: &str) -> Result<gix::Repository> {
85+ let path = repo_path(repositories_dir, owner, name);
86+ gix::open(&path).map_err(|e| Error::Storage(format!("open {}: {e}", path.display())))
87+}
addedcrates/anvil-core/src/users.rs+112 −0
1+//! User accounts: creation, lookup, and password hashing.
2+
3+use argon2::Argon2;
4+use argon2::password_hash::rand_core::OsRng;
5+use argon2::password_hash::{
6+ PasswordHash,
7+ PasswordHasher,
8+ PasswordVerifier,
9+ SaltString,
10+};
11+
12+use crate::error::{
13+ Error,
14+ Result,
15+};
16+use crate::models::User;
17+
18+/// Usernames reserved for system use — they collide with (or could be confused
19+/// for) top-level routes such as the `/-/…` namespace.
20+const RESERVED_USERNAMES: &[&str] = &[
21+ "-",
22+ "new",
23+ "settings",
24+ "login",
25+ "logout",
26+ "static",
27+ "healthz",
28+ "admin",
29+ "api",
30+ "about",
31+ "help",
32+ "assets",
33+ "favicon.ico",
34+ "robots.txt",
35+];
36+
37+/// Hash a plaintext password into a PHC-format Argon2 string.
38+pub fn hash_password(password: &str) -> Result<String> {
39+ let salt = SaltString::generate(&mut OsRng);
40+ Argon2::default()
41+ .hash_password(password.as_bytes(), &salt)
42+ .map(|h| h.to_string())
43+ .map_err(|e| Error::Password(e.to_string()))
44+}
45+
46+/// Verify a plaintext password against a stored PHC hash.
47+pub fn verify_password(hash: &str, password: &str) -> Result<bool> {
48+ let parsed = PasswordHash::new(hash).map_err(|e| Error::Password(e.to_string()))?;
49+ Ok(Argon2::default()
50+ .verify_password(password.as_bytes(), &parsed)
51+ .is_ok())
52+}
53+
54+/// Create a new user, hashing `password` before storage.
55+///
56+/// Returns [`Error::AlreadyExists`] if the username is taken.
57+pub async fn create(
58+ db: &toasty::Db,
59+ username: &str,
60+ email: &str,
61+ password: &str,
62+ is_admin: bool,
63+) -> Result<User> {
64+ if username.trim().is_empty() {
65+ return Err(Error::Invalid("username must not be empty".into()));
66+ }
67+ // Usernames live in the URL root namespace (e.g. `/<username>`) and on disk
68+ // under `repositories/<username>/`. Reject path-unsafe characters and names
69+ // reserved for system routes (the `/-/…` prefix is reserved structurally,
70+ // but we keep a denylist as defense-in-depth).
71+ if username.contains('/') || username.contains('\\') || username.contains("..") {
72+ return Err(Error::Invalid(format!("invalid username: {username:?}")));
73+ }
74+ if RESERVED_USERNAMES.contains(&username.to_ascii_lowercase().as_str()) {
75+ return Err(Error::Invalid(format!("username '{username}' is reserved")));
76+ }
77+ if find_by_username(db, username).await?.is_some() {
78+ return Err(Error::AlreadyExists(format!("user {username}")));
79+ }
80+
81+ let mut db = db.clone();
82+ let user = toasty::create!(User {
83+ username: username,
84+ email: email,
85+ password_hash: hash_password(password)?,
86+ is_admin: is_admin,
87+ created_at: crate::now(),
88+ })
89+ .exec(&mut db)
90+ .await?;
91+ Ok(user)
92+}
93+
94+/// Look up a user by id.
95+pub async fn find_by_id(db: &toasty::Db, id: i64) -> Result<Option<User>> {
96+ let mut db = db.clone();
97+ let user = User::filter(User::fields().id().eq(id))
98+ .first()
99+ .exec(&mut db)
100+ .await?;
101+ Ok(user)
102+}
103+
104+/// Look up a user by exact username.
105+pub async fn find_by_username(db: &toasty::Db, username: &str) -> Result<Option<User>> {
106+ let mut db = db.clone();
107+ let user = User::filter(User::fields().username().eq(username))
108+ .first()
109+ .exec(&mut db)
110+ .await?;
111+ Ok(user)
112+}
addedcrates/anvil-git/Cargo.toml+16 −0
1+[package]
2+name = "anvil-git"
3+version.workspace = true
4+edition.workspace = true
5+license.workspace = true
6+repository.workspace = true
7+rust-version.workspace = true
8+description = "Server-side git wire protocol (smart-HTTP / SSH) on top of gix. Transport-agnostic; upstream-candidate."
9+
10+[dependencies]
11+anvil-core.workspace = true
12+gix.workspace = true
13+gitserver-core.workspace = true
14+tokio.workspace = true
15+thiserror.workspace = true
16+tracing.workspace = true
addedcrates/anvil-git/src/browse.rs+300 −0
1+//! Read-only repository browsing via gix: refs, trees, and blobs.
2+//!
3+//! These helpers back the web UI. Each opens the bare repo by path; that is
4+//! cheap enough at our scale and keeps the API stateless.
5+
6+use std::collections::{
7+ BTreeMap,
8+ BTreeSet,
9+};
10+use std::fmt::Display;
11+use std::path::Path;
12+
13+use crate::error::{
14+ Error,
15+ Result,
16+};
17+
18+/// Summary of a repository's refs and state, for the repo landing page.
19+pub struct Overview {
20+ /// Short name of the default branch (from `HEAD`), if resolvable.
21+ pub default_branch: Option<String>,
22+ pub branches: Vec<String>,
23+ pub tags: Vec<String>,
24+ /// True if the repository has no commits yet (unborn `HEAD`).
25+ pub is_empty: bool,
26+}
27+
28+/// One entry in a tree listing.
29+pub struct TreeEntry {
30+ pub name: String,
31+ pub is_dir: bool,
32+ pub oid: String,
33+}
34+
35+/// Map any gix error into our error type without a forest of `From` impls.
36+fn read(e: impl Display) -> Error {
37+ Error::Read(e.to_string())
38+}
39+
40+/// Summarize a repository's default branch, branches, and tags.
41+pub fn overview(repo_path: &Path) -> Result<Overview> {
42+ let repo = gix::open(repo_path).map_err(read)?;
43+ let default_branch = repo
44+ .head_name()
45+ .ok()
46+ .flatten()
47+ .map(|n| n.shorten().to_string());
48+ let is_empty = repo.head_id().is_err();
49+
50+ let refs = repo.references().map_err(read)?;
51+ let mut branches = Vec::new();
52+ for r in refs.local_branches().map_err(read)?.flatten() {
53+ branches.push(r.name().shorten().to_string());
54+ }
55+ let mut tags = Vec::new();
56+ for r in refs.tags().map_err(read)?.flatten() {
57+ tags.push(r.name().shorten().to_string());
58+ }
59+ branches.sort();
60+ tags.sort();
61+
62+ Ok(Overview {
63+ default_branch,
64+ branches,
65+ tags,
66+ is_empty,
67+ })
68+}
69+
70+/// List the entries of the tree at `path` (empty for root) for revision `rev`
71+/// (a branch, tag, or commit-ish). Directories sort before files. Returns an
72+/// empty list if `path` is not a directory.
73+pub fn list_tree(repo_path: &Path, rev: &str, path: &str) -> Result<Vec<TreeEntry>> {
74+ let repo = gix::open(repo_path).map_err(read)?;
75+ let root = repo
76+ .rev_parse_single(rev)
77+ .map_err(read)?
78+ .object()
79+ .map_err(read)?
80+ .peel_to_commit()
81+ .map_err(read)?
82+ .tree()
83+ .map_err(read)?;
84+
85+ let tree = if path.is_empty() {
86+ root
87+ } else {
88+ match root.lookup_entry_by_path(path).map_err(read)? {
89+ Some(entry) if entry.mode().is_tree() => {
90+ entry.object().map_err(read)?.peel_to_tree().map_err(read)?
91+ }
92+ _ => return Ok(Vec::new()),
93+ }
94+ };
95+
96+ let mut entries = Vec::new();
97+ for e in tree.iter() {
98+ let e = e.map_err(read)?;
99+ entries.push(TreeEntry {
100+ name: e.filename().to_string(),
101+ is_dir: e.mode().is_tree(),
102+ oid: e.oid().to_string(),
103+ });
104+ }
105+ entries.sort_by(|a, b| (!a.is_dir, &a.name).cmp(&(!b.is_dir, &b.name)));
106+ Ok(entries)
107+}
108+
109+/// Metadata about a single commit.
110+pub struct CommitInfo {
111+ pub id: String,
112+ pub short: String,
113+ pub summary: String,
114+ pub author: String,
115+ /// Commit time in Unix seconds.
116+ pub time: i64,
117+}
118+
119+/// How a file changed between two commits.
120+#[derive(Clone, Copy, PartialEq, Eq)]
121+pub enum ChangeKind {
122+ Added,
123+ Deleted,
124+ Modified,
125+}
126+
127+/// A single file's change in a commit, with old/new contents for diffing.
128+pub struct FileChange {
129+ pub path: String,
130+ pub kind: ChangeKind,
131+ pub old: Option<Vec<u8>>,
132+ pub new: Option<Vec<u8>>,
133+}
134+
135+/// A commit plus its diff against its first parent.
136+pub struct CommitDetail {
137+ pub info: CommitInfo,
138+ pub parent: Option<String>,
139+ pub message: String,
140+ pub changes: Vec<FileChange>,
141+}
142+
143+/// Walk commit history starting at `rev`, newest first, up to `limit` commits.
144+pub fn commit_log(repo_path: &Path, rev: &str, limit: usize) -> Result<Vec<CommitInfo>> {
145+ let repo = gix::open(repo_path).map_err(read)?;
146+ let start = repo
147+ .rev_parse_single(rev)
148+ .map_err(read)?
149+ .object()
150+ .map_err(read)?
151+ .peel_to_commit()
152+ .map_err(read)?
153+ .id;
154+
155+ let mut out = Vec::new();
156+ for info in repo.rev_walk(Some(start)).all().map_err(read)?.take(limit) {
157+ let info = info.map_err(read)?;
158+ let commit = repo
159+ .find_object(info.id)
160+ .map_err(read)?
161+ .try_into_commit()
162+ .map_err(read)?;
163+ out.push(commit_info(&commit)?);
164+ }
165+ Ok(out)
166+}
167+
168+/// Load a commit and compute its file-level diff against its first parent
169+/// (against the empty tree for a root commit).
170+pub fn commit_detail(repo_path: &Path, rev: &str) -> Result<CommitDetail> {
171+ let repo = gix::open(repo_path).map_err(read)?;
172+ let commit = repo
173+ .rev_parse_single(rev)
174+ .map_err(read)?
175+ .object()
176+ .map_err(read)?
177+ .peel_to_commit()
178+ .map_err(read)?;
179+
180+ let info = commit_info(&commit)?;
181+ let message = commit.message_raw().map_err(read)?.to_string();
182+ let parent = commit.parent_ids().next().map(|p| p.detach());
183+
184+ let mut new_map = BTreeMap::new();
185+ flatten_tree(&commit.tree().map_err(read)?, "", &mut new_map)?;
186+ let mut old_map = BTreeMap::new();
187+ if let Some(pid) = parent {
188+ let parent_commit = repo
189+ .find_object(pid)
190+ .map_err(read)?
191+ .try_into_commit()
192+ .map_err(read)?;
193+ flatten_tree(&parent_commit.tree().map_err(read)?, "", &mut old_map)?;
194+ }
195+
196+ let blob = |oid: gix::ObjectId| -> Result<Vec<u8>> {
197+ Ok(repo.find_object(oid).map_err(read)?.data.clone())
198+ };
199+
200+ let mut paths: BTreeSet<&String> = new_map.keys().collect();
201+ paths.extend(old_map.keys());
202+ let mut changes = Vec::new();
203+ for path in paths {
204+ match (old_map.get(path), new_map.get(path)) {
205+ (None, Some(n)) => changes.push(FileChange {
206+ path: path.clone(),
207+ kind: ChangeKind::Added,
208+ old: None,
209+ new: Some(blob(*n)?),
210+ }),
211+ (Some(o), None) => changes.push(FileChange {
212+ path: path.clone(),
213+ kind: ChangeKind::Deleted,
214+ old: Some(blob(*o)?),
215+ new: None,
216+ }),
217+ (Some(o), Some(n)) if o != n => changes.push(FileChange {
218+ path: path.clone(),
219+ kind: ChangeKind::Modified,
220+ old: Some(blob(*o)?),
221+ new: Some(blob(*n)?),
222+ }),
223+ _ => {}
224+ }
225+ }
226+
227+ Ok(CommitDetail {
228+ info,
229+ parent: parent.map(|p| p.to_string()),
230+ message,
231+ changes,
232+ })
233+}
234+
235+fn commit_info(commit: &gix::Commit) -> Result<CommitInfo> {
236+ let id = commit.id.to_string();
237+ let raw = commit.message_raw().map_err(read)?;
238+ let summary = raw
239+ .to_string()
240+ .lines()
241+ .next()
242+ .unwrap_or_default()
243+ .to_string();
244+ let author = commit.author().map_err(read)?.name.to_string();
245+ let time = commit.time().map_err(read)?.seconds;
246+ Ok(CommitInfo {
247+ short: id[..id.len().min(8)].to_string(),
248+ id,
249+ summary,
250+ author,
251+ time,
252+ })
253+}
254+
255+/// Recursively collect blob paths → object id from a tree.
256+fn flatten_tree(
257+ tree: &gix::Tree,
258+ prefix: &str,
259+ out: &mut BTreeMap<String, gix::ObjectId>,
260+) -> Result<()> {
261+ for e in tree.iter() {
262+ let e = e.map_err(read)?;
263+ let name = e.filename().to_string();
264+ let path = if prefix.is_empty() {
265+ name
266+ } else {
267+ format!("{prefix}/{name}")
268+ };
269+ if e.mode().is_tree() {
270+ let sub = e.object().map_err(read)?.peel_to_tree().map_err(read)?;
271+ flatten_tree(&sub, &path, out)?;
272+ } else if e.mode().is_blob() {
273+ out.insert(path, e.oid().to_owned());
274+ }
275+ }
276+ Ok(())
277+}
278+
279+/// Read the raw bytes of the blob at `path` for revision `rev`. Returns `None`
280+/// if the path does not exist or is not a blob.
281+pub fn read_blob(repo_path: &Path, rev: &str, path: &str) -> Result<Option<Vec<u8>>> {
282+ let repo = gix::open(repo_path).map_err(read)?;
283+ let tree = repo
284+ .rev_parse_single(rev)
285+ .map_err(read)?
286+ .object()
287+ .map_err(read)?
288+ .peel_to_commit()
289+ .map_err(read)?
290+ .tree()
291+ .map_err(read)?;
292+
293+ match tree.lookup_entry_by_path(path).map_err(read)? {
294+ Some(entry) if entry.mode().is_blob() => {
295+ let object = repo.find_object(entry.oid().to_owned()).map_err(read)?;
296+ Ok(Some(object.data.clone()))
297+ }
298+ _ => Ok(None),
299+ }
300+}
addedcrates/anvil-git/src/error.rs+18 −0
1+//! Errors from the git wire-protocol layer.
2+
3+/// Errors produced while serving the git smart protocol.
4+#[derive(Debug, thiserror::Error)]
5+pub enum Error {
6+ /// An error from the underlying protocol engine (`gitserver-core`).
7+ #[error(transparent)]
8+ Core(#[from] gitserver_core::error::Error),
9+
10+ #[error(transparent)]
11+ Io(#[from] std::io::Error),
12+
13+ /// An error reading repository contents via gix (browse layer).
14+ #[error("git read error: {0}")]
15+ Read(String),
16+}
17+
18+pub type Result<T> = std::result::Result<T, Error>;
addedcrates/anvil-git/src/lib.rs+28 −0
1+//! Server-side git wire protocol for anvil.
2+//!
3+//! `gix` provides object/ref/pack *plumbing* but no server-side protocol: it
4+//! ships no `upload-pack`/`receive-pack` and its `gix-transport`/`gix-protocol`
5+//! crates are client-only. This crate fills that gap.
6+//!
7+//! ## Design
8+//! - **Transport-agnostic.** [`smart_http`] takes a repo path plus request bytes
9+//! and returns response bytes / a packfile stream, with no HTTP dependency, so
10+//! the same engine serves smart-HTTP (`anvil-web`) and, later, SSH
11+//! (`anvil-ssh`).
12+//! - **Bootstrapped on [`gitserver_core`]** (vendored, gix-native pkt-line /
13+//! protocol-v2 / upload-pack / receive-pack), behind this crate's interface so
14+//! it can be replaced incrementally with an upstream-shaped implementation.
15+
16+pub mod browse;
17+pub mod error;
18+pub mod smart_http;
19+pub mod ssh;
20+
21+pub use error::{
22+ Error,
23+ Result,
24+};
25+pub use smart_http::{
26+ Service,
27+ UploadPack,
28+};
addedcrates/anvil-git/src/smart_http.rs+155 −0
1+//! Git smart-HTTP protocol, transport-agnostic.
2+//!
3+//! These functions take a bare-repository path plus request bytes and return
4+//! response bytes (or a packfile stream), with no dependency on the HTTP
5+//! framework. `anvil-web` adapts them to axum; a future `anvil-ssh` can reuse
6+//! the same engine over an SSH channel.
7+//!
8+//! The protocol engine is [`gitserver_core`]; this module orchestrates it into
9+//! the request/response shapes a transport needs. We target git **protocol v2**
10+//! for fetch (the modern default) with a v0 fallback, and the classic v0
11+//! advertisement for `receive-pack` (push).
12+
13+use std::path::Path;
14+use std::pin::Pin;
15+
16+use gitserver_core::backend::GitBackend;
17+use gitserver_core::{
18+ pack,
19+ pktline,
20+ protocol_v2,
21+};
22+use tokio::io::AsyncRead;
23+
24+use crate::error::Result;
25+
26+/// The two git wire services.
27+#[derive(Debug, Copy, Clone, Eq, PartialEq)]
28+pub enum Service {
29+ /// `git-upload-pack` — serves fetch/clone.
30+ UploadPack,
31+ /// `git-receive-pack` — accepts push.
32+ ReceivePack,
33+}
34+
35+impl Service {
36+ pub fn as_str(self) -> &'static str {
37+ match self {
38+ Service::UploadPack => "git-upload-pack",
39+ Service::ReceivePack => "git-receive-pack",
40+ }
41+ }
42+
43+ /// Parse the `?service=` query value used by `GET /info/refs`.
44+ pub fn from_query(value: &str) -> Option<Self> {
45+ match value {
46+ "git-upload-pack" => Some(Service::UploadPack),
47+ "git-receive-pack" => Some(Service::ReceivePack),
48+ _ => None,
49+ }
50+ }
51+
52+ /// The `Content-Type` for this service's `info/refs` advertisement.
53+ pub fn advertisement_content_type(self) -> String {
54+ format!("application/x-{}-advertisement", self.as_str())
55+ }
56+
57+ /// The `Content-Type` for this service's RPC result.
58+ pub fn result_content_type(self) -> String {
59+ format!("application/x-{}-result", self.as_str())
60+ }
61+}
62+
63+/// A boxed packfile stream produced by `upload-pack`.
64+pub type PackStream = Pin<Box<dyn AsyncRead + Send>>;
65+
66+/// Outcome of a `git-upload-pack` RPC: either a fully buffered response (an
67+/// `ls-refs` reply or an acknowledgments-only negotiation round) or a streamed
68+/// packfile (prefixed with any acknowledgments/shallow info).
69+pub enum UploadPack {
70+ Buffered(Vec<u8>),
71+ Pack(PackStream),
72+}
73+
74+/// Build the `GET /info/refs` advertisement body for `service`.
75+///
76+/// `protocol_v2` should be true only when the client requested v2
77+/// (`Git-Protocol: version=2`) for `upload-pack`; `receive-pack` always uses the
78+/// v0-style advertisement.
79+pub fn advertise(repo_path: &Path, service: Service, protocol_v2: bool) -> Result<Vec<u8>> {
80+ let body = match service {
81+ Service::UploadPack if protocol_v2 => protocol_v2::advertise_capabilities(),
82+ Service::UploadPack => GitBackend::new(repo_path.to_path_buf()).advertise_refs()?,
83+ Service::ReceivePack => {
84+ let mut body = pktline::encode_comment("service=git-receive-pack");
85+ body.extend_from_slice(pktline::flush());
86+ body.extend_from_slice(
87+ &GitBackend::new(repo_path.to_path_buf()).advertise_receive_refs()?,
88+ );
89+ body
90+ }
91+ };
92+ Ok(body)
93+}
94+
95+/// Handle a `POST /git-upload-pack` request using protocol v2.
96+pub async fn upload_pack_v2(repo_path: &Path, request: &[u8]) -> Result<UploadPack> {
97+ let backend = GitBackend::new(repo_path.to_path_buf());
98+ match protocol_v2::parse_command_request(request)? {
99+ protocol_v2::Command::LsRefs(req) => {
100+ Ok(UploadPack::Buffered(protocol_v2::ls_refs(repo_path, &req)?))
101+ }
102+ protocol_v2::Command::Fetch(mut req) => {
103+ let shallow = protocol_v2::apply_shallow_boundaries(repo_path, &mut req)?;
104+ let is_shallow_negotiation = req.upload_request.shallow.depth.is_some();
105+
106+ // Negotiation round (no packfile yet): reply with acknowledgments.
107+ if !req.upload_request.done && !is_shallow_negotiation {
108+ let common = protocol_v2::common_haves(repo_path, &req)?;
109+ let mut body = protocol_v2::encode_fetch_acknowledgments(&common);
110+ body.extend_from_slice(&protocol_v2::encode_shallow_info(&shallow));
111+ return Ok(UploadPack::Buffered(body));
112+ }
113+
114+ let reader = backend.upload_pack(&req.upload_request).await?;
115+
116+ let mut prefix = if is_shallow_negotiation
117+ && !req.upload_request.haves.is_empty()
118+ && !req.upload_request.done
119+ {
120+ protocol_v2::encode_fetch_ready_and_acknowledgments(&protocol_v2::common_haves(
121+ repo_path, &req,
122+ )?)
123+ } else {
124+ Vec::new()
125+ };
126+ prefix.extend_from_slice(&protocol_v2::encode_shallow_info(&shallow));
127+ prefix.extend_from_slice(&pktline::encode(b"packfile\n"));
128+
129+ let stream = protocol_v2::PrefixThenReader::new(
130+ prefix,
131+ protocol_v2::PackSectionReader::new(reader),
132+ );
133+ Ok(UploadPack::Pack(Box::pin(stream)))
134+ }
135+ }
136+}
137+
138+/// Handle a `POST /git-upload-pack` request using protocol v0/v1 (fallback for
139+/// clients that did not request v2). Returns the raw packfile stream.
140+pub async fn upload_pack_v0(repo_path: &Path, request: &[u8]) -> Result<PackStream> {
141+ let backend = GitBackend::new(repo_path.to_path_buf());
142+ let parsed = pack::UploadPackRequest::parse(request)?;
143+ let reader = backend.upload_pack(&parsed).await?;
144+ Ok(Box::pin(reader))
145+}
146+
147+/// Handle a `POST /git-receive-pack` request (push). Returns the report-status
148+/// response body.
149+pub async fn receive_pack<R>(repo_path: &Path, request: R) -> Result<Vec<u8>>
150+where
151+ R: AsyncRead + Unpin + Send + 'static,
152+{
153+ let backend = GitBackend::new(repo_path.to_path_buf());
154+ Ok(backend.receive_pack(request).await?)
155+}
addedcrates/anvil-git/src/ssh.rs+228 −0
1+//! Git smart protocol over a bidirectional stream (SSH transport / `git://`).
2+//!
3+//! Unlike smart-HTTP — which is a sequence of independent request/response pairs
4+//! — the SSH transport is one duplex stream: the server writes the ref
5+//! advertisement, then reads the client's request(s) and writes the
6+//! response/packfile on the same connection. SSH also omits the HTTP-only
7+//! `# service=…` announcement line.
8+//!
9+//! This module reuses the same [`crate::smart_http`] engine; it only adds the
10+//! stream framing (reading pkt-line requests and stripping the HTTP prefix).
11+
12+use std::path::Path;
13+
14+use gitserver_core::backend::GitBackend;
15+use tokio::io::{
16+ AsyncRead,
17+ AsyncReadExt,
18+ AsyncWrite,
19+ AsyncWriteExt,
20+};
21+
22+use crate::error::Result;
23+use crate::smart_http::{
24+ self,
25+ Service,
26+ UploadPack,
27+};
28+
29+/// Parse an SSH `exec` command such as `git-upload-pack '/alice/hello.git'`,
30+/// returning the service and the (leading-slash-trimmed) repository path.
31+pub fn parse_command(command: &str) -> Option<(Service, String)> {
32+ let command = command.trim();
33+ for (prefix, service) in [
34+ ("git-upload-pack", Service::UploadPack),
35+ ("git-receive-pack", Service::ReceivePack),
36+ ] {
37+ if let Some(rest) = command.strip_prefix(prefix) {
38+ let path = rest
39+ .trim()
40+ .trim_matches('\'')
41+ .trim_matches('"')
42+ .trim_start_matches('/');
43+ return Some((service, path.to_string()));
44+ }
45+ }
46+ None
47+}
48+
49+/// Serve one git request over `stream` for the bare repo at `repo_path`.
50+///
51+/// Takes ownership of the stream: `receive-pack` splits it so the protocol
52+/// engine can own the read half.
53+pub async fn serve<S>(
54+ repo_path: &Path,
55+ service: Service,
56+ protocol_v2: bool,
57+ mut stream: S,
58+) -> Result<()>
59+where
60+ S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
61+{
62+ match service {
63+ Service::UploadPack => upload_pack(repo_path, protocol_v2, &mut stream).await,
64+ Service::ReceivePack => receive_pack(repo_path, stream).await,
65+ }
66+}
67+
68+async fn upload_pack<S>(repo_path: &Path, protocol_v2: bool, stream: &mut S) -> Result<()>
69+where
70+ S: AsyncRead + AsyncWrite + Unpin + Send,
71+{
72+ let advertisement = smart_http::advertise(repo_path, Service::UploadPack, protocol_v2)?;
73+ stream
74+ .write_all(strip_http_service_prefix(&advertisement))
75+ .await?;
76+ stream.flush().await?;
77+
78+ if protocol_v2 {
79+ // v2: a sequence of commands (ls-refs, fetch), each terminated by flush.
80+ loop {
81+ let command = read_until_flush(stream).await?;
82+ if command.is_empty() {
83+ break; // client closed the connection
84+ }
85+ match smart_http::upload_pack_v2(repo_path, &command).await? {
86+ UploadPack::Buffered(body) => {
87+ stream.write_all(&body).await?;
88+ stream.flush().await?;
89+ }
90+ UploadPack::Pack(mut pack) => {
91+ tokio::io::copy(&mut pack, stream).await?;
92+ stream.flush().await?;
93+ break;
94+ }
95+ }
96+ }
97+ } else {
98+ // v0/v1: a single want/have request terminated by `done`.
99+ let request = read_until_done(stream).await?;
100+ if !request.is_empty() {
101+ let mut pack = smart_http::upload_pack_v0(repo_path, &request).await?;
102+ tokio::io::copy(&mut pack, stream).await?;
103+ stream.flush().await?;
104+ }
105+ }
106+ Ok(())
107+}
108+
109+async fn receive_pack<S>(repo_path: &Path, stream: S) -> Result<()>
110+where
111+ S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
112+{
113+ let (read_half, mut write_half) = tokio::io::split(stream);
114+ let backend = GitBackend::new(repo_path.to_path_buf());
115+
116+ // Advertise refs (no HTTP `# service` prefix on the SSH transport).
117+ let advertisement = backend.advertise_receive_refs()?;
118+ write_half.write_all(&advertisement).await?;
119+ write_half.flush().await?;
120+
121+ // The engine reads exactly the commands + packfile (not to EOF), so this
122+ // does not deadlock waiting for the client to half-close.
123+ let report = backend.receive_pack(read_half).await?;
124+ write_half.write_all(&report).await?;
125+ write_half.flush().await?;
126+ Ok(())
127+}
128+
129+/// The HTTP advertisements begin with a `# service=…` pkt-line followed by a
130+/// flush; the SSH/`git://` transports omit it. Return the bytes after the first
131+/// flush packet (the service comment never contains `0000`).
132+fn strip_http_service_prefix(advertisement: &[u8]) -> &[u8] {
133+ match advertisement.windows(4).position(|w| w == b"0000") {
134+ Some(pos) => &advertisement[pos + 4..],
135+ None => advertisement,
136+ }
137+}
138+
139+/// Read raw pkt-lines until (and including) a flush packet. Returns the exact
140+/// bytes read, suitable for the v2 command parser. An empty result means EOF
141+/// before any data (the client closed the connection).
142+async fn read_until_flush<S>(stream: &mut S) -> Result<Vec<u8>>
143+where
144+ S: AsyncRead + Unpin,
145+{
146+ let mut buf = Vec::new();
147+ loop {
148+ match read_pkt(stream).await? {
149+ None => break,
150+ Some(Pkt::Flush) => {
151+ buf.extend_from_slice(b"0000");
152+ break;
153+ }
154+ Some(Pkt::Delim) => buf.extend_from_slice(b"0001"),
155+ Some(Pkt::ResponseEnd) => buf.extend_from_slice(b"0002"),
156+ Some(Pkt::Line(len, payload)) => {
157+ buf.extend_from_slice(format!("{len:04x}").as_bytes());
158+ buf.extend_from_slice(&payload);
159+ }
160+ }
161+ }
162+ Ok(buf)
163+}
164+
165+/// Read raw pkt-lines until a `done` line (v0/v1 negotiation end) or EOF.
166+async fn read_until_done<S>(stream: &mut S) -> Result<Vec<u8>>
167+where
168+ S: AsyncRead + Unpin,
169+{
170+ let mut buf = Vec::new();
171+ loop {
172+ match read_pkt(stream).await? {
173+ None => break,
174+ Some(Pkt::Flush) => buf.extend_from_slice(b"0000"),
175+ Some(Pkt::Delim) => buf.extend_from_slice(b"0001"),
176+ Some(Pkt::ResponseEnd) => buf.extend_from_slice(b"0002"),
177+ Some(Pkt::Line(len, payload)) => {
178+ buf.extend_from_slice(format!("{len:04x}").as_bytes());
179+ buf.extend_from_slice(&payload);
180+ if payload.trim_ascii_end() == b"done" {
181+ break;
182+ }
183+ }
184+ }
185+ }
186+ Ok(buf)
187+}
188+
189+enum Pkt {
190+ Flush,
191+ Delim,
192+ ResponseEnd,
193+ Line(usize, Vec<u8>),
194+}
195+
196+/// Read a single pkt-line. Returns `None` on a clean EOF at a packet boundary.
197+async fn read_pkt<S>(stream: &mut S) -> Result<Option<Pkt>>
198+where
199+ S: AsyncRead + Unpin,
200+{
201+ let mut header = [0u8; 4];
202+ match stream.read_exact(&mut header).await {
203+ Ok(_) => {}
204+ Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None),
205+ Err(e) => return Err(e.into()),
206+ }
207+ let len = usize::from_str_radix(
208+ std::str::from_utf8(&header).map_err(|_| proto_err("non-utf8 pkt-line length"))?,
209+ 16,
210+ )
211+ .map_err(|_| proto_err("invalid pkt-line length"))?;
212+
213+ match len {
214+ 0 => Ok(Some(Pkt::Flush)),
215+ 1 => Ok(Some(Pkt::Delim)),
216+ 2 => Ok(Some(Pkt::ResponseEnd)),
217+ 3 => Err(proto_err("invalid pkt-line length 0003")),
218+ _ => {
219+ let mut payload = vec![0u8; len - 4];
220+ stream.read_exact(&mut payload).await?;
221+ Ok(Some(Pkt::Line(len, payload)))
222+ }
223+ }
224+}
225+
226+fn proto_err(message: &'static str) -> crate::error::Error {
227+ std::io::Error::new(std::io::ErrorKind::InvalidData, message).into()
228+}
addedcrates/anvil-ssh/Cargo.toml+16 −0
1+[package]
2+name = "anvil-ssh"
3+version.workspace = true
4+edition.workspace = true
5+license.workspace = true
6+repository.workspace = true
7+rust-version.workspace = true
8+description = "git-over-SSH transport for anvil (russh), delegating to anvil-git. Added in milestone 6."
9+
10+[dependencies]
11+anvil-core.workspace = true
12+anvil-git.workspace = true
13+russh.workspace = true
14+tokio.workspace = true
15+tracing.workspace = true
16+rand.workspace = true
addedcrates/anvil-ssh/src/lib.rs+275 −0
1+//! git-over-SSH transport for anvil, built on `russh` (pure Rust — no OpenSSH).
2+//!
3+//! Authenticates by public key, then handles `git-upload-pack` /
4+//! `git-receive-pack` `exec` requests by running the transport-agnostic engine
5+//! in [`anvil_git::ssh`] over the channel's byte stream.
6+//!
7+//! The SSH bind address is configurable (`[ssh] listen` in the config).
8+
9+use std::net::SocketAddr;
10+use std::path::{
11+ Path,
12+ PathBuf,
13+};
14+use std::sync::Arc;
15+use std::time::Duration;
16+
17+use anvil_core::{
18+ App,
19+ Error as CoreError,
20+ Result as CoreResult,
21+ access,
22+ repos,
23+ users,
24+};
25+use anvil_git::ssh as git_ssh;
26+use russh::keys::ssh_key::{
27+ HashAlg,
28+ LineEnding,
29+ PublicKey,
30+};
31+use russh::keys::{
32+ Algorithm,
33+ PrivateKey,
34+};
35+use russh::server::{
36+ self,
37+ Auth,
38+ Handler,
39+ Msg,
40+ Server as _,
41+ Session,
42+};
43+use russh::{
44+ Channel,
45+ ChannelId,
46+};
47+use tokio::net::TcpListener;
48+
49+/// Bind to the configured SSH address and serve git over SSH until shutdown.
50+pub async fn serve(app: App) -> CoreResult<()> {
51+ let listen = app.config.ssh.listen.clone();
52+ let host_key = load_or_create_host_key(&app.config.data_dir)?;
53+
54+ let config = Arc::new(server::Config {
55+ inactivity_timeout: Some(Duration::from_secs(3600)),
56+ auth_rejection_time: Duration::from_secs(1),
57+ keys: vec![host_key],
58+ ..Default::default()
59+ });
60+
61+ let listener = TcpListener::bind(&listen).await?;
62+ tracing::info!("anvil ssh server listening on {listen}");
63+
64+ let mut server = GitSshServer { app };
65+ server
66+ .run_on_socket(config, &listener)
67+ .await
68+ .map_err(|e| CoreError::Storage(format!("ssh server: {e}")))?;
69+ Ok(())
70+}
71+
72+/// Load the persistent ed25519 host key, generating and saving it on first run
73+/// so the server identity is stable across restarts.
74+fn load_or_create_host_key(data_dir: &Path) -> CoreResult<PrivateKey> {
75+ let path = data_dir.join("ssh_host_ed25519_key");
76+ if path.exists() {
77+ let pem = std::fs::read_to_string(&path)?;
78+ return PrivateKey::from_openssh(&pem).map_err(|e| {
79+ CoreError::Config(format!("reading ssh host key {}: {e}", path.display()))
80+ });
81+ }
82+
83+ let key = PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519)
84+ .map_err(|e| CoreError::Config(format!("generating ssh host key: {e}")))?;
85+ let pem = key
86+ .to_openssh(LineEnding::LF)
87+ .map_err(|e| CoreError::Config(format!("encoding ssh host key: {e}")))?;
88+ std::fs::write(&path, pem.as_bytes())?;
89+ #[cfg(unix)]
90+ {
91+ use std::os::unix::fs::PermissionsExt;
92+ let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
93+ }
94+ tracing::info!("generated ssh host key at {}", path.display());
95+ Ok(key)
96+}
97+
98+struct GitSshServer {
99+ app: App,
100+}
101+
102+impl server::Server for GitSshServer {
103+ type Handler = GitSshSession;
104+
105+ fn new_client(&mut self, _peer: Option<SocketAddr>) -> GitSshSession {
106+ GitSshSession {
107+ app: self.app.clone(),
108+ channel: None,
109+ protocol_v2: false,
110+ authed_user: None,
111+ }
112+ }
113+}
114+
115+struct GitSshSession {
116+ app: App,
117+ /// The session channel, taken in `channel_open_session` and consumed by the
118+ /// git protocol task in `exec_request`.
119+ channel: Option<Channel<Msg>>,
120+ /// Set if the client requested git protocol v2 via the `GIT_PROTOCOL` env.
121+ protocol_v2: bool,
122+ /// The user id authenticated by public key, set in `auth_publickey`.
123+ authed_user: Option<i64>,
124+}
125+
126+impl Handler for GitSshSession {
127+ type Error = russh::Error;
128+
129+ async fn auth_publickey(&mut self, _user: &str, key: &PublicKey) -> Result<Auth, Self::Error> {
130+ // Authenticate by matching the (signature-verified) key's fingerprint to
131+ // a registered user. Unknown keys are rejected. Per-repo authorization
132+ // is a later milestone; for now any authenticated user has full access.
133+ let fingerprint = key.fingerprint(HashAlg::Sha256).to_string();
134+ match anvil_core::ssh_keys::find_user_id_by_fingerprint(&self.app.db, &fingerprint).await {
135+ Ok(Some(user_id)) => {
136+ self.authed_user = Some(user_id);
137+ tracing::info!("ssh auth: accepted key {fingerprint} (user {user_id})");
138+ Ok(Auth::Accept)
139+ }
140+ Ok(None) => {
141+ tracing::info!("ssh auth: rejected unknown key {fingerprint}");
142+ Ok(Auth::reject())
143+ }
144+ Err(e) => {
145+ tracing::error!("ssh auth: key lookup failed: {e}");
146+ Ok(Auth::reject())
147+ }
148+ }
149+ }
150+
151+ async fn channel_open_session(
152+ &mut self,
153+ channel: Channel<Msg>,
154+ _session: &mut Session,
155+ ) -> Result<bool, Self::Error> {
156+ self.channel = Some(channel);
157+ Ok(true)
158+ }
159+
160+ async fn env_request(
161+ &mut self,
162+ _channel: ChannelId,
163+ name: &str,
164+ value: &str,
165+ _session: &mut Session,
166+ ) -> Result<(), Self::Error> {
167+ if name == "GIT_PROTOCOL" && value.split(':').any(|v| v.trim() == "version=2") {
168+ self.protocol_v2 = true;
169+ }
170+ Ok(())
171+ }
172+
173+ async fn exec_request(
174+ &mut self,
175+ channel_id: ChannelId,
176+ data: &[u8],
177+ session: &mut Session,
178+ ) -> Result<(), Self::Error> {
179+ let command = String::from_utf8_lossy(data);
180+ let Some((service, rel)) = git_ssh::parse_command(&command) else {
181+ return fail(session, channel_id, "unsupported command");
182+ };
183+ let need_write = service == anvil_git::Service::ReceivePack;
184+ let path = match authorize_repo(&self.app, self.authed_user, &rel, need_write).await {
185+ Ok(path) => path,
186+ Err(message) => return fail(session, channel_id, message),
187+ };
188+ let Some(channel) = self.channel.take() else {
189+ return fail(session, channel_id, "no session channel");
190+ };
191+
192+ tracing::info!(
193+ "ssh {} on {rel} (user {:?})",
194+ service.as_str(),
195+ self.authed_user
196+ );
197+
198+ let protocol_v2 = self.protocol_v2;
199+ let handle = session.handle();
200+ session.channel_success(channel_id)?;
201+
202+ tokio::spawn(async move {
203+ let stream = channel.into_stream();
204+ let code = match git_ssh::serve(&path, service, protocol_v2, stream).await {
205+ Ok(()) => 0,
206+ Err(e) => {
207+ tracing::error!("git ssh {}: {e}", service.as_str());
208+ 1
209+ }
210+ };
211+ let _ = handle.exit_status_request(channel_id, code).await;
212+ let _ = handle.eof(channel_id).await;
213+ let _ = handle.close(channel_id).await;
214+ });
215+ Ok(())
216+ }
217+}
218+
219+/// Write a one-line error to the channel and close it with a failure status.
220+fn fail(session: &mut Session, channel_id: ChannelId, message: &str) -> Result<(), russh::Error> {
221+ let _ = session.data(channel_id, format!("{message}\n").into_bytes());
222+ session.exit_status_request(channel_id, 1)?;
223+ session.close(channel_id)?;
224+ Ok(())
225+}
226+
227+/// Resolve an SSH repo path (`owner/name[.git]`) to an existing bare repo and
228+/// enforce access for the authenticated user. Returns the on-disk path or a
229+/// short error message. Rejects traversal.
230+async fn authorize_repo(
231+ app: &App,
232+ authed_user: Option<i64>,
233+ rel: &str,
234+ need_write: bool,
235+) -> Result<PathBuf, &'static str> {
236+ let (owner, repo) = rel
237+ .trim_start_matches('/')
238+ .split_once('/')
239+ .ok_or("invalid repository path")?;
240+ let name = repo.strip_suffix(".git").unwrap_or(repo);
241+ let bad = |s: &str| s.is_empty() || s.contains("..") || s.contains('\\') || s.contains('/');
242+ if bad(owner) || bad(name) {
243+ return Err("invalid repository path");
244+ }
245+
246+ let owner_user = users::find_by_username(&app.db, owner)
247+ .await
248+ .ok()
249+ .flatten()
250+ .ok_or("repository not found")?;
251+ let repo = repos::find(&app.db, owner_user.id, name)
252+ .await
253+ .ok()
254+ .flatten()
255+ .ok_or("repository not found")?;
256+
257+ let viewer = match authed_user {
258+ Some(id) => users::find_by_id(&app.db, id).await.ok().flatten(),
259+ None => None,
260+ };
261+ let allowed = if need_write {
262+ access::can_write(&repo, viewer.as_ref())
263+ } else {
264+ access::can_read(&repo, viewer.as_ref())
265+ };
266+ if !allowed {
267+ return Err("access denied");
268+ }
269+
270+ let path = anvil_core::storage::repo_path(&app.config.repositories_dir(), owner, name);
271+ if !path.exists() {
272+ return Err("repository not found");
273+ }
274+ Ok(path)
275+}
addedcrates/anvil-web/Cargo.toml+24 −0
1+[package]
2+name = "anvil-web"
3+version.workspace = true
4+edition.workspace = true
5+license.workspace = true
6+repository.workspace = true
7+rust-version.workspace = true
8+description = "HTTP server for anvil: web UI and smart-HTTP git endpoints (axum)."
9+
10+[dependencies]
11+anvil-core.workspace = true
12+anvil-git.workspace = true
13+axum.workspace = true
14+axum-extra.workspace = true
15+tokio.workspace = true
16+tokio-util.workspace = true
17+tower-http.workspace = true
18+tracing.workspace = true
19+serde.workspace = true
20+base64.workspace = true
21+maud.workspace = true
22+similar.workspace = true
23+syntect.workspace = true
24+time.workspace = true
addedcrates/anvil-web/assets/htmx.min.js+1 −0
1+var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true},parseInterval:null,_:null,version:"2.0.4"};Q.onLoad=j;Q.process=kt;Q.on=ye;Q.off=be;Q.trigger=he;Q.ajax=Rn;Q.find=u;Q.findAll=x;Q.closest=g;Q.remove=z;Q.addClass=K;Q.removeClass=G;Q.toggleClass=W;Q.takeClass=Z;Q.swap=$e;Q.defineExtension=Fn;Q.removeExtension=Bn;Q.logAll=V;Q.logNone=_;Q.parseInterval=d;Q._=e;const n={addTriggerHandler:St,bodyContains:le,canAccessLocalStorage:B,findThisElement:Se,filterValues:hn,swap:$e,hasAttribute:s,getAttributeValue:te,getClosestAttributeValue:re,getClosestMatch:o,getExpressionVars:En,getHeaders:fn,getInputValues:cn,getInternalData:ie,getSwapSpecification:gn,getTriggerSpecs:st,getTarget:Ee,makeFragment:P,mergeObjects:ce,makeSettleInfo:xn,oobSwap:He,querySelectorExt:ae,settleImmediately:Kt,shouldCancel:ht,triggerEvent:he,triggerErrorEvent:fe,withExtensions:Ft};const r=["get","post","put","delete","patch"];const H=r.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function c(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function ne(){return document}function m(e,t){return e.getRootNode?e.getRootNode({composed:t}):ne()}function o(e,t){while(e&&!t(e)){e=c(e)}return e||null}function i(e,t,n){const r=te(t,n);const o=te(t,"hx-disinherit");var i=te(t,"hx-inherit");if(e!==t){if(Q.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function re(t,n){let r=null;o(t,function(e){return!!(r=i(t,ue(e),n))});if(r!=="unset"){return r}}function h(e,t){const n=e instanceof Element&&(e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector);return!!n&&n.call(e,t)}function T(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function q(e){const t=new DOMParser;return t.parseFromString(e,"text/html")}function L(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function A(e){const t=ne().createElement("script");se(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function N(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function I(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(N(e)){const t=A(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){O(e)}finally{e.remove()}}})}function P(e){const t=e.replace(/<head(\s[^>]*)?>[\s\S]*?<\/head>/i,"");const n=T(t);let r;if(n==="html"){r=new DocumentFragment;const i=q(e);L(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=q(t);L(r,i.body);r.title=i.title}else{const i=q('<body><template class="internal-htmx-wrapper">'+t+"</template></body>");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){I(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function oe(e){if(e){e()}}function t(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function k(e){return typeof e==="function"}function D(e){return t(e,"Object")}function ie(e){const t="htmx-internal-data";let n=e[t];if(!n){n=e[t]={}}return n}function M(t){const n=[];if(t){for(let e=0;e<t.length;e++){n.push(t[e])}}return n}function se(t,n){if(t){for(let e=0;e<t.length;e++){n(t[e])}}}function X(e){const t=e.getBoundingClientRect();const n=t.top;const r=t.bottom;return n<window.innerHeight&&r>=0}function le(e){return e.getRootNode({composed:true})===document}function F(e){return e.trim().split(/\s+/)}function ce(e,t){for(const n in t){if(t.hasOwnProperty(n)){e[n]=t[n]}}return e}function S(e){try{return JSON.parse(e)}catch(e){O(e);return null}}function B(){const e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function U(t){try{const e=new URL(t);if(e){t=e.pathname+e.search}if(!/^\/$/.test(t)){t=t.replace(/\/+$/,"")}return t}catch(e){return t}}function e(e){return vn(ne().body,function(){return eval(e)})}function j(t){const e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function V(){Q.logger=function(e,t,n){if(console){console.log(t,e,n)}}}function _(){Q.logger=null}function u(e,t){if(typeof e!=="string"){return e.querySelector(t)}else{return u(ne(),e)}}function x(e,t){if(typeof e!=="string"){return e.querySelectorAll(t)}else{return x(ne(),e)}}function E(){return window}function z(e,t){e=y(e);if(t){E().setTimeout(function(){z(e);e=null},t)}else{c(e).removeChild(e)}}function ue(e){return e instanceof Element?e:null}function $(e){return e instanceof HTMLElement?e:null}function J(e){return typeof e==="string"?e:null}function f(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function K(e,t,n){e=ue(y(e));if(!e){return}if(n){E().setTimeout(function(){K(e,t);e=null},n)}else{e.classList&&e.classList.add(t)}}function G(e,t,n){let r=ue(y(e));if(!r){return}if(n){E().setTimeout(function(){G(r,t);r=null},n)}else{if(r.classList){r.classList.remove(t);if(r.classList.length===0){r.removeAttribute("class")}}}}function W(e,t){e=y(e);e.classList.toggle(t)}function Z(e,t){e=y(e);se(e.parentElement.children,function(e){G(e,t)});K(ue(e),t)}function g(e,t){e=ue(y(e));if(e&&e.closest){return e.closest(t)}else{do{if(e==null||h(e,t)){return e}}while(e=e&&ue(c(e)));return null}}function l(e,t){return e.substring(0,t.length)===t}function Y(e,t){return e.substring(e.length-t.length)===t}function ge(e){const t=e.trim();if(l(t,"<")&&Y(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function p(t,r,n){if(r.indexOf("global ")===0){return p(t,r.slice(7),true)}t=y(t);const o=[];{let t=0;let n=0;for(let e=0;e<r.length;e++){const l=r[e];if(l===","&&t===0){o.push(r.substring(n,e));n=e+1;continue}if(l==="<"){t++}else if(l==="/"&&e<r.length-1&&r[e+1]===">"){t--}}if(n<r.length){o.push(r.substring(n))}}const i=[];const s=[];while(o.length>0){const r=ge(o.shift());let e;if(r.indexOf("closest ")===0){e=g(ue(t),ge(r.substr(8)))}else if(r.indexOf("find ")===0){e=u(f(t),ge(r.substr(5)))}else if(r==="next"||r==="nextElementSibling"){e=ue(t).nextElementSibling}else if(r.indexOf("next ")===0){e=pe(t,ge(r.substr(5)),!!n)}else if(r==="previous"||r==="previousElementSibling"){e=ue(t).previousElementSibling}else if(r.indexOf("previous ")===0){e=me(t,ge(r.substr(9)),!!n)}else if(r==="document"){e=document}else if(r==="window"){e=window}else if(r==="body"){e=document.body}else if(r==="root"){e=m(t,!!n)}else if(r==="host"){e=t.getRootNode().host}else{s.push(r)}if(e){i.push(e)}}if(s.length>0){const e=s.join(",");const c=f(m(t,!!n));i.push(...M(c.querySelectorAll(e)))}return i}var pe=function(t,e,n){const r=f(m(t,n)).querySelectorAll(e);for(let e=0;e<r.length;e++){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_PRECEDING){return o}}};var me=function(t,e,n){const r=f(m(t,n)).querySelectorAll(e);for(let e=r.length-1;e>=0;e--){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_FOLLOWING){return o}}};function ae(e,t){if(typeof e!=="string"){return p(e,t)[0]}else{return p(ne().body,e)[0]}}function y(e,t){if(typeof e==="string"){return u(f(t)||document,e)}else{return e}}function xe(e,t,n,r){if(k(t)){return{target:ne().body,event:J(e),listener:t,options:n}}else{return{target:y(e),event:J(t),listener:n,options:r}}}function ye(t,n,r,o){Vn(function(){const e=xe(t,n,r,o);e.target.addEventListener(e.event,e.listener,e.options)});const e=k(n);return e?n:r}function be(t,n,r){Vn(function(){const e=xe(t,n,r);e.target.removeEventListener(e.event,e.listener)});return k(n)?n:r}const ve=ne().createElement("output");function we(e,t){const n=re(e,t);if(n){if(n==="this"){return[Se(e,t)]}else{const r=p(e,n);if(r.length===0){O('The selector "'+n+'" on '+t+" returned no matches!");return[ve]}else{return r}}}}function Se(e,t){return ue(o(e,function(e){return te(ue(e),t)!=null}))}function Ee(e){const t=re(e,"hx-target");if(t){if(t==="this"){return Se(e,"hx-target")}else{return ae(e,t)}}else{const n=ie(e);if(n.boosted){return ne().body}else{return e}}}function Ce(t){const n=Q.config.attributesToSettle;for(let e=0;e<n.length;e++){if(t===n[e]){return true}}return false}function Oe(t,n){se(t.attributes,function(e){if(!n.hasAttribute(e.name)&&Ce(e.name)){t.removeAttribute(e.name)}});se(n.attributes,function(e){if(Ce(e.name)){t.setAttribute(e.name,e.value)}})}function Re(t,e){const n=Un(e);for(let e=0;e<n.length;e++){const r=n[e];try{if(r.isInlineSwap(t)){return true}}catch(e){O(e)}}return t==="outerHTML"}function He(e,o,i,t){t=t||ne();let n="#"+ee(o,"id");let s="outerHTML";if(e==="true"){}else if(e.indexOf(":")>0){s=e.substring(0,e.indexOf(":"));n=e.substring(e.indexOf(":")+1)}else{s=e}o.removeAttribute("hx-swap-oob");o.removeAttribute("data-hx-swap-oob");const r=p(t,n,false);if(r){se(r,function(e){let t;const n=o.cloneNode(true);t=ne().createDocumentFragment();t.appendChild(n);if(!Re(s,e)){t=f(n)}const r={shouldSwap:true,target:e,fragment:t};if(!he(e,"htmx:oobBeforeSwap",r))return;e=r.target;if(r.shouldSwap){qe(t);_e(s,e,e,t,i);Te()}se(i.elts,function(e){he(e,"htmx:oobAfterSwap",r)})});o.parentNode.removeChild(o)}else{o.parentNode.removeChild(o);fe(ne().body,"htmx:oobErrorNoTarget",{content:o})}return e}function Te(){const e=u("#--htmx-preserve-pantry--");if(e){for(const t of[...e.children]){const n=u("#"+t.id);n.parentNode.moveBefore(t,n);n.remove()}e.remove()}}function qe(e){se(x(e,"[hx-preserve], [data-hx-preserve]"),function(e){const t=te(e,"id");const n=ne().getElementById(t);if(n!=null){if(e.moveBefore){let e=u("#--htmx-preserve-pantry--");if(e==null){ne().body.insertAdjacentHTML("afterend","<div id='--htmx-preserve-pantry--'></div>");e=u("#--htmx-preserve-pantry--")}e.moveBefore(n,null)}else{e.parentNode.replaceChild(n,e)}}})}function Le(l,e,c){se(e.querySelectorAll("[id]"),function(t){const n=ee(t,"id");if(n&&n.length>0){const r=n.replace("'","\\'");const o=t.tagName.replace(":","\\:");const e=f(l);const i=e&&e.querySelector(o+"[id='"+r+"']");if(i&&i!==e){const s=t.cloneNode();Oe(t,i);c.tasks.push(function(){Oe(t,s)})}}})}function Ae(e){return function(){G(e,Q.config.addedClass);kt(ue(e));Ne(f(e));he(e,"htmx:load")}}function Ne(e){const t="[autofocus]";const n=$(h(e,t)?e:e.querySelector(t));if(n!=null){n.focus()}}function a(e,t,n,r){Le(e,n,r);while(n.childNodes.length>0){const o=n.firstChild;K(ue(o),Q.config.addedClass);e.insertBefore(o,t);if(o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE){r.tasks.push(Ae(o))}}}function Ie(e,t){let n=0;while(n<e.length){t=(t<<5)-t+e.charCodeAt(n++)|0}return t}function Pe(t){let n=0;if(t.attributes){for(let e=0;e<t.attributes.length;e++){const r=t.attributes[e];if(r.value){n=Ie(r.name,n);n=Ie(r.value,n)}}}return n}function ke(t){const n=ie(t);if(n.onHandlers){for(let e=0;e<n.onHandlers.length;e++){const r=n.onHandlers[e];be(t,r.event,r.listener)}delete n.onHandlers}}function De(e){const t=ie(e);if(t.timeout){clearTimeout(t.timeout)}if(t.listenerInfos){se(t.listenerInfos,function(e){if(e.on){be(e.on,e.trigger,e.listener)}})}ke(e);se(Object.keys(t),function(e){if(e!=="firstInitCompleted")delete t[e]})}function b(e){he(e,"htmx:beforeCleanupElement");De(e);if(e.children){se(e.children,function(e){b(e)})}}function Me(t,e,n){if(t instanceof Element&&t.tagName==="BODY"){return Ve(t,e,n)}let r;const o=t.previousSibling;const i=c(t);if(!i){return}a(i,t,e,n);if(o==null){r=i.firstChild}else{r=o.nextSibling}n.elts=n.elts.filter(function(e){return e!==t});while(r&&r!==t){if(r instanceof Element){n.elts.push(r)}r=r.nextSibling}b(t);if(t instanceof Element){t.remove()}else{t.parentNode.removeChild(t)}}function Xe(e,t,n){return a(e,e.firstChild,t,n)}function Fe(e,t,n){return a(c(e),e,t,n)}function Be(e,t,n){return a(e,null,t,n)}function Ue(e,t,n){return a(c(e),e.nextSibling,t,n)}function je(e){b(e);const t=c(e);if(t){return t.removeChild(e)}}function Ve(e,t,n){const r=e.firstChild;a(e,r,t,n);if(r){while(r.nextSibling){b(r.nextSibling);e.removeChild(r.nextSibling)}b(r);e.removeChild(r)}}function _e(t,e,n,r,o){switch(t){case"none":return;case"outerHTML":Me(n,r,o);return;case"afterbegin":Xe(n,r,o);return;case"beforebegin":Fe(n,r,o);return;case"beforeend":Be(n,r,o);return;case"afterend":Ue(n,r,o);return;case"delete":je(n);return;default:var i=Un(e);for(let e=0;e<i.length;e++){const s=i[e];try{const l=s.handleSwap(t,n,r,o);if(l){if(Array.isArray(l)){for(let e=0;e<l.length;e++){const c=l[e];if(c.nodeType!==Node.TEXT_NODE&&c.nodeType!==Node.COMMENT_NODE){o.tasks.push(Ae(c))}}}return}}catch(e){O(e)}}if(t==="innerHTML"){Ve(n,r,o)}else{_e(Q.config.defaultSwapStyle,e,n,r,o)}}}function ze(e,n,r){var t=x(e,"[hx-swap-oob], [data-hx-swap-oob]");se(t,function(e){if(Q.config.allowNestedOobSwaps||e.parentElement===null){const t=te(e,"hx-swap-oob");if(t!=null){He(t,e,n,r)}}else{e.removeAttribute("hx-swap-oob");e.removeAttribute("data-hx-swap-oob")}});return t.length>0}function $e(e,t,r,o){if(!o){o={}}e=y(e);const i=o.contextElement?m(o.contextElement,false):ne();const n=document.activeElement;let s={};try{s={elt:n,start:n?n.selectionStart:null,end:n?n.selectionEnd:null}}catch(e){}const l=xn(e);if(r.swapStyle==="textContent"){e.textContent=t}else{let n=P(t);l.title=n.title;if(o.selectOOB){const u=o.selectOOB.split(",");for(let t=0;t<u.length;t++){const a=u[t].split(":",2);let e=a[0].trim();if(e.indexOf("#")===0){e=e.substring(1)}const f=a[1]||"true";const h=n.querySelector("#"+e);if(h){He(f,h,l,i)}}}ze(n,l,i);se(x(n,"template"),function(e){if(e.content&&ze(e.content,l,i)){e.remove()}});if(o.select){const d=ne().createDocumentFragment();se(n.querySelectorAll(o.select),function(e){d.appendChild(e)});n=d}qe(n);_e(r.swapStyle,o.contextElement,e,n,l);Te()}if(s.elt&&!le(s.elt)&&ee(s.elt,"id")){const g=document.getElementById(ee(s.elt,"id"));const p={preventScroll:r.focusScroll!==undefined?!r.focusScroll:!Q.config.defaultFocusScroll};if(g){if(s.start&&g.setSelectionRange){try{g.setSelectionRange(s.start,s.end)}catch(e){}}g.focus(p)}}e.classList.remove(Q.config.swappingClass);se(l.elts,function(e){if(e.classList){e.classList.add(Q.config.settlingClass)}he(e,"htmx:afterSwap",o.eventInfo)});if(o.afterSwapCallback){o.afterSwapCallback()}if(!r.ignoreTitle){kn(l.title)}const c=function(){se(l.tasks,function(e){e.call()});se(l.elts,function(e){if(e.classList){e.classList.remove(Q.config.settlingClass)}he(e,"htmx:afterSettle",o.eventInfo)});if(o.anchor){const e=ue(y("#"+o.anchor));if(e){e.scrollIntoView({block:"start",behavior:"auto"})}}yn(l.elts,r);if(o.afterSettleCallback){o.afterSettleCallback()}};if(r.settleDelay>0){E().setTimeout(c,r.settleDelay)}else{c()}}function Je(e,t,n){const r=e.getResponseHeader(t);if(r.indexOf("{")===0){const o=S(r);for(const i in o){if(o.hasOwnProperty(i)){let e=o[i];if(D(e)){n=e.target!==undefined?e.target:n}else{e={value:e}}he(n,i,e)}}}else{const s=r.split(",");for(let e=0;e<s.length;e++){he(n,s[e].trim(),[])}}}const Ke=/\s/;const v=/[\s,]/;const Ge=/[_$a-zA-Z]/;const We=/[_$a-zA-Z0-9]/;const Ze=['"',"'","/"];const w=/[^\s]/;const Ye=/[{(]/;const Qe=/[})]/;function et(e){const t=[];let n=0;while(n<e.length){if(Ge.exec(e.charAt(n))){var r=n;while(We.exec(e.charAt(n+1))){n++}t.push(e.substring(r,n+1))}else if(Ze.indexOf(e.charAt(n))!==-1){const o=e.charAt(n);var r=n;n++;while(n<e.length&&e.charAt(n)!==o){if(e.charAt(n)==="\\"){n++}n++}t.push(e.substring(r,n+1))}else{const i=e.charAt(n);t.push(i)}n++}return t}function tt(e,t,n){return Ge.exec(e.charAt(0))&&e!=="true"&&e!=="false"&&e!=="this"&&e!==n&&t!=="."}function nt(r,o,i){if(o[0]==="["){o.shift();let e=1;let t=" return (function("+i+"){ return (";let n=null;while(o.length>0){const s=o[0];if(s==="]"){e--;if(e===0){if(n===null){t=t+"true"}o.shift();t+=")})";try{const l=vn(r,function(){return Function(t)()},function(){return true});l.source=t;return l}catch(e){fe(ne().body,"htmx:syntax:error",{error:e,source:t});return null}}}else if(s==="["){e++}if(tt(s,n,i)){t+="(("+i+"."+s+") ? ("+i+"."+s+") : (window."+s+"))"}else{t=t+s}n=o.shift()}}}function C(e,t){let n="";while(e.length>0&&!t.test(e[0])){n+=e.shift()}return n}function rt(e){let t;if(e.length>0&&Ye.test(e[0])){e.shift();t=C(e,Qe).trim();e.shift()}else{t=C(e,v)}return t}const ot="input, textarea, select";function it(e,t,n){const r=[];const o=et(t);do{C(o,w);const l=o.length;const c=C(o,/[,\[\s]/);if(c!==""){if(c==="every"){const u={trigger:"every"};C(o,w);u.pollInterval=d(C(o,/[,\[\s]/));C(o,w);var i=nt(e,o,"event");if(i){u.eventFilter=i}r.push(u)}else{const a={trigger:c};var i=nt(e,o,"event");if(i){a.eventFilter=i}C(o,w);while(o.length>0&&o[0]!==","){const f=o.shift();if(f==="changed"){a.changed=true}else if(f==="once"){a.once=true}else if(f==="consume"){a.consume=true}else if(f==="delay"&&o[0]===":"){o.shift();a.delay=d(C(o,v))}else if(f==="from"&&o[0]===":"){o.shift();if(Ye.test(o[0])){var s=rt(o)}else{var s=C(o,v);if(s==="closest"||s==="find"||s==="next"||s==="previous"){o.shift();const h=rt(o);if(h.length>0){s+=" "+h}}}a.from=s}else if(f==="target"&&o[0]===":"){o.shift();a.target=rt(o)}else if(f==="throttle"&&o[0]===":"){o.shift();a.throttle=d(C(o,v))}else if(f==="queue"&&o[0]===":"){o.shift();a.queue=C(o,v)}else if(f==="root"&&o[0]===":"){o.shift();a[f]=rt(o)}else if(f==="threshold"&&o[0]===":"){o.shift();a[f]=C(o,v)}else{fe(e,"htmx:syntax:error",{token:o.shift()})}C(o,w)}r.push(a)}}if(o.length===l){fe(e,"htmx:syntax:error",{token:o.shift()})}C(o,w)}while(o[0]===","&&o.shift());if(n){n[t]=r}return r}function st(e){const t=te(e,"hx-trigger");let n=[];if(t){const r=Q.config.triggerSpecsCache;n=r&&r[t]||it(e,t,r)}if(n.length>0){return n}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,ot)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function lt(e){ie(e).cancelled=true}function ct(e,t,n){const r=ie(e);r.timeout=E().setTimeout(function(){if(le(e)&&r.cancelled!==true){if(!gt(n,e,Mt("hx:poll:trigger",{triggerSpec:n,target:e}))){t(e)}ct(e,t,n)}},n.pollInterval)}function ut(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function at(e){return g(e,Q.config.disableSelector)}function ft(t,n,e){if(t instanceof HTMLAnchorElement&&ut(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"&&String(ee(t,"method")).toLowerCase()!=="dialog"){n.boosted=true;let r,o;if(t.tagName==="A"){r="get";o=ee(t,"href")}else{const i=ee(t,"method");r=i?i.toLowerCase():"get";o=ee(t,"action");if(o==null||o===""){o=ne().location.href}if(r==="get"&&o.includes("?")){o=o.replace(/\?[^#]+/,"")}}e.forEach(function(e){pt(t,function(e,t){const n=ue(e);if(at(n)){b(n);return}de(r,o,n,t)},n,e,true)})}}function ht(e,t){const n=ue(t);if(!n){return false}if(e.type==="submit"||e.type==="click"){if(n.tagName==="FORM"){return true}if(h(n,'input[type="submit"], button')&&(h(n,"[form]")||g(n,"form")!==null)){return true}if(n instanceof HTMLAnchorElement&&n.href&&(n.getAttribute("href")==="#"||n.getAttribute("href").indexOf("#")!==0)){return true}}return false}function dt(e,t){return ie(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function gt(e,t,n){const r=e.eventFilter;if(r){try{return r.call(t,n)!==true}catch(e){const o=r.source;fe(ne().body,"htmx:eventFilter:error",{error:e,source:o});return true}}return false}function pt(l,c,e,u,a){const f=ie(l);let t;if(u.from){t=p(l,u.from)}else{t=[l]}if(u.changed){if(!("lastValue"in f)){f.lastValue=new WeakMap}t.forEach(function(e){if(!f.lastValue.has(u)){f.lastValue.set(u,new WeakMap)}f.lastValue.get(u).set(e,e.value)})}se(t,function(i){const s=function(e){if(!le(l)){i.removeEventListener(u.trigger,s);return}if(dt(l,e)){return}if(a||ht(e,l)){e.preventDefault()}if(gt(u,l,e)){return}const t=ie(e);t.triggerSpec=u;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(l)<0){t.handledFor.push(l);if(u.consume){e.stopPropagation()}if(u.target&&e.target){if(!h(ue(e.target),u.target)){return}}if(u.once){if(f.triggeredOnce){return}else{f.triggeredOnce=true}}if(u.changed){const n=event.target;const r=n.value;const o=f.lastValue.get(u);if(o.has(n)&&o.get(n)===r){return}o.set(n,r)}if(f.delayed){clearTimeout(f.delayed)}if(f.throttle){return}if(u.throttle>0){if(!f.throttle){he(l,"htmx:trigger");c(l,e);f.throttle=E().setTimeout(function(){f.throttle=null},u.throttle)}}else if(u.delay>0){f.delayed=E().setTimeout(function(){he(l,"htmx:trigger");c(l,e)},u.delay)}else{he(l,"htmx:trigger");c(l,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:u.trigger,listener:s,on:i});i.addEventListener(u.trigger,s)})}let mt=false;let xt=null;function yt(){if(!xt){xt=function(){mt=true};window.addEventListener("scroll",xt);window.addEventListener("resize",xt);setInterval(function(){if(mt){mt=false;se(ne().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){bt(e)})}},200)}}function bt(e){if(!s(e,"data-hx-revealed")&&X(e)){e.setAttribute("data-hx-revealed","true");const t=ie(e);if(t.initHash){he(e,"revealed")}else{e.addEventListener("htmx:afterProcessNode",function(){he(e,"revealed")},{once:true})}}}function vt(e,t,n,r){const o=function(){if(!n.loaded){n.loaded=true;he(e,"htmx:trigger");t(e)}};if(r>0){E().setTimeout(o,r)}else{o()}}function wt(t,n,e){let i=false;se(r,function(r){if(s(t,"hx-"+r)){const o=te(t,"hx-"+r);i=true;n.path=o;n.verb=r;e.forEach(function(e){St(t,e,n,function(e,t){const n=ue(e);if(g(n,Q.config.disableSelector)){b(n);return}de(r,o,n,t)})})}});return i}function St(r,e,t,n){if(e.trigger==="revealed"){yt();pt(r,n,t,e);bt(ue(r))}else if(e.trigger==="intersect"){const o={};if(e.root){o.root=ae(r,e.root)}if(e.threshold){o.threshold=parseFloat(e.threshold)}const i=new IntersectionObserver(function(t){for(let e=0;e<t.length;e++){const n=t[e];if(n.isIntersecting){he(r,"intersect");break}}},o);i.observe(ue(r));pt(ue(r),n,t,e)}else if(!t.firstInitCompleted&&e.trigger==="load"){if(!gt(e,r,Mt("load",{elt:r}))){vt(ue(r),n,t,e.delay)}}else if(e.pollInterval>0){t.polling=true;ct(ue(r),n,e)}else{pt(r,n,t,e)}}function Et(e){const t=ue(e);if(!t){return false}const n=t.attributes;for(let e=0;e<n.length;e++){const r=n[e].name;if(l(r,"hx-on:")||l(r,"data-hx-on:")||l(r,"hx-on-")||l(r,"data-hx-on-")){return true}}return false}const Ct=(new XPathEvaluator).createExpression('.//*[@*[ starts-with(name(), "hx-on:") or starts-with(name(), "data-hx-on:") or'+' starts-with(name(), "hx-on-") or starts-with(name(), "data-hx-on-") ]]');function Ot(e,t){if(Et(e)){t.push(ue(e))}const n=Ct.evaluate(e);let r=null;while(r=n.iterateNext())t.push(ue(r))}function Rt(e){const t=[];if(e instanceof DocumentFragment){for(const n of e.childNodes){Ot(n,t)}}else{Ot(e,t)}return t}function Ht(e){if(e.querySelectorAll){const n=", [hx-boost] a, [data-hx-boost] a, a[hx-boost], a[data-hx-boost]";const r=[];for(const i in Mn){const s=Mn[i];if(s.getSelectors){var t=s.getSelectors();if(t){r.push(t)}}}const o=e.querySelectorAll(H+n+", form, [type='submit'],"+" [hx-ext], [data-hx-ext], [hx-trigger], [data-hx-trigger]"+r.flat().map(e=>", "+e).join(""));return o}else{return[]}}function Tt(e){const t=g(ue(e.target),"button, input[type='submit']");const n=Lt(e);if(n){n.lastButtonClicked=t}}function qt(e){const t=Lt(e);if(t){t.lastButtonClicked=null}}function Lt(e){const t=g(ue(e.target),"button, input[type='submit']");if(!t){return}const n=y("#"+ee(t,"form"),t.getRootNode())||g(t,"form");if(!n){return}return ie(n)}function At(e){e.addEventListener("click",Tt);e.addEventListener("focusin",Tt);e.addEventListener("focusout",qt)}function Nt(t,e,n){const r=ie(t);if(!Array.isArray(r.onHandlers)){r.onHandlers=[]}let o;const i=function(e){vn(t,function(){if(at(t)){return}if(!o){o=new Function("event",n)}o.call(t,e)})};t.addEventListener(e,i);r.onHandlers.push({event:e,listener:i})}function It(t){ke(t);for(let e=0;e<t.attributes.length;e++){const n=t.attributes[e].name;const r=t.attributes[e].value;if(l(n,"hx-on")||l(n,"data-hx-on")){const o=n.indexOf("-on")+3;const i=n.slice(o,o+1);if(i==="-"||i===":"){let e=n.slice(o+1);if(l(e,":")){e="htmx"+e}else if(l(e,"-")){e="htmx:"+e.slice(1)}else if(l(e,"htmx-")){e="htmx:"+e.slice(5)}Nt(t,e,r)}}}}function Pt(t){if(g(t,Q.config.disableSelector)){b(t);return}const n=ie(t);const e=Pe(t);if(n.initHash!==e){De(t);n.initHash=e;he(t,"htmx:beforeProcessNode");const r=st(t);const o=wt(t,n,r);if(!o){if(re(t,"hx-boost")==="true"){ft(t,n,r)}else if(s(t,"hx-trigger")){r.forEach(function(e){St(t,e,n,function(){})})}}if(t.tagName==="FORM"||ee(t,"type")==="submit"&&s(t,"form")){At(t)}n.firstInitCompleted=true;he(t,"htmx:afterProcessNode")}}function kt(e){e=y(e);if(g(e,Q.config.disableSelector)){b(e);return}Pt(e);se(Ht(e),function(e){Pt(e)});se(Rt(e),It)}function Dt(e){return e.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase()}function Mt(e,t){let n;if(window.CustomEvent&&typeof window.CustomEvent==="function"){n=new CustomEvent(e,{bubbles:true,cancelable:true,composed:true,detail:t})}else{n=ne().createEvent("CustomEvent");n.initCustomEvent(e,true,true,t)}return n}function fe(e,t,n){he(e,t,ce({error:t},n))}function Xt(e){return e==="htmx:afterProcessNode"}function Ft(e,t){se(Un(e),function(e){try{t(e)}catch(e){O(e)}})}function O(e){if(console.error){console.error(e)}else if(console.log){console.log("ERROR: ",e)}}function he(e,t,n){e=y(e);if(n==null){n={}}n.elt=e;const r=Mt(t,n);if(Q.logger&&!Xt(t)){Q.logger(e,t,n)}if(n.error){O(n.error);he(e,"htmx:error",{errorInfo:n})}let o=e.dispatchEvent(r);const i=Dt(t);if(o&&i!==t){const s=Mt(i,r.detail);o=o&&e.dispatchEvent(s)}Ft(ue(e),function(e){o=o&&(e.onEvent(t,r)!==false&&!r.defaultPrevented)});return o}let Bt=location.pathname+location.search;function Ut(){const e=ne().querySelector("[hx-history-elt],[data-hx-history-elt]");return e||ne().body}function jt(t,e){if(!B()){return}const n=_t(e);const r=ne().title;const o=window.scrollY;if(Q.config.historyCacheSize<=0){localStorage.removeItem("htmx-history-cache");return}t=U(t);const i=S(localStorage.getItem("htmx-history-cache"))||[];for(let e=0;e<i.length;e++){if(i[e].url===t){i.splice(e,1);break}}const s={url:t,content:n,title:r,scroll:o};he(ne().body,"htmx:historyItemCreated",{item:s,cache:i});i.push(s);while(i.length>Q.config.historyCacheSize){i.shift()}while(i.length>0){try{localStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(ne().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Vt(t){if(!B()){return null}t=U(t);const n=S(localStorage.getItem("htmx-history-cache"))||[];for(let e=0;e<n.length;e++){if(n[e].url===t){return n[e]}}return null}function _t(e){const t=Q.config.requestClass;const n=e.cloneNode(true);se(x(n,"."+t),function(e){G(e,t)});se(x(n,"[data-disabled-by-htmx]"),function(e){e.removeAttribute("disabled")});return n.innerHTML}function zt(){const e=Ut();const t=Bt||location.pathname+location.search;let n;try{n=ne().querySelector('[hx-history="false" i],[data-hx-history="false" i]')}catch(e){n=ne().querySelector('[hx-history="false"],[data-hx-history="false"]')}if(!n){he(ne().body,"htmx:beforeHistorySave",{path:t,historyElt:e});jt(t,e)}if(Q.config.historyEnabled)history.replaceState({htmx:true},ne().title,window.location.href)}function $t(e){if(Q.config.getCacheBusterParam){e=e.replace(/org\.htmx\.cache-buster=[^&]*&?/,"");if(Y(e,"&")||Y(e,"?")){e=e.slice(0,-1)}}if(Q.config.historyEnabled){history.pushState({htmx:true},"",e)}Bt=e}function Jt(e){if(Q.config.historyEnabled)history.replaceState({htmx:true},"",e);Bt=e}function Kt(e){se(e,function(e){e.call(undefined)})}function Gt(o){const e=new XMLHttpRequest;const i={path:o,xhr:e};he(ne().body,"htmx:historyCacheMiss",i);e.open("GET",o,true);e.setRequestHeader("HX-Request","true");e.setRequestHeader("HX-History-Restore-Request","true");e.setRequestHeader("HX-Current-URL",ne().location.href);e.onload=function(){if(this.status>=200&&this.status<400){he(ne().body,"htmx:historyCacheMissLoad",i);const e=P(this.response);const t=e.querySelector("[hx-history-elt],[data-hx-history-elt]")||e;const n=Ut();const r=xn(n);kn(e.title);qe(e);Ve(n,t,r);Te();Kt(r.tasks);Bt=o;he(ne().body,"htmx:historyRestore",{path:o,cacheMiss:true,serverResponse:this.response})}else{fe(ne().body,"htmx:historyCacheMissLoadError",i)}};e.send()}function Wt(e){zt();e=e||location.pathname+location.search;const t=Vt(e);if(t){const n=P(t.content);const r=Ut();const o=xn(r);kn(t.title);qe(n);Ve(r,n,o);Te();Kt(o.tasks);E().setTimeout(function(){window.scrollTo(0,t.scroll)},0);Bt=e;he(ne().body,"htmx:historyRestore",{path:e,item:t})}else{if(Q.config.refreshOnHistoryMiss){window.location.reload(true)}else{Gt(e)}}}function Zt(e){let t=we(e,"hx-indicator");if(t==null){t=[e]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.classList.add.call(e.classList,Q.config.requestClass)});return t}function Yt(e){let t=we(e,"hx-disabled-elt");if(t==null){t=[]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.setAttribute("disabled","");e.setAttribute("data-disabled-by-htmx","")});return t}function Qt(e,t){se(e.concat(t),function(e){const t=ie(e);t.requestCount=(t.requestCount||1)-1});se(e,function(e){const t=ie(e);if(t.requestCount===0){e.classList.remove.call(e.classList,Q.config.requestClass)}});se(t,function(e){const t=ie(e);if(t.requestCount===0){e.removeAttribute("disabled");e.removeAttribute("data-disabled-by-htmx")}})}function en(t,n){for(let e=0;e<t.length;e++){const r=t[e];if(r.isSameNode(n)){return true}}return false}function tn(e){const t=e;if(t.name===""||t.name==null||t.disabled||g(t,"fieldset[disabled]")){return false}if(t.type==="button"||t.type==="submit"||t.tagName==="image"||t.tagName==="reset"||t.tagName==="file"){return false}if(t.type==="checkbox"||t.type==="radio"){return t.checked}return true}function nn(t,e,n){if(t!=null&&e!=null){if(Array.isArray(e)){e.forEach(function(e){n.append(t,e)})}else{n.append(t,e)}}}function rn(t,n,r){if(t!=null&&n!=null){let e=r.getAll(t);if(Array.isArray(n)){e=e.filter(e=>n.indexOf(e)<0)}else{e=e.filter(e=>e!==n)}r.delete(t);se(e,e=>r.append(t,e))}}function on(t,n,r,o,i){if(o==null||en(t,o)){return}else{t.push(o)}if(tn(o)){const s=ee(o,"name");let e=o.value;if(o instanceof HTMLSelectElement&&o.multiple){e=M(o.querySelectorAll("option:checked")).map(function(e){return e.value})}if(o instanceof HTMLInputElement&&o.files){e=M(o.files)}nn(s,e,n);if(i){sn(o,r)}}if(o instanceof HTMLFormElement){se(o.elements,function(e){if(t.indexOf(e)>=0){rn(e.name,e.value,n)}else{t.push(e)}if(i){sn(e,r)}});new FormData(o).forEach(function(e,t){if(e instanceof File&&e.name===""){return}nn(t,e,n)})}}function sn(e,t){const n=e;if(n.willValidate){he(n,"htmx:validation:validate");if(!n.checkValidity()){t.push({elt:n,message:n.validationMessage,validity:n.validity});he(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})}}}function ln(n,e){for(const t of e.keys()){n.delete(t)}e.forEach(function(e,t){n.append(t,e)});return n}function cn(e,t){const n=[];const r=new FormData;const o=new FormData;const i=[];const s=ie(e);if(s.lastButtonClicked&&!le(s.lastButtonClicked)){s.lastButtonClicked=null}let l=e instanceof HTMLFormElement&&e.noValidate!==true||te(e,"hx-validate")==="true";if(s.lastButtonClicked){l=l&&s.lastButtonClicked.formNoValidate!==true}if(t!=="get"){on(n,o,i,g(e,"form"),l)}on(n,r,i,e,l);if(s.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&ee(e,"type")==="submit"){const u=s.lastButtonClicked||e;const a=ee(u,"name");nn(a,u.value,o)}const c=we(e,"hx-include");se(c,function(e){on(n,r,i,ue(e),l);if(!h(e,"form")){se(f(e).querySelectorAll(ot),function(e){on(n,r,i,e,l)})}});ln(r,o);return{errors:i,formData:r,values:An(r)}}function un(e,t,n){if(e!==""){e+="&"}if(String(n)==="[object Object]"){n=JSON.stringify(n)}const r=encodeURIComponent(n);e+=encodeURIComponent(t)+"="+r;return e}function an(e){e=qn(e);let n="";e.forEach(function(e,t){n=un(n,t,e)});return n}function fn(e,t,n){const r={"HX-Request":"true","HX-Trigger":ee(e,"id"),"HX-Trigger-Name":ee(e,"name"),"HX-Target":te(t,"id"),"HX-Current-URL":ne().location.href};bn(e,"hx-headers",false,r);if(n!==undefined){r["HX-Prompt"]=n}if(ie(e).boosted){r["HX-Boosted"]="true"}return r}function hn(n,e){const t=re(e,"hx-params");if(t){if(t==="none"){return new FormData}else if(t==="*"){return n}else if(t.indexOf("not ")===0){se(t.slice(4).split(","),function(e){e=e.trim();n.delete(e)});return n}else{const r=new FormData;se(t.split(","),function(t){t=t.trim();if(n.has(t)){n.getAll(t).forEach(function(e){r.append(t,e)})}});return r}}else{return n}}function dn(e){return!!ee(e,"href")&&ee(e,"href").indexOf("#")>=0}function gn(e,t){const n=t||re(e,"hx-swap");const r={swapStyle:ie(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&ie(e).boosted&&!dn(e)){r.show="top"}if(n){const s=F(n);if(s.length>0){for(let e=0;e<s.length;e++){const l=s[e];if(l.indexOf("swap:")===0){r.swapDelay=d(l.slice(5))}else if(l.indexOf("settle:")===0){r.settleDelay=d(l.slice(7))}else if(l.indexOf("transition:")===0){r.transition=l.slice(11)==="true"}else if(l.indexOf("ignoreTitle:")===0){r.ignoreTitle=l.slice(12)==="true"}else if(l.indexOf("scroll:")===0){const c=l.slice(7);var o=c.split(":");const u=o.pop();var i=o.length>0?o.join(":"):null;r.scroll=u;r.scrollTarget=i}else if(l.indexOf("show:")===0){const a=l.slice(5);var o=a.split(":");const f=o.pop();var i=o.length>0?o.join(":"):null;r.show=f;r.showTarget=i}else if(l.indexOf("focus-scroll:")===0){const h=l.slice("focus-scroll:".length);r.focusScroll=h=="true"}else if(e==0){r.swapStyle=l}else{O("Unknown modifier in hx-swap: "+l)}}}}return r}function pn(e){return re(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function mn(t,n,r){let o=null;Ft(n,function(e){if(o==null){o=e.encodeParameters(t,r,n)}});if(o!=null){return o}else{if(pn(n)){return ln(new FormData,qn(r))}else{return an(r)}}}function xn(e){return{tasks:[],elts:[e]}}function yn(e,t){const n=e[0];const r=e[e.length-1];if(t.scroll){var o=null;if(t.scrollTarget){o=ue(ae(n,t.scrollTarget))}if(t.scroll==="top"&&(n||o)){o=o||n;o.scrollTop=0}if(t.scroll==="bottom"&&(r||o)){o=o||r;o.scrollTop=o.scrollHeight}}if(t.show){var o=null;if(t.showTarget){let e=t.showTarget;if(t.showTarget==="window"){e="body"}o=ue(ae(n,e))}if(t.show==="top"&&(n||o)){o=o||n;o.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(r||o)){o=o||r;o.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function bn(r,e,o,i){if(i==null){i={}}if(r==null){return i}const s=te(r,e);if(s){let e=s.trim();let t=o;if(e==="unset"){return null}if(e.indexOf("javascript:")===0){e=e.slice(11);t=true}else if(e.indexOf("js:")===0){e=e.slice(3);t=true}if(e.indexOf("{")!==0){e="{"+e+"}"}let n;if(t){n=vn(r,function(){return Function("return ("+e+")")()},{})}else{n=S(e)}for(const l in n){if(n.hasOwnProperty(l)){if(i[l]==null){i[l]=n[l]}}}}return bn(ue(c(r)),e,o,i)}function vn(e,t,n){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return n}}function wn(e,t){return bn(e,"hx-vars",true,t)}function Sn(e,t){return bn(e,"hx-vals",false,t)}function En(e){return ce(wn(e),Sn(e))}function Cn(t,n,r){if(r!==null){try{t.setRequestHeader(n,r)}catch(e){t.setRequestHeader(n,encodeURIComponent(r));t.setRequestHeader(n+"-URI-AutoEncoded","true")}}}function On(t){if(t.responseURL&&typeof URL!=="undefined"){try{const e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(ne().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function R(e,t){return t.test(e.getAllResponseHeaders())}function Rn(t,n,r){t=t.toLowerCase();if(r){if(r instanceof Element||typeof r==="string"){return de(t,n,null,null,{targetOverride:y(r)||ve,returnPromise:true})}else{let e=y(r.target);if(r.target&&!e||r.source&&!e&&!y(r.source)){e=ve}return de(t,n,y(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:e,swapOverride:r.swap,select:r.select,returnPromise:true})}}else{return de(t,n,null,null,{returnPromise:true})}}function Hn(e){const t=[];while(e){t.push(e);e=e.parentElement}return t}function Tn(e,t,n){let r;let o;if(typeof URL==="function"){o=new URL(t,document.location.href);const i=document.location.origin;r=i===o.origin}else{o=t;r=l(t,document.location.origin)}if(Q.config.selfRequestsOnly){if(!r){return false}}return he(e,"htmx:validateUrl",ce({url:o,sameHost:r},n))}function qn(e){if(e instanceof FormData)return e;const t=new FormData;for(const n in e){if(e.hasOwnProperty(n)){if(e[n]&&typeof e[n].forEach==="function"){e[n].forEach(function(e){t.append(n,e)})}else if(typeof e[n]==="object"&&!(e[n]instanceof Blob)){t.append(n,JSON.stringify(e[n]))}else{t.append(n,e[n])}}}return t}function Ln(r,o,e){return new Proxy(e,{get:function(t,e){if(typeof e==="number")return t[e];if(e==="length")return t.length;if(e==="push"){return function(e){t.push(e);r.append(o,e)}}if(typeof t[e]==="function"){return function(){t[e].apply(t,arguments);r.delete(o);t.forEach(function(e){r.append(o,e)})}}if(t[e]&&t[e].length===1){return t[e][0]}else{return t[e]}},set:function(e,t,n){e[t]=n;r.delete(o);e.forEach(function(e){r.append(o,e)});return true}})}function An(o){return new Proxy(o,{get:function(e,t){if(typeof t==="symbol"){const r=Reflect.get(e,t);if(typeof r==="function"){return function(){return r.apply(o,arguments)}}else{return r}}if(t==="toJSON"){return()=>Object.fromEntries(o)}if(t in e){if(typeof e[t]==="function"){return function(){return o[t].apply(o,arguments)}}else{return e[t]}}const n=o.getAll(t);if(n.length===0){return undefined}else if(n.length===1){return n[0]}else{return Ln(e,t,n)}},set:function(t,n,e){if(typeof n!=="string"){return false}t.delete(n);if(e&&typeof e.forEach==="function"){e.forEach(function(e){t.append(n,e)})}else if(typeof e==="object"&&!(e instanceof Blob)){t.append(n,JSON.stringify(e))}else{t.append(n,e)}return true},deleteProperty:function(e,t){if(typeof t==="string"){e.delete(t)}return true},ownKeys:function(e){return Reflect.ownKeys(Object.fromEntries(e))},getOwnPropertyDescriptor:function(e,t){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(e),t)}})}function de(t,n,r,o,i,D){let s=null;let l=null;i=i!=null?i:{};if(i.returnPromise&&typeof Promise!=="undefined"){var e=new Promise(function(e,t){s=e;l=t})}if(r==null){r=ne().body}const M=i.handler||Dn;const X=i.select||null;if(!le(r)){oe(s);return e}const c=i.targetOverride||ue(Ee(r));if(c==null||c==ve){fe(r,"htmx:targetError",{target:te(r,"hx-target")});oe(l);return e}let u=ie(r);const a=u.lastButtonClicked;if(a){const L=ee(a,"formaction");if(L!=null){n=L}const A=ee(a,"formmethod");if(A!=null){if(A.toLowerCase()!=="dialog"){t=A}}}const f=re(r,"hx-confirm");if(D===undefined){const K=function(e){return de(t,n,r,o,i,!!e)};const G={target:c,elt:r,path:n,verb:t,triggeringEvent:o,etc:i,issueRequest:K,question:f};if(he(r,"htmx:confirm",G)===false){oe(s);return e}}let h=r;let d=re(r,"hx-sync");let g=null;let F=false;if(d){const N=d.split(":");const I=N[0].trim();if(I==="this"){h=Se(r,"hx-sync")}else{h=ue(ae(r,I))}d=(N[1]||"drop").trim();u=ie(h);if(d==="drop"&&u.xhr&&u.abortable!==true){oe(s);return e}else if(d==="abort"){if(u.xhr){oe(s);return e}else{F=true}}else if(d==="replace"){he(h,"htmx:abort")}else if(d.indexOf("queue")===0){const W=d.split(" ");g=(W[1]||"last").trim()}}if(u.xhr){if(u.abortable){he(h,"htmx:abort")}else{if(g==null){if(o){const P=ie(o);if(P&&P.triggerSpec&&P.triggerSpec.queue){g=P.triggerSpec.queue}}if(g==null){g="last"}}if(u.queuedRequests==null){u.queuedRequests=[]}if(g==="first"&&u.queuedRequests.length===0){u.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="all"){u.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="last"){u.queuedRequests=[];u.queuedRequests.push(function(){de(t,n,r,o,i)})}oe(s);return e}}const p=new XMLHttpRequest;u.xhr=p;u.abortable=F;const m=function(){u.xhr=null;u.abortable=false;if(u.queuedRequests!=null&&u.queuedRequests.length>0){const e=u.queuedRequests.shift();e()}};const B=re(r,"hx-prompt");if(B){var x=prompt(B);if(x===null||!he(r,"htmx:prompt",{prompt:x,target:c})){oe(s);m();return e}}if(f&&!D){if(!confirm(f)){oe(s);m();return e}}let y=fn(r,c,x);if(t!=="get"&&!pn(r)){y["Content-Type"]="application/x-www-form-urlencoded"}if(i.headers){y=ce(y,i.headers)}const U=cn(r,t);let b=U.errors;const j=U.formData;if(i.values){ln(j,qn(i.values))}const V=qn(En(r));const v=ln(j,V);let w=hn(v,r);if(Q.config.getCacheBusterParam&&t==="get"){w.set("org.htmx.cache-buster",ee(c,"id")||"true")}if(n==null||n===""){n=ne().location.href}const S=bn(r,"hx-request");const _=ie(r).boosted;let E=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;const C={boosted:_,useUrlParams:E,formData:w,parameters:An(w),unfilteredFormData:v,unfilteredParameters:An(v),headers:y,target:c,verb:t,errors:b,withCredentials:i.credentials||S.credentials||Q.config.withCredentials,timeout:i.timeout||S.timeout||Q.config.timeout,path:n,triggeringEvent:o};if(!he(r,"htmx:configRequest",C)){oe(s);m();return e}n=C.path;t=C.verb;y=C.headers;w=qn(C.parameters);b=C.errors;E=C.useUrlParams;if(b&&b.length>0){he(r,"htmx:validation:halted",C);oe(s);m();return e}const z=n.split("#");const $=z[0];const O=z[1];let R=n;if(E){R=$;const Z=!w.keys().next().done;if(Z){if(R.indexOf("?")<0){R+="?"}else{R+="&"}R+=an(w);if(O){R+="#"+O}}}if(!Tn(r,R,C)){fe(r,"htmx:invalidPath",C);oe(l);return e}p.open(t.toUpperCase(),R,true);p.overrideMimeType("text/html");p.withCredentials=C.withCredentials;p.timeout=C.timeout;if(S.noHeaders){}else{for(const k in y){if(y.hasOwnProperty(k)){const Y=y[k];Cn(p,k,Y)}}}const H={xhr:p,target:c,requestConfig:C,etc:i,boosted:_,select:X,pathInfo:{requestPath:n,finalRequestPath:R,responsePath:null,anchor:O}};p.onload=function(){try{const t=Hn(r);H.pathInfo.responsePath=On(p);M(r,H);if(H.keepIndicators!==true){Qt(T,q)}he(r,"htmx:afterRequest",H);he(r,"htmx:afterOnLoad",H);if(!le(r)){let e=null;while(t.length>0&&e==null){const n=t.shift();if(le(n)){e=n}}if(e){he(e,"htmx:afterRequest",H);he(e,"htmx:afterOnLoad",H)}}oe(s);m()}catch(e){fe(r,"htmx:onLoadError",ce({error:e},H));throw e}};p.onerror=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendError",H);oe(l);m()};p.onabort=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendAbort",H);oe(l);m()};p.ontimeout=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:timeout",H);oe(l);m()};if(!he(r,"htmx:beforeRequest",H)){oe(s);m();return e}var T=Zt(r);var q=Yt(r);se(["loadstart","loadend","progress","abort"],function(t){se([p,p.upload],function(e){e.addEventListener(t,function(e){he(r,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});he(r,"htmx:beforeSend",H);const J=E?null:mn(p,r,w);p.send(J);return e}function Nn(e,t){const n=t.xhr;let r=null;let o=null;if(R(n,/HX-Push:/i)){r=n.getResponseHeader("HX-Push");o="push"}else if(R(n,/HX-Push-Url:/i)){r=n.getResponseHeader("HX-Push-Url");o="push"}else if(R(n,/HX-Replace-Url:/i)){r=n.getResponseHeader("HX-Replace-Url");o="replace"}if(r){if(r==="false"){return{}}else{return{type:o,path:r}}}const i=t.pathInfo.finalRequestPath;const s=t.pathInfo.responsePath;const l=re(e,"hx-push-url");const c=re(e,"hx-replace-url");const u=ie(e).boosted;let a=null;let f=null;if(l){a="push";f=l}else if(c){a="replace";f=c}else if(u){a="push";f=s||i}if(f){if(f==="false"){return{}}if(f==="true"){f=s||i}if(t.pathInfo.anchor&&f.indexOf("#")===-1){f=f+"#"+t.pathInfo.anchor}return{type:a,path:f}}else{return{}}}function In(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function Pn(e){for(var t=0;t<Q.config.responseHandling.length;t++){var n=Q.config.responseHandling[t];if(In(n,e.status)){return n}}return{swap:false}}function kn(e){if(e){const t=u("title");if(t){t.innerHTML=e}else{window.document.title=e}}}function Dn(o,i){const s=i.xhr;let l=i.target;const e=i.etc;const c=i.select;if(!he(o,"htmx:beforeOnLoad",i))return;if(R(s,/HX-Trigger:/i)){Je(s,"HX-Trigger",o)}if(R(s,/HX-Location:/i)){zt();let e=s.getResponseHeader("HX-Location");var t;if(e.indexOf("{")===0){t=S(e);e=t.path;delete t.path}Rn("get",e,t).then(function(){$t(e)});return}const n=R(s,/HX-Refresh:/i)&&s.getResponseHeader("HX-Refresh")==="true";if(R(s,/HX-Redirect:/i)){i.keepIndicators=true;location.href=s.getResponseHeader("HX-Redirect");n&&location.reload();return}if(n){i.keepIndicators=true;location.reload();return}if(R(s,/HX-Retarget:/i)){if(s.getResponseHeader("HX-Retarget")==="this"){i.target=o}else{i.target=ue(ae(o,s.getResponseHeader("HX-Retarget")))}}const u=Nn(o,i);const r=Pn(s);const a=r.swap;let f=!!r.error;let h=Q.config.ignoreTitle||r.ignoreTitle;let d=r.select;if(r.target){i.target=ue(ae(o,r.target))}var g=e.swapOverride;if(g==null&&r.swapOverride){g=r.swapOverride}if(R(s,/HX-Retarget:/i)){if(s.getResponseHeader("HX-Retarget")==="this"){i.target=o}else{i.target=ue(ae(o,s.getResponseHeader("HX-Retarget")))}}if(R(s,/HX-Reswap:/i)){g=s.getResponseHeader("HX-Reswap")}var p=s.response;var m=ce({shouldSwap:a,serverResponse:p,isError:f,ignoreTitle:h,selectOverride:d,swapOverride:g},i);if(r.event&&!he(l,r.event,m))return;if(!he(l,"htmx:beforeSwap",m))return;l=m.target;p=m.serverResponse;f=m.isError;h=m.ignoreTitle;d=m.selectOverride;g=m.swapOverride;i.target=l;i.failed=f;i.successful=!f;if(m.shouldSwap){if(s.status===286){lt(o)}Ft(o,function(e){p=e.transformResponse(p,s,o)});if(u.type){zt()}var x=gn(o,g);if(!x.hasOwnProperty("ignoreTitle")){x.ignoreTitle=h}l.classList.add(Q.config.swappingClass);let n=null;let r=null;if(c){d=c}if(R(s,/HX-Reselect:/i)){d=s.getResponseHeader("HX-Reselect")}const y=re(o,"hx-select-oob");const b=re(o,"hx-select");let e=function(){try{if(u.type){he(ne().body,"htmx:beforeHistoryUpdate",ce({history:u},i));if(u.type==="push"){$t(u.path);he(ne().body,"htmx:pushedIntoHistory",{path:u.path})}else{Jt(u.path);he(ne().body,"htmx:replacedInHistory",{path:u.path})}}$e(l,p,x,{select:d||b,selectOOB:y,eventInfo:i,anchor:i.pathInfo.anchor,contextElement:o,afterSwapCallback:function(){if(R(s,/HX-Trigger-After-Swap:/i)){let e=o;if(!le(o)){e=ne().body}Je(s,"HX-Trigger-After-Swap",e)}},afterSettleCallback:function(){if(R(s,/HX-Trigger-After-Settle:/i)){let e=o;if(!le(o)){e=ne().body}Je(s,"HX-Trigger-After-Settle",e)}oe(n)}})}catch(e){fe(o,"htmx:swapError",i);oe(r);throw e}};let t=Q.config.globalViewTransitions;if(x.hasOwnProperty("transition")){t=x.transition}if(t&&he(o,"htmx:beforeTransition",i)&&typeof Promise!=="undefined"&&document.startViewTransition){const v=new Promise(function(e,t){n=e;r=t});const w=e;e=function(){document.startViewTransition(function(){w();return v})}}if(x.swapDelay>0){E().setTimeout(e,x.swapDelay)}else{e()}}if(f){fe(o,"htmx:responseError",ce({error:"Response Status Error Code "+s.status+" from "+i.pathInfo.requestPath},i))}}const Mn={};function Xn(){return{init:function(e){return null},getSelectors:function(){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,n){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,n,r){return false},encodeParameters:function(e,t,n){return null}}}function Fn(e,t){if(t.init){t.init(n)}Mn[e]=ce(Xn(),t)}function Bn(e){delete Mn[e]}function Un(e,n,r){if(n==undefined){n=[]}if(e==undefined){return n}if(r==undefined){r=[]}const t=te(e,"hx-ext");if(t){se(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){r.push(e.slice(7));return}if(r.indexOf(e)<0){const t=Mn[e];if(t&&n.indexOf(t)<0){n.push(t)}}})}return Un(ue(c(e)),n,r)}var jn=false;ne().addEventListener("DOMContentLoaded",function(){jn=true});function Vn(e){if(jn||ne().readyState==="complete"){e()}else{ne().addEventListener("DOMContentLoaded",e)}}function _n(){if(Q.config.includeIndicatorStyles!==false){const e=Q.config.inlineStyleNonce?` nonce="${Q.config.inlineStyleNonce}"`:"";ne().head.insertAdjacentHTML("beforeend","<style"+e+"> ."+Q.config.indicatorClass+"{opacity:0} ."+Q.config.requestClass+" ."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ."+Q.config.requestClass+"."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} </style>")}}function zn(){const e=ne().querySelector('meta[name="htmx-config"]');if(e){return S(e.content)}else{return null}}function $n(){const e=zn();if(e){Q.config=ce(Q.config,e)}}Vn(function(){$n();_n();let e=ne().body;kt(e);const t=ne().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){const t=e.target;const n=ie(t);if(n&&n.xhr){n.xhr.abort()}});const n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){Wt();se(t,function(e){he(e,"htmx:restored",{document:ne(),triggerEvent:he})})}else{if(n){n(e)}}};E().setTimeout(function(){he(e,"htmx:load",{});e=null},0)});return Q}();
addedcrates/anvil-web/src/auth.rs+156 −0
1+//! Web authentication: cookie sessions, login/logout, the `CurrentUser`
2+//! extractor, and HTTP Basic auth for git push.
3+
4+use std::convert::Infallible;
5+
6+use anvil_core::{
7+ App,
8+ User,
9+ sessions,
10+ users,
11+};
12+use axum::{
13+ Form,
14+ extract::{
15+ FromRequestParts,
16+ State,
17+ },
18+ http::request::Parts,
19+ response::{
20+ IntoResponse,
21+ Redirect,
22+ Response,
23+ },
24+};
25+use axum_extra::extract::cookie::{
26+ Cookie,
27+ CookieJar,
28+ SameSite,
29+};
30+use maud::{
31+ Markup,
32+ html,
33+};
34+
35+use crate::ui::layout;
36+
37+const SESSION_COOKIE: &str = "anvil_session";
38+
39+/// Extractor yielding the logged-in user, if any, from the session cookie.
40+/// Never fails — absence of a valid session simply yields `None`.
41+pub struct CurrentUser(pub Option<User>);
42+
43+impl FromRequestParts<App> for CurrentUser {
44+ type Rejection = Infallible;
45+
46+ async fn from_request_parts(parts: &mut Parts, app: &App) -> Result<Self, Infallible> {
47+ let jar = CookieJar::from_headers(&parts.headers);
48+ let user = match jar.get(SESSION_COOKIE) {
49+ Some(cookie) => sessions::lookup_user(&app.db, cookie.value())
50+ .await
51+ .ok()
52+ .flatten(),
53+ None => None,
54+ };
55+ Ok(CurrentUser(user))
56+ }
57+}
58+
59+#[derive(serde::Deserialize)]
60+pub struct LoginForm {
61+ username: String,
62+ password: String,
63+}
64+
65+/// `GET /login` — show the login form (or bounce home if already signed in).
66+pub async fn login_form(CurrentUser(user): CurrentUser) -> Response {
67+ if user.is_some() {
68+ return Redirect::to("/").into_response();
69+ }
70+ login_page(None).into_response()
71+}
72+
73+/// `POST /login` — verify credentials, create a session, set the cookie.
74+pub async fn login_submit(
75+ State(app): State<App>,
76+ jar: CookieJar,
77+ Form(form): Form<LoginForm>,
78+) -> Response {
79+ let ok = match users::find_by_username(&app.db, &form.username).await {
80+ Ok(Some(user)) => users::verify_password(&user.password_hash, &form.password)
81+ .unwrap_or(false)
82+ .then_some(user),
83+ _ => None,
84+ };
85+
86+ let Some(user) = ok else {
87+ return (
88+ axum::http::StatusCode::UNAUTHORIZED,
89+ login_page(Some("Invalid username or password.")),
90+ )
91+ .into_response();
92+ };
93+
94+ match sessions::create(&app.db, user.id).await {
95+ Ok(session) => {
96+ let cookie = Cookie::build((SESSION_COOKIE, session.token))
97+ .path("/")
98+ .http_only(true)
99+ .same_site(SameSite::Lax)
100+ .build();
101+ (jar.add(cookie), Redirect::to("/")).into_response()
102+ }
103+ Err(e) => {
104+ tracing::error!("session create failed: {e}");
105+ (
106+ axum::http::StatusCode::INTERNAL_SERVER_ERROR,
107+ login_page(Some("Could not start a session.")),
108+ )
109+ .into_response()
110+ }
111+ }
112+}
113+
114+/// `POST /logout` — destroy the session and clear the cookie.
115+pub async fn logout(State(app): State<App>, jar: CookieJar) -> Response {
116+ if let Some(cookie) = jar.get(SESSION_COOKIE) {
117+ let _ = sessions::delete(&app.db, cookie.value()).await;
118+ }
119+ (jar.remove(Cookie::from(SESSION_COOKIE)), Redirect::to("/")).into_response()
120+}
121+
122+fn login_page(error: Option<&str>) -> Markup {
123+ layout(
124+ "Sign in",
125+ None,
126+ html! {
127+ h1 { "Sign in" }
128+ @if let Some(error) = error {
129+ p style="color:#cf222e" { (error) }
130+ }
131+ form method="post" action="/-/login" style="max-width:320px" {
132+ p { label { "Username" br; input name="username" autofocus; } }
133+ p { label { "Password" br; input name="password" type="password"; } }
134+ button type="submit" { "Sign in" }
135+ }
136+ },
137+ )
138+}
139+
140+/// Verify HTTP Basic credentials from the `Authorization` header against a user.
141+/// Returns the authenticated user, or `None` if absent/invalid.
142+pub async fn basic_auth_user(app: &App, authorization: Option<&str>) -> Option<User> {
143+ use base64::Engine;
144+
145+ let encoded = authorization?.strip_prefix("Basic ")?;
146+ let decoded = base64::engine::general_purpose::STANDARD
147+ .decode(encoded.trim())
148+ .ok()?;
149+ let creds = String::from_utf8(decoded).ok()?;
150+ let (username, password) = creds.split_once(':')?;
151+
152+ let user = users::find_by_username(&app.db, username).await.ok()??;
153+ users::verify_password(&user.password_hash, password)
154+ .unwrap_or(false)
155+ .then_some(user)
156+}
addedcrates/anvil-web/src/git_http.rs+221 −0
1+//! Smart-HTTP git endpoints: `info/refs`, `git-upload-pack` (clone/fetch), and
2+//! `git-receive-pack` (push). These adapt the transport-agnostic protocol layer
3+//! in [`anvil_git::smart_http`] to axum.
4+//!
5+//! Routes are mounted under `/{owner}/{repo}/…` where `{repo}` is the URL form
6+//! including the `.git` suffix (e.g. `/alice/hello.git/info/refs`).
7+//!
8+//! Access control: public repos may be cloned anonymously; private repos and all
9+//! pushes require HTTP Basic auth, enforced via [`anvil_core::access`].
10+
11+use std::collections::HashMap;
12+use std::path::PathBuf;
13+
14+use anvil_core::{
15+ App,
16+ Repository,
17+ access,
18+ repos,
19+ users,
20+};
21+use anvil_git::smart_http::{
22+ self,
23+ Service,
24+ UploadPack,
25+};
26+use axum::{
27+ Router,
28+ body::{
29+ Body,
30+ Bytes,
31+ },
32+ extract::{
33+ Path,
34+ Query,
35+ State,
36+ },
37+ http::{
38+ HeaderMap,
39+ StatusCode,
40+ header,
41+ },
42+ response::{
43+ IntoResponse,
44+ Response,
45+ },
46+ routing::{
47+ get,
48+ post,
49+ },
50+};
51+use tokio_util::io::ReaderStream;
52+
53+/// Mount the smart-HTTP git routes onto `router`.
54+pub fn routes(router: Router<App>) -> Router<App> {
55+ router
56+ .route("/{owner}/{repo}/info/refs", get(info_refs))
57+ .route("/{owner}/{repo}/git-upload-pack", post(upload_pack))
58+ .route("/{owner}/{repo}/git-receive-pack", post(receive_pack))
59+}
60+
61+/// Resolve `<owner>/<repo>` (repo may carry a `.git` suffix) to its on-disk path
62+/// and metadata row, rejecting traversal and missing repos.
63+async fn load_repo(app: &App, owner: &str, repo: &str) -> Result<(PathBuf, Repository), Response> {
64+ let name = repo.strip_suffix(".git").unwrap_or(repo);
65+ let bad = |s: &str| s.is_empty() || s.contains('/') || s.contains('\\') || s.contains("..");
66+ if bad(owner) || bad(name) {
67+ return Err((StatusCode::BAD_REQUEST, "invalid repository path").into_response());
68+ }
69+ let not_found = || (StatusCode::NOT_FOUND, "repository not found").into_response();
70+ let owner_user = users::find_by_username(&app.db, owner)
71+ .await
72+ .map_err(internal)?
73+ .ok_or_else(not_found)?;
74+ let meta = repos::find(&app.db, owner_user.id, name)
75+ .await
76+ .map_err(internal)?
77+ .ok_or_else(not_found)?;
78+ let path = anvil_core::storage::repo_path(&app.config.repositories_dir(), owner, name);
79+ if !path.exists() {
80+ return Err(not_found());
81+ }
82+ Ok((path, meta))
83+}
84+
85+/// Enforce access for a git request: anonymous reads are allowed for public
86+/// repos; private reads and all writes require valid Basic credentials. On
87+/// failure, returns a `401` with a `WWW-Authenticate` challenge so the git
88+/// client prompts for credentials.
89+async fn authorize(
90+ app: &App,
91+ headers: &HeaderMap,
92+ repo: &Repository,
93+ need_write: bool,
94+) -> Result<(), Response> {
95+ let authorization = headers
96+ .get(header::AUTHORIZATION)
97+ .and_then(|v| v.to_str().ok());
98+ let user = crate::auth::basic_auth_user(app, authorization).await;
99+ let allowed = if need_write {
100+ access::can_write(repo, user.as_ref())
101+ } else {
102+ access::can_read(repo, user.as_ref())
103+ };
104+ if allowed {
105+ Ok(())
106+ } else {
107+ Err(Response::builder()
108+ .status(StatusCode::UNAUTHORIZED)
109+ .header(header::WWW_AUTHENTICATE, "Basic realm=\"anvil\"")
110+ .body(Body::from("authentication required"))
111+ .unwrap())
112+ }
113+}
114+
115+/// True if the client requested git protocol v2 via the `Git-Protocol` header.
116+fn wants_v2(headers: &HeaderMap) -> bool {
117+ headers
118+ .get("git-protocol")
119+ .and_then(|v| v.to_str().ok())
120+ .map(|v| v.split(':').any(|item| item.trim() == "version=2"))
121+ .unwrap_or(false)
122+}
123+
124+fn internal(err: impl std::fmt::Display) -> Response {
125+ tracing::error!("git smart-http error: {err}");
126+ (StatusCode::INTERNAL_SERVER_ERROR, "internal server error").into_response()
127+}
128+
129+fn rpc_response(content_type: String, body: Body) -> Response {
130+ Response::builder()
131+ .status(StatusCode::OK)
132+ .header(header::CONTENT_TYPE, content_type)
133+ .header(header::CACHE_CONTROL, "no-cache")
134+ .body(body)
135+ .unwrap()
136+}
137+
138+/// `GET /{owner}/{repo}/info/refs?service=…` — ref advertisement.
139+async fn info_refs(
140+ State(app): State<App>,
141+ Path((owner, repo)): Path<(String, String)>,
142+ Query(query): Query<HashMap<String, String>>,
143+ headers: HeaderMap,
144+) -> Response {
145+ let (path, meta) = match load_repo(&app, &owner, &repo).await {
146+ Ok(v) => v,
147+ Err(resp) => return resp,
148+ };
149+ let Some(service) = query.get("service").and_then(|s| Service::from_query(s)) else {
150+ return (StatusCode::BAD_REQUEST, "missing or unsupported service").into_response();
151+ };
152+ let need_write = service == Service::ReceivePack;
153+ if let Err(resp) = authorize(&app, &headers, &meta, need_write).await {
154+ return resp;
155+ }
156+
157+ let v2 = service == Service::UploadPack && wants_v2(&headers);
158+ match smart_http::advertise(&path, service, v2) {
159+ Ok(body) => Response::builder()
160+ .status(StatusCode::OK)
161+ .header(header::CONTENT_TYPE, service.advertisement_content_type())
162+ .header(header::CACHE_CONTROL, "no-cache")
163+ .body(Body::from(body))
164+ .unwrap(),
165+ Err(e) => internal(e),
166+ }
167+}
168+
169+/// `POST /{owner}/{repo}/git-upload-pack` — clone/fetch (read access).
170+async fn upload_pack(
171+ State(app): State<App>,
172+ Path((owner, repo)): Path<(String, String)>,
173+ headers: HeaderMap,
174+ body: Bytes,
175+) -> Response {
176+ let (path, meta) = match load_repo(&app, &owner, &repo).await {
177+ Ok(v) => v,
178+ Err(resp) => return resp,
179+ };
180+ if let Err(resp) = authorize(&app, &headers, &meta, false).await {
181+ return resp;
182+ }
183+ let content_type = Service::UploadPack.result_content_type();
184+
185+ if wants_v2(&headers) {
186+ match smart_http::upload_pack_v2(&path, &body).await {
187+ Ok(UploadPack::Buffered(b)) => rpc_response(content_type, Body::from(b)),
188+ Ok(UploadPack::Pack(reader)) => {
189+ rpc_response(content_type, Body::from_stream(ReaderStream::new(reader)))
190+ }
191+ Err(e) => internal(e),
192+ }
193+ } else {
194+ match smart_http::upload_pack_v0(&path, &body).await {
195+ Ok(reader) => rpc_response(content_type, Body::from_stream(ReaderStream::new(reader))),
196+ Err(e) => internal(e),
197+ }
198+ }
199+}
200+
201+/// `POST /{owner}/{repo}/git-receive-pack` — push (write access).
202+async fn receive_pack(
203+ State(app): State<App>,
204+ Path((owner, repo)): Path<(String, String)>,
205+ headers: HeaderMap,
206+ body: Bytes,
207+) -> Response {
208+ let (path, meta) = match load_repo(&app, &owner, &repo).await {
209+ Ok(v) => v,
210+ Err(resp) => return resp,
211+ };
212+ if let Err(resp) = authorize(&app, &headers, &meta, true).await {
213+ return resp;
214+ }
215+
216+ let reader = std::io::Cursor::new(body.to_vec());
217+ match smart_http::receive_pack(&path, reader).await {
218+ Ok(b) => rpc_response(Service::ReceivePack.result_content_type(), Body::from(b)),
219+ Err(e) => internal(e),
220+ }
221+}
addedcrates/anvil-web/src/lib.rs+51 −0
1+//! HTTP server for anvil: the web UI and the smart-HTTP git endpoints
2+//! (`/<owner>/<repo>.git/info/refs`, `/git-upload-pack`, `/git-receive-pack`),
3+//! plus cookie-session auth.
4+
5+// Handlers return `Result<_, Response>` — the idiomatic axum pattern where the
6+// error arm is a ready-made HTTP response (404/500/redirect). `Response` is
7+// intrinsically large, so `result_large_err` fires across the crate; the
8+// pattern is intentional and the responses are never hot-path allocated en masse.
9+#![allow(clippy::result_large_err)]
10+
11+use anvil_core::{
12+ App,
13+ Result,
14+};
15+use axum::{
16+ Router,
17+ routing::{
18+ get,
19+ post,
20+ },
21+};
22+
23+pub mod auth;
24+pub mod git_http;
25+pub mod ui;
26+
27+/// Build the application router.
28+pub fn router(app: App) -> Router {
29+ let mut router = Router::new()
30+ .route("/-/healthz", get(healthz))
31+ .route("/-/login", get(auth::login_form).post(auth::login_submit))
32+ .route("/-/logout", post(auth::logout));
33+ router = ui::routes(router); // web UI, including `/`
34+ router = git_http::routes(router); // smart-HTTP git endpoints
35+ router.with_state(app)
36+}
37+
38+/// Bind to the configured HTTP address and serve until shutdown.
39+pub async fn serve(app: App) -> Result<()> {
40+ let listen = app.config.http.listen.clone();
41+ let router = router(app);
42+
43+ let listener = tokio::net::TcpListener::bind(&listen).await?;
44+ tracing::info!("anvil web server listening on http://{listen}");
45+ axum::serve(listener, router).await?;
46+ Ok(())
47+}
48+
49+async fn healthz() -> &'static str {
50+ "ok"
51+}
addedcrates/anvil-web/src/ui.rs+1055 −0
1+//! Server-rendered web UI (Maud): repo list, repo overview, tree browsing, and
2+//! blob viewing. Pages are plain SSR and work without JavaScript; htmx-based
3+//! progressive enhancement is a follow-up.
4+
5+use std::path::PathBuf;
6+use std::sync::OnceLock;
7+
8+use anvil_core::{
9+ App,
10+ Repository,
11+ SshKey,
12+ User,
13+ access,
14+ repos,
15+ ssh_keys,
16+ users,
17+};
18+use anvil_git::browse::{
19+ self,
20+ ChangeKind,
21+ FileChange,
22+};
23+use axum::{
24+ Form,
25+ Router,
26+ extract::{
27+ Path,
28+ State,
29+ },
30+ http::{
31+ StatusCode,
32+ header,
33+ },
34+ response::{
35+ IntoResponse,
36+ Redirect,
37+ Response,
38+ },
39+ routing::{
40+ get,
41+ post,
42+ },
43+};
44+use maud::{
45+ DOCTYPE,
46+ Markup,
47+ PreEscaped,
48+ html,
49+};
50+use similar::{
51+ ChangeTag,
52+ TextDiff,
53+};
54+use syntect::easy::HighlightLines;
55+use syntect::highlighting::{
56+ Theme,
57+ ThemeSet,
58+};
59+use syntect::html::{
60+ IncludeBackground,
61+ styled_line_to_highlighted_html,
62+};
63+use syntect::parsing::SyntaxSet;
64+use time::OffsetDateTime;
65+
66+use crate::auth::CurrentUser;
67+
68+const STYLE: &str = r#"
69+:root { --fg:#1f2328; --muted:#656d76; --bg:#fff; --border:#d0d7de; --accent:#0969da; --code-bg:#f6f8fa; }
70+* { box-sizing:border-box; }
71+body { margin:0; font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif; color:var(--fg); background:var(--bg); }
72+a { color:var(--accent); text-decoration:none; } a:hover { text-decoration:underline; }
73+header.top { border-bottom:1px solid var(--border); padding:12px 0; background:var(--code-bg); }
74+.container { max-width:980px; margin:0 auto; padding:0 16px; }
75+header.top .container { display:flex; align-items:center; gap:12px; }
76+.brand { font-weight:700; font-size:16px; color:var(--fg); }
77+main { padding:24px 0; }
78+h1,h2 { font-weight:600; } h1 { font-size:20px; } h2 { font-size:15px; margin:20px 0 8px; }
79+.muted { color:var(--muted); }
80+.repo-list { list-style:none; padding:0; margin:0; }
81+.repo-list li { padding:12px 0; border-bottom:1px solid var(--border); }
82+.repo-list .name { font-size:16px; font-weight:600; }
83+.box { border:1px solid var(--border); border-radius:6px; overflow:hidden; }
84+.box .row { display:flex; justify-content:space-between; padding:8px 16px; border-top:1px solid var(--border); }
85+.box .row:first-child { border-top:0; }
86+.box .row a.entry { display:flex; gap:8px; align-items:center; }
87+.icon { width:16px; color:var(--muted); }
88+table.code { border-collapse:collapse; width:100%; font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
89+table.code td { padding:0 10px; vertical-align:top; white-space:pre; }
90+table.code td.ln { text-align:right; color:var(--muted); user-select:none; width:1%; border-right:1px solid var(--border); background:var(--code-bg); }
91+.clone { border:1px solid var(--border); border-radius:6px; padding:12px 16px; margin:16px 0; }
92+.clone-head { display:flex; align-items:center; gap:12px; margin-bottom:8px; }
93+.clone-tabs { display:flex; gap:4px; margin-left:auto; }
94+.clone-tab { font-size:12px; padding:2px 10px; border:1px solid var(--border); border-radius:2em; background:var(--bg); color:var(--muted); cursor:pointer; }
95+.clone-tab.active { background:var(--accent); color:#fff; border-color:var(--accent); }
96+.clone-cmd { display:flex; align-items:center; gap:8px; background:var(--code-bg); border:1px solid var(--border); border-radius:6px; padding:6px 10px; }
97+.clone-cmd code { flex:1; font:12px ui-monospace,monospace; user-select:all; overflow-x:auto; white-space:nowrap; }
98+.copy-btn { display:inline-flex; align-items:center; background:none; border:0; color:var(--muted); cursor:pointer; padding:2px; }
99+.copy-btn:hover { color:var(--fg); }
100+.copied-msg { display:none; color:#1a7f37; font-size:12px; }
101+.clone.copied .copied-msg { display:inline; }
102+.clone.copied .copy-btn { color:#1a7f37; }
103+.crumbs { margin:12px 0; font:13px ui-monospace,monospace; }
104+.pill { display:inline-block; background:var(--code-bg); border:1px solid var(--border); border-radius:2em; padding:1px 8px; font-size:12px; color:var(--muted); }
105+.linkbtn { background:none; border:0; color:var(--accent); cursor:pointer; font:inherit; padding:0; }
106+.linkbtn:hover { text-decoration:underline; }
107+.btn { display:inline-block; background:var(--accent); color:#fff; border:1px solid var(--accent); border-radius:6px; padding:5px 12px; font-size:13px; cursor:pointer; }
108+.btn:hover { text-decoration:none; opacity:.92; }
109+form.stack p { margin:10px 0; } form.stack label { font-size:13px; color:var(--muted); }
110+form.stack input[type=text], form.stack textarea { width:100%; max-width:480px; padding:6px 8px; border:1px solid var(--border); border-radius:6px; font:inherit; }
111+form.stack .check { display:flex; gap:8px; align-items:flex-start; max-width:480px; }
112+.commit-list { list-style:none; padding:0; margin:0; }
113+.commit-list li { padding:8px 0; border-top:1px solid var(--border); display:flex; gap:12px; align-items:baseline; }
114+.commit-list li:first-child { border-top:0; }
115+.sha { font:12px ui-monospace,monospace; color:var(--muted); }
116+.file-diff { margin:16px 0; }
117+.file-diff .head { background:var(--code-bg); border:1px solid var(--border); border-bottom:0; border-radius:6px 6px 0 0; padding:6px 12px; font:12px ui-monospace,monospace; }
118+table.diff { border:1px solid var(--border); border-radius:0 0 6px 6px; }
119+table.diff td.sign { width:1%; text-align:center; color:var(--muted); user-select:none; }
120+table.diff tr.ins { background:#e6ffec; } table.diff tr.ins td.sign { color:#1a7f37; }
121+table.diff tr.del { background:#ffebe9; } table.diff tr.del td.sign { color:#cf222e; }
122+.badge { font-size:11px; border-radius:3px; padding:1px 6px; }
123+.badge.add { background:#dafbe1; color:#1a7f37; } .badge.del { background:#ffebe9; color:#cf222e; } .badge.mod { background:#fff8c5; color:#7d4e00; }
124+footer { color:var(--muted); font-size:12px; padding:24px 0; border-top:1px solid var(--border); margin-top:32px; }
125+"#;
126+
127+/// Clipboard icon for the clone "copy" button.
128+const CLIPBOARD_SVG: &str = r#"<svg viewBox="0 0 16 16" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M10 1.5H6a.5.5 0 0 0-.5.5v1H4A1.5 1.5 0 0 0 2.5 4.5v9A1.5 1.5 0 0 0 4 15h8a1.5 1.5 0 0 0 1.5-1.5v-9A1.5 1.5 0 0 0 12 3h-1.5V2a.5.5 0 0 0-.5-.5zm-3.5 1h3v1h-3v-1zM4 4.5h8v9H4v-9z"/></svg>"#;
129+
130+/// Delegated handlers for the clone widget: protocol toggle + copy-to-clipboard.
131+/// Registered once on `document`, so it survives htmx body swaps.
132+const CLONE_JS: &str = r#"
133+(function(){
134+ function copyText(t){
135+ if (navigator.clipboard && navigator.clipboard.writeText) return navigator.clipboard.writeText(t);
136+ var ta=document.createElement('textarea'); ta.value=t; ta.style.position='fixed'; ta.style.opacity='0';
137+ document.body.appendChild(ta); ta.focus(); ta.select();
138+ try{document.execCommand('copy')}catch(e){}
139+ document.body.removeChild(ta); return Promise.resolve();
140+ }
141+ document.addEventListener('click', function(e){
142+ var tab=e.target.closest('.clone-tab');
143+ if(tab){
144+ var box=tab.closest('.clone'), cmd=box.dataset[tab.dataset.proto];
145+ if(cmd){ box.querySelector('.clone-cmd code').textContent=cmd; }
146+ box.querySelectorAll('.clone-tab').forEach(function(t){ t.classList.toggle('active', t===tab); });
147+ return;
148+ }
149+ var copy=e.target.closest('.copy-btn');
150+ if(copy){
151+ var box=copy.closest('.clone');
152+ copyText(box.querySelector('.clone-cmd code').textContent).then(function(){
153+ box.classList.add('copied');
154+ setTimeout(function(){ box.classList.remove('copied'); }, 1300);
155+ });
156+ }
157+ });
158+})();
159+"#;
160+
161+/// Mount the web UI routes.
162+pub fn routes(router: Router<App>) -> Router<App> {
163+ router
164+ .route("/", get(home))
165+ .route("/-/settings", get(account_settings))
166+ .route("/-/settings/keys", post(add_ssh_key))
167+ .route("/-/settings/keys/{id}/delete", post(delete_ssh_key))
168+ .route("/-/new", get(new_repo_form).post(new_repo_submit))
169+ .route("/{username}", get(user_profile))
170+ .route(
171+ "/{owner}/{repo}/settings",
172+ get(repo_settings).post(repo_settings_submit),
173+ )
174+ .route("/{owner}/{repo}", get(repo_index))
175+ .route("/{owner}/{repo}/tree/{rev}", get(tree_root))
176+ .route("/{owner}/{repo}/tree/{rev}/{*path}", get(tree_path))
177+ .route("/{owner}/{repo}/blob/{rev}/{*path}", get(blob))
178+ .route("/{owner}/{repo}/commits/{rev}", get(commits))
179+ .route("/{owner}/{repo}/commit/{id}", get(commit))
180+ .route("/-/static/htmx.min.js", get(htmx_js))
181+}
182+
183+/// Serve the vendored htmx script (embedded in the binary).
184+async fn htmx_js() -> Response {
185+ (
186+ [(
187+ header::CONTENT_TYPE,
188+ "application/javascript; charset=utf-8",
189+ )],
190+ include_str!("../assets/htmx.min.js"),
191+ )
192+ .into_response()
193+}
194+
195+pub(crate) fn layout(title: &str, user: Option<&User>, body: Markup) -> Markup {
196+ html! {
197+ (DOCTYPE)
198+ html lang="en" {
199+ head {
200+ meta charset="utf-8";
201+ meta name="viewport" content="width=device-width, initial-scale=1";
202+ title { (title) " · anvil" }
203+ style { (PreEscaped(STYLE)) }
204+ }
205+ body hx-boost="true" {
206+ header.top { div.container {
207+ a.brand href="/" { "anvil" }
208+ span style="margin-left:auto" {
209+ @match user {
210+ Some(u) => {
211+ a href="/-/settings" { (u.username) }
212+ " · "
213+ form method="post" action="/-/logout" style="display:inline" {
214+ button.linkbtn type="submit" { "sign out" }
215+ }
216+ }
217+ None => { a href="/-/login" { "sign in" } }
218+ }
219+ }
220+ } }
221+ main { div.container { (body) } }
222+ footer { div.container { "anvil — a minimal git forge" } }
223+ script src="/-/static/htmx.min.js" {}
224+ script { (PreEscaped(CLONE_JS)) }
225+ }
226+ }
227+ }
228+}
229+
230+fn not_found(message: &str) -> Response {
231+ (
232+ StatusCode::NOT_FOUND,
233+ layout(
234+ "Not found",
235+ None,
236+ html! { h1 { "Not found" } p.muted { (message) } },
237+ ),
238+ )
239+ .into_response()
240+}
241+
242+fn server_error(err: impl std::fmt::Display) -> Response {
243+ tracing::error!("ui error: {err}");
244+ (
245+ StatusCode::INTERNAL_SERVER_ERROR,
246+ layout("Error", None, html! { h1 { "Something went wrong" } }),
247+ )
248+ .into_response()
249+}
250+
251+/// Resolve `<owner>/<repo>` to its on-disk path and metadata row, enforcing read
252+/// access for `viewer`. Private repos 404 for non-owners (no existence leak).
253+async fn resolve_repo(
254+ app: &App,
255+ viewer: Option<&User>,
256+ owner: &str,
257+ name: &str,
258+) -> Result<(PathBuf, Repository), Response> {
259+ let owner_user = users::find_by_username(&app.db, owner)
260+ .await
261+ .map_err(server_error)?
262+ .ok_or_else(|| not_found("no such user"))?;
263+ let repo = repos::find(&app.db, owner_user.id, name)
264+ .await
265+ .map_err(server_error)?
266+ .ok_or_else(|| not_found("no such repository"))?;
267+ if !access::can_read(&repo, viewer) {
268+ return Err(not_found("no such repository"));
269+ }
270+ let path = anvil_core::storage::repo_path(&app.config.repositories_dir(), owner, name);
271+ if !path.exists() {
272+ return Err(not_found("repository not found on disk"));
273+ }
274+ Ok((path, repo))
275+}
276+
277+/// `GET /` — list repositories visible to the current user.
278+async fn home(State(app): State<App>, CurrentUser(user): CurrentUser) -> Result<Markup, Response> {
279+ let all = repos::list_all_with_owner(&app.db)
280+ .await
281+ .map_err(server_error)?;
282+ let repos: Vec<_> = all
283+ .into_iter()
284+ .filter(|r| {
285+ !r.is_private
286+ || user
287+ .as_ref()
288+ .is_some_and(|u| u.id == r.owner_id || u.is_admin)
289+ })
290+ .collect();
291+ Ok(layout(
292+ "Repositories",
293+ user.as_ref(),
294+ html! {
295+ div style="display:flex;align-items:center" {
296+ h1 style="margin-right:auto" { "Repositories" }
297+ @if user.is_some() { a.btn href="/-/new" { "New repository" } }
298+ }
299+ @if repos.is_empty() {
300+ p.muted {
301+ "No repositories yet. "
302+ @if user.is_some() { a href="/-/new" { "Create one" } "." }
303+ @else { "Sign in to create one." }
304+ }
305+ } @else {
306+ ul.repo-list {
307+ @for r in &repos {
308+ li {
309+ div.name {
310+ a href=(format!("/{}", r.owner)) { (r.owner) }
311+ "/"
312+ a href=(format!("/{}/{}", r.owner, r.name)) { (r.name) }
313+ @if r.is_private { " " span.pill { "private" } }
314+ }
315+ @if !r.description.is_empty() { div.muted { (r.description) } }
316+ }
317+ }
318+ }
319+ }
320+ },
321+ ))
322+}
323+
324+/// `GET /{username}` — a user's profile: their repositories (public to all;
325+/// private only to themselves or an admin).
326+async fn user_profile(
327+ State(app): State<App>,
328+ CurrentUser(viewer): CurrentUser,
329+ Path(username): Path<String>,
330+) -> Result<Markup, Response> {
331+ let owner = users::find_by_username(&app.db, &username)
332+ .await
333+ .map_err(server_error)?
334+ .ok_or_else(|| not_found("no such user"))?;
335+ let visible: Vec<_> = repos::list_by_owner(&app.db, owner.id)
336+ .await
337+ .map_err(server_error)?
338+ .into_iter()
339+ .filter(|r| access::can_read(r, viewer.as_ref()))
340+ .collect();
341+ let is_self = viewer.as_ref().is_some_and(|u| u.id == owner.id);
342+
343+ Ok(layout(
344+ &owner.username,
345+ viewer.as_ref(),
346+ html! {
347+ div style="display:flex;align-items:center" {
348+ h1 style="margin-right:auto" { (owner.username) }
349+ @if is_self { a.btn href="/-/new" { "New repository" } }
350+ }
351+ @if !owner.email.is_empty() { p.muted { (owner.email) } }
352+ h2 { "Repositories" }
353+ @if visible.is_empty() {
354+ p.muted { "No repositories." }
355+ } @else {
356+ ul.repo-list {
357+ @for r in &visible {
358+ li {
359+ div.name {
360+ a href=(format!("/{}/{}", owner.username, r.name)) { (r.name) }
361+ @if r.is_private { " " span.pill { "private" } }
362+ }
363+ @if !r.description.is_empty() { div.muted { (r.description) } }
364+ }
365+ }
366+ }
367+ }
368+ },
369+ ))
370+}
371+
372+#[derive(serde::Deserialize)]
373+struct AddKeyForm {
374+ #[serde(default)]
375+ title: String,
376+ key: String,
377+}
378+
379+/// `GET /settings` — account settings: profile + SSH keys.
380+async fn account_settings(State(app): State<App>, CurrentUser(user): CurrentUser) -> Response {
381+ let Some(user) = user else {
382+ return Redirect::to("/-/login").into_response();
383+ };
384+ let keys = match ssh_keys::list_by_user(&app.db, user.id).await {
385+ Ok(keys) => keys,
386+ Err(e) => return server_error(e),
387+ };
388+ account_page(&user, &keys, None).into_response()
389+}
390+
391+/// `POST /settings/keys` — register an SSH public key for the current user.
392+async fn add_ssh_key(
393+ State(app): State<App>,
394+ CurrentUser(user): CurrentUser,
395+ Form(form): Form<AddKeyForm>,
396+) -> Response {
397+ let Some(user) = user else {
398+ return Redirect::to("/-/login").into_response();
399+ };
400+ let result = match ssh_keys::parse_public_key(&form.key) {
401+ Ok((fingerprint, content)) => {
402+ ssh_keys::add(&app.db, user.id, &form.title, &fingerprint, &content)
403+ .await
404+ .map(|_| ())
405+ }
406+ Err(e) => Err(e),
407+ };
408+ match result {
409+ Ok(()) => Redirect::to("/-/settings").into_response(),
410+ Err(e) => {
411+ let keys = ssh_keys::list_by_user(&app.db, user.id)
412+ .await
413+ .unwrap_or_default();
414+ (
415+ StatusCode::BAD_REQUEST,
416+ account_page(&user, &keys, Some(&e.to_string())),
417+ )
418+ .into_response()
419+ }
420+ }
421+}
422+
423+/// `POST /settings/keys/{id}/delete` — remove one of the current user's keys.
424+async fn delete_ssh_key(
425+ State(app): State<App>,
426+ CurrentUser(user): CurrentUser,
427+ Path(id): Path<i64>,
428+) -> Response {
429+ let Some(user) = user else {
430+ return Redirect::to("/-/login").into_response();
431+ };
432+ if let Err(e) = ssh_keys::delete(&app.db, id, user.id).await {
433+ return server_error(e);
434+ }
435+ Redirect::to("/-/settings").into_response()
436+}
437+
438+fn account_page(user: &User, keys: &[SshKey], error: Option<&str>) -> Markup {
439+ layout(
440+ "Account settings",
441+ Some(user),
442+ html! {
443+ h1 { "Account settings" }
444+ p.muted {
445+ "Signed in as " strong { (user.username) }
446+ @if !user.email.is_empty() { " · " (user.email) }
447+ }
448+
449+ h2 { "SSH keys" }
450+ p.muted { "Add a public key to clone and push over SSH." }
451+ @if let Some(error) = error { p style="color:#cf222e" { (error) } }
452+ @if keys.is_empty() {
453+ p.muted { "No SSH keys yet." }
454+ } @else {
455+ div.box {
456+ @for k in keys {
457+ div.row {
458+ div {
459+ @if !k.title.is_empty() { strong { (k.title) } " " }
460+ span.sha { (k.fingerprint) }
461+ div.muted style="font-size:12px" { "added " (fmt_time(k.created_at)) }
462+ }
463+ form method="post" action=(format!("/-/settings/keys/{}/delete", k.id)) {
464+ button.linkbtn type="submit" { "delete" }
465+ }
466+ }
467+ }
468+ }
469+ }
470+
471+ form.stack method="post" action="/-/settings/keys" style="margin-top:16px" {
472+ p { label { "Title" br; input type="text" name="title" placeholder="laptop"; } }
473+ p { label { "Public key" br; textarea name="key" rows="4" placeholder="ssh-ed25519 AAAA…" {} } }
474+ p { button.btn type="submit" { "Add SSH key" } }
475+ }
476+ },
477+ )
478+}
479+
480+fn forbidden() -> Response {
481+ (
482+ StatusCode::FORBIDDEN,
483+ layout(
484+ "Forbidden",
485+ None,
486+ html! { h1 { "Forbidden" } p.muted { "You don't have access to this." } },
487+ ),
488+ )
489+ .into_response()
490+}
491+
492+#[derive(serde::Deserialize)]
493+struct NewRepoForm {
494+ name: String,
495+ #[serde(default)]
496+ description: String,
497+ private: Option<String>,
498+}
499+
500+#[derive(serde::Deserialize)]
501+struct SettingsForm {
502+ #[serde(default)]
503+ description: String,
504+ private: Option<String>,
505+}
506+
507+/// `GET /new` — new-repository form (requires login).
508+async fn new_repo_form(CurrentUser(user): CurrentUser) -> Response {
509+ let Some(user) = user else {
510+ return Redirect::to("/-/login").into_response();
511+ };
512+ new_repo_page(&user, None, "", "", false).into_response()
513+}
514+
515+/// `POST /new` — create a repository owned by the current user.
516+async fn new_repo_submit(
517+ State(app): State<App>,
518+ CurrentUser(user): CurrentUser,
519+ Form(form): Form<NewRepoForm>,
520+) -> Response {
521+ let Some(user) = user else {
522+ return Redirect::to("/-/login").into_response();
523+ };
524+ let private = form.private.is_some();
525+ match repos::create(
526+ &app.db,
527+ &app.config.repositories_dir(),
528+ &user,
529+ &form.name,
530+ &form.description,
531+ private,
532+ )
533+ .await
534+ {
535+ Ok(repo) => Redirect::to(&format!("/{}/{}", user.username, repo.name)).into_response(),
536+ Err(e) => (
537+ StatusCode::BAD_REQUEST,
538+ new_repo_page(
539+ &user,
540+ Some(&e.to_string()),
541+ &form.name,
542+ &form.description,
543+ private,
544+ ),
545+ )
546+ .into_response(),
547+ }
548+}
549+
550+fn new_repo_page(
551+ user: &User,
552+ error: Option<&str>,
553+ name: &str,
554+ description: &str,
555+ private: bool,
556+) -> Markup {
557+ layout(
558+ "New repository",
559+ Some(user),
560+ html! {
561+ h1 { "New repository" }
562+ @if let Some(error) = error { p style="color:#cf222e" { (error) } }
563+ form.stack method="post" action="/-/new" {
564+ p { label { "Name" br; input type="text" name="name" value=(name) placeholder="my-project" autofocus; } }
565+ p { label { "Description" br; input type="text" name="description" value=(description); } }
566+ p { label.check { input type="checkbox" name="private" value="on" checked[private]; span { "Private — only you can see and push to it" } } }
567+ p { button.btn type="submit" { "Create repository" } }
568+ }
569+ p.muted { "It will be created at " code { (user.username) "/" (if name.is_empty() { "<name>" } else { name }) } "." }
570+ },
571+ )
572+}
573+
574+/// Load a repo for an owner-only settings action, enforcing write access.
575+async fn resolve_for_settings(
576+ app: &App,
577+ viewer: Option<&User>,
578+ owner: &str,
579+ name: &str,
580+) -> Result<Repository, Response> {
581+ let owner_user = users::find_by_username(&app.db, owner)
582+ .await
583+ .map_err(server_error)?
584+ .ok_or_else(|| not_found("no such repository"))?;
585+ let repo = repos::find(&app.db, owner_user.id, name)
586+ .await
587+ .map_err(server_error)?
588+ .ok_or_else(|| not_found("no such repository"))?;
589+ if !access::can_read(&repo, viewer) {
590+ return Err(not_found("no such repository"));
591+ }
592+ if !access::can_write(&repo, viewer) {
593+ return Err(forbidden());
594+ }
595+ Ok(repo)
596+}
597+
598+/// `GET /{owner}/{repo}/settings` — owner-only repository settings.
599+async fn repo_settings(
600+ State(app): State<App>,
601+ CurrentUser(user): CurrentUser,
602+ Path((owner, repo)): Path<(String, String)>,
603+) -> Response {
604+ let meta = match resolve_for_settings(&app, user.as_ref(), &owner, &repo).await {
605+ Ok(m) => m,
606+ Err(resp) => return resp,
607+ };
608+ settings_page(user.as_ref(), &owner, &repo, &meta, None).into_response()
609+}
610+
611+/// `POST /{owner}/{repo}/settings` — update description / visibility.
612+async fn repo_settings_submit(
613+ State(app): State<App>,
614+ CurrentUser(user): CurrentUser,
615+ Path((owner, repo)): Path<(String, String)>,
616+ Form(form): Form<SettingsForm>,
617+) -> Response {
618+ let meta = match resolve_for_settings(&app, user.as_ref(), &owner, &repo).await {
619+ Ok(m) => m,
620+ Err(resp) => return resp,
621+ };
622+ if let Err(e) =
623+ repos::update_settings(&app.db, meta.id, &form.description, form.private.is_some()).await
624+ {
625+ return server_error(e);
626+ }
627+ Redirect::to(&format!("/{owner}/{repo}")).into_response()
628+}
629+
630+fn settings_page(
631+ user: Option<&User>,
632+ owner: &str,
633+ repo: &str,
634+ meta: &Repository,
635+ error: Option<&str>,
636+) -> Markup {
637+ layout(
638+ &format!("{owner}/{repo}: settings"),
639+ user,
640+ html! {
641+ h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } " · settings" }
642+ @if let Some(error) = error { p style="color:#cf222e" { (error) } }
643+ form.stack method="post" action=(format!("/{owner}/{repo}/settings")) {
644+ p { label { "Description" br; input type="text" name="description" value=(meta.description); } }
645+ p { label.check { input type="checkbox" name="private" value="on" checked[meta.is_private]; span { "Private — only you can see and push to it" } } }
646+ p { button.btn type="submit" { "Save changes" } }
647+ }
648+ },
649+ )
650+}
651+
652+fn clone_box(app: &App, owner: &str, name: &str) -> Markup {
653+ let http = app.config.http_clone_url(owner, name);
654+ let ssh = app
655+ .config
656+ .ssh
657+ .enabled
658+ .then(|| app.config.ssh_clone_url(owner, name));
659+ html! {
660+ div.clone data-http=(format!("git clone {http}")) data-ssh=[ssh.as_ref().map(|s| format!("git clone {s}"))] {
661+ div.clone-head {
662+ span.muted { "Clone" }
663+ div.clone-tabs {
664+ button.clone-tab.active type="button" data-proto="http" { "HTTP" }
665+ @if ssh.is_some() {
666+ button.clone-tab type="button" data-proto="ssh" { "SSH" }
667+ }
668+ }
669+ }
670+ div.clone-cmd {
671+ code { "git clone " (http) }
672+ button.copy-btn type="button" title="Copy to clipboard" aria-label="Copy" {
673+ (PreEscaped(CLIPBOARD_SVG))
674+ }
675+ span.copied-msg { "Copied!" }
676+ }
677+ }
678+ }
679+}
680+
681+/// `GET /{owner}/{repo}` — repository overview with the root tree.
682+async fn repo_index(
683+ State(app): State<App>,
684+ CurrentUser(user): CurrentUser,
685+ Path((owner, repo)): Path<(String, String)>,
686+) -> Result<Markup, Response> {
687+ let (path, meta) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
688+ let overview = browse::overview(&path).map_err(server_error)?;
689+
690+ let can_write = access::can_write(&meta, user.as_ref());
691+ let header = html! {
692+ div style="display:flex;align-items:center;gap:8px" {
693+ h1 style="margin-right:auto" {
694+ a href="/" { "anvil" } " / "
695+ a href=(format!("/{owner}")) { (owner) } " / " (repo)
696+ @if meta.is_private { " " span.pill { "private" } }
697+ }
698+ @if can_write {
699+ a.btn href=(format!("/{owner}/{repo}/settings")) { "Settings" }
700+ }
701+ }
702+ @if !meta.description.is_empty() { p.muted { (meta.description) } }
703+ p {
704+ span.pill { (overview.branches.len()) " branches" }
705+ " "
706+ span.pill { (overview.tags.len()) " tags" }
707+ }
708+ (clone_box(&app, &owner, &repo))
709+ };
710+
711+ if overview.is_empty {
712+ return Ok(layout(
713+ &format!("{owner}/{repo}"),
714+ user.as_ref(),
715+ html! {
716+ (header)
717+ p.muted { "This repository is empty. Push to it to get started." }
718+ },
719+ ));
720+ }
721+
722+ let rev = overview
723+ .default_branch
724+ .clone()
725+ .unwrap_or_else(|| "HEAD".to_string());
726+ let entries = browse::list_tree(&path, &rev, "").map_err(server_error)?;
727+
728+ Ok(layout(
729+ &format!("{owner}/{repo}"),
730+ user.as_ref(),
731+ html! {
732+ (header)
733+ p.muted {
734+ "Branch: " (rev) " · "
735+ a href=(format!("/{owner}/{repo}/commits/{rev}")) { "commits" }
736+ }
737+ (tree_table(&owner, &repo, &rev, "", &entries))
738+ },
739+ ))
740+}
741+
742+async fn tree_root(
743+ State(app): State<App>,
744+ user: CurrentUser,
745+ Path((owner, repo, rev)): Path<(String, String, String)>,
746+) -> Result<Markup, Response> {
747+ render_tree(&app, user, &owner, &repo, &rev, "").await
748+}
749+
750+async fn tree_path(
751+ State(app): State<App>,
752+ user: CurrentUser,
753+ Path((owner, repo, rev, path)): Path<(String, String, String, String)>,
754+) -> Result<Markup, Response> {
755+ render_tree(&app, user, &owner, &repo, &rev, &path).await
756+}
757+
758+async fn render_tree(
759+ app: &App,
760+ CurrentUser(user): CurrentUser,
761+ owner: &str,
762+ repo: &str,
763+ rev: &str,
764+ path: &str,
765+) -> Result<Markup, Response> {
766+ let (repo_path, _) = resolve_repo(app, user.as_ref(), owner, repo).await?;
767+ let entries = browse::list_tree(&repo_path, rev, path).map_err(server_error)?;
768+ Ok(layout(
769+ &format!("{owner}/{repo}: {path}"),
770+ user.as_ref(),
771+ html! {
772+ h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } }
773+ (breadcrumbs(owner, repo, rev, path, false))
774+ (tree_table(owner, repo, rev, path, &entries))
775+ },
776+ ))
777+}
778+
779+/// `GET /{owner}/{repo}/blob/{rev}/{*path}` — view a file.
780+async fn blob(
781+ State(app): State<App>,
782+ CurrentUser(user): CurrentUser,
783+ Path((owner, repo, rev, path)): Path<(String, String, String, String)>,
784+) -> Result<Markup, Response> {
785+ let (repo_path, _) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
786+ let bytes = browse::read_blob(&repo_path, &rev, &path)
787+ .map_err(server_error)?
788+ .ok_or_else(|| not_found("file not found"))?;
789+
790+ let body = if is_binary(&bytes) {
791+ html! { p.muted { "Binary file (" (bytes.len()) " bytes)" } }
792+ } else {
793+ let text = String::from_utf8_lossy(&bytes);
794+ let lines = highlight(&path, &text);
795+ html! {
796+ table.code {
797+ @for (i, line) in lines.iter().enumerate() {
798+ tr {
799+ td.ln { (i + 1) }
800+ td { (PreEscaped(line)) }
801+ }
802+ }
803+ }
804+ }
805+ };
806+
807+ Ok(layout(
808+ &format!("{owner}/{repo}: {path}"),
809+ user.as_ref(),
810+ html! {
811+ h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } }
812+ (breadcrumbs(&owner, &repo, &rev, &path, true))
813+ div.box style="overflow-x:auto" { (body) }
814+ },
815+ ))
816+}
817+
818+/// Render a tree listing as a box of rows; directories link to `tree`, files to `blob`.
819+fn tree_table(
820+ owner: &str,
821+ repo: &str,
822+ rev: &str,
823+ path: &str,
824+ entries: &[browse::TreeEntry],
825+) -> Markup {
826+ let join = |name: &str| {
827+ if path.is_empty() {
828+ name.to_string()
829+ } else {
830+ format!("{path}/{name}")
831+ }
832+ };
833+ html! {
834+ div.box {
835+ @if !path.is_empty() {
836+ div.row {
837+ a.entry href=(parent_link(owner, repo, rev, path)) { span.icon { ".." } "up" }
838+ }
839+ }
840+ @for e in entries {
841+ @let child = join(&e.name);
842+ @let kind = if e.is_dir { "tree" } else { "blob" };
843+ div.row {
844+ a.entry href=(format!("/{owner}/{repo}/{kind}/{rev}/{child}")) {
845+ span.icon { (if e.is_dir { "[ ]" } else { "·" }) }
846+ (e.name) @if e.is_dir { "/" }
847+ }
848+ }
849+ }
850+ }
851+ }
852+}
853+
854+fn parent_link(owner: &str, repo: &str, rev: &str, path: &str) -> String {
855+ match path.rsplit_once('/') {
856+ Some((parent, _)) => format!("/{owner}/{repo}/tree/{rev}/{parent}"),
857+ None => format!("/{owner}/{repo}/tree/{rev}"),
858+ }
859+}
860+
861+/// Path breadcrumbs. `is_blob` marks the final component as a file.
862+fn breadcrumbs(owner: &str, repo: &str, rev: &str, path: &str, is_blob: bool) -> Markup {
863+ // Precompute (label, cumulative_path) for each path component.
864+ let mut crumbs: Vec<(String, String)> = Vec::new();
865+ let mut acc = String::new();
866+ for part in path.split('/').filter(|p| !p.is_empty()) {
867+ if !acc.is_empty() {
868+ acc.push('/');
869+ }
870+ acc.push_str(part);
871+ crumbs.push((part.to_string(), acc.clone()));
872+ }
873+ let last = crumbs.len();
874+ html! {
875+ div.crumbs {
876+ a href=(format!("/{owner}/{repo}/tree/{rev}")) { (rev) }
877+ @for (i, (label, cum)) in crumbs.iter().enumerate() {
878+ " / "
879+ @if i + 1 == last && is_blob {
880+ span { (label) }
881+ } @else {
882+ a href=(format!("/{owner}/{repo}/tree/{rev}/{cum}")) { (label) }
883+ }
884+ }
885+ }
886+ }
887+}
888+
889+/// `GET /{owner}/{repo}/commits/{rev}` — commit history.
890+async fn commits(
891+ State(app): State<App>,
892+ CurrentUser(user): CurrentUser,
893+ Path((owner, repo, rev)): Path<(String, String, String)>,
894+) -> Result<Markup, Response> {
895+ let (path, _) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
896+ let log = browse::commit_log(&path, &rev, 100).map_err(server_error)?;
897+ Ok(layout(
898+ &format!("{owner}/{repo}: commits"),
899+ user.as_ref(),
900+ html! {
901+ h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } " · commits" }
902+ ul.commit-list {
903+ @for c in &log {
904+ li {
905+ a.sha href=(format!("/{owner}/{repo}/commit/{}", c.id)) { (c.short) }
906+ span { (c.summary) }
907+ span.muted style="margin-left:auto" { (c.author) " · " (fmt_time(c.time)) }
908+ }
909+ }
910+ }
911+ },
912+ ))
913+}
914+
915+/// `GET /{owner}/{repo}/commit/{id}` — a commit with its diff.
916+async fn commit(
917+ State(app): State<App>,
918+ CurrentUser(user): CurrentUser,
919+ Path((owner, repo, id)): Path<(String, String, String)>,
920+) -> Result<Markup, Response> {
921+ let (path, _) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
922+ let detail = browse::commit_detail(&path, &id).map_err(server_error)?;
923+ Ok(layout(
924+ &format!("{owner}/{repo}: {}", detail.info.short),
925+ user.as_ref(),
926+ html! {
927+ h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } " · " span.sha { (detail.info.short) } }
928+ p { (detail.info.summary) }
929+ p.muted {
930+ (detail.info.author) " · " (fmt_time(detail.info.time)) " · "
931+ span.sha { (detail.info.id) }
932+ @if let Some(parent) = &detail.parent {
933+ " · parent " a.sha href=(format!("/{owner}/{repo}/commit/{parent}")) { (&parent[..parent.len().min(8)]) }
934+ }
935+ }
936+ @if detail.changes.is_empty() {
937+ p.muted { "No file changes." }
938+ }
939+ @for change in &detail.changes {
940+ (render_file_diff(change))
941+ }
942+ },
943+ ))
944+}
945+
946+/// Render one file's diff (added/deleted/modified) as a unified line diff.
947+fn render_file_diff(change: &FileChange) -> Markup {
948+ let (badge_cls, badge) = match change.kind {
949+ ChangeKind::Added => ("add", "added"),
950+ ChangeKind::Deleted => ("del", "deleted"),
951+ ChangeKind::Modified => ("mod", "modified"),
952+ };
953+ let binary = change.old.as_deref().is_some_and(is_binary)
954+ || change.new.as_deref().is_some_and(is_binary);
955+ html! {
956+ div.file-diff {
957+ div.head {
958+ span class=(format!("badge {badge_cls}")) { (badge) }
959+ " " (change.path)
960+ }
961+ @if binary {
962+ div.box { div.row { span.muted { "Binary file" } } }
963+ } @else {
964+ @let old = change.old.as_deref().map(|b| String::from_utf8_lossy(b).into_owned()).unwrap_or_default();
965+ @let new = change.new.as_deref().map(|b| String::from_utf8_lossy(b).into_owned()).unwrap_or_default();
966+ (unified_diff(&old, &new))
967+ }
968+ }
969+ }
970+}
971+
972+fn unified_diff(old: &str, new: &str) -> Markup {
973+ let diff = TextDiff::from_lines(old, new);
974+ html! {
975+ table.code.diff {
976+ @for change in diff.iter_all_changes() {
977+ @let (sign, cls) = match change.tag() {
978+ ChangeTag::Delete => ("-", "del"),
979+ ChangeTag::Insert => ("+", "ins"),
980+ ChangeTag::Equal => (" ", ""),
981+ };
982+ tr class=(cls) {
983+ td.sign { (sign) }
984+ td { (change.value().trim_end_matches('\n')) }
985+ }
986+ }
987+ }
988+ }
989+}
990+
991+/// Lazily-loaded syntax set and theme (pure-Rust fancy-regex backend).
992+fn highlighter() -> &'static (SyntaxSet, Theme) {
993+ static HL: OnceLock<(SyntaxSet, Theme)> = OnceLock::new();
994+ HL.get_or_init(|| {
995+ let syntaxes = SyntaxSet::load_defaults_newlines();
996+ let themes = ThemeSet::load_defaults();
997+ let theme = themes
998+ .themes
999+ .get("InspiredGitHub")
1000+ .or_else(|| themes.themes.values().next())
1001+ .cloned()
1002+ .expect("at least one default theme");
1003+ (syntaxes, theme)
1004+ })
1005+}
1006+
1007+/// Syntax-highlight `text` (chosen by file extension), returning per-line HTML.
1008+/// Falls back to escaped plain text for large files or on any failure.
1009+fn highlight(path: &str, text: &str) -> Vec<String> {
1010+ if text.len() > 512 * 1024 {
1011+ return text.lines().map(escape).collect();
1012+ }
1013+ let (syntaxes, theme) = highlighter();
1014+ let syntax = std::path::Path::new(path)
1015+ .extension()
1016+ .and_then(|e| e.to_str())
1017+ .and_then(|ext| syntaxes.find_syntax_by_extension(ext))
1018+ .or_else(|| syntaxes.find_syntax_by_first_line(text.lines().next().unwrap_or("")))
1019+ .unwrap_or_else(|| syntaxes.find_syntax_plain_text());
1020+
1021+ let mut h = HighlightLines::new(syntax, theme);
1022+ text.lines()
1023+ .map(|line| match h.highlight_line(line, syntaxes) {
1024+ Ok(ranges) => styled_line_to_highlighted_html(&ranges, IncludeBackground::No)
1025+ .unwrap_or_else(|_| escape(line)),
1026+ Err(_) => escape(line),
1027+ })
1028+ .collect()
1029+}
1030+
1031+fn escape(s: &str) -> String {
1032+ s.replace('&', "&amp;")
1033+ .replace('<', "&lt;")
1034+ .replace('>', "&gt;")
1035+}
1036+
1037+/// Format a Unix timestamp as `YYYY-MM-DD HH:MM UTC`.
1038+fn fmt_time(secs: i64) -> String {
1039+ match OffsetDateTime::from_unix_timestamp(secs) {
1040+ Ok(t) => format!(
1041+ "{:04}-{:02}-{:02} {:02}:{:02} UTC",
1042+ t.year(),
1043+ u8::from(t.month()),
1044+ t.day(),
1045+ t.hour(),
1046+ t.minute()
1047+ ),
1048+ Err(_) => secs.to_string(),
1049+ }
1050+}
1051+
1052+/// Heuristic: treat content with a NUL in the first 8 KiB as binary.
1053+fn is_binary(bytes: &[u8]) -> bool {
1054+ bytes.iter().take(8192).any(|&b| b == 0)
1055+}
addedcrates/anvil/Cargo.toml+23 −0
1+[package]
2+name = "anvil"
3+version.workspace = true
4+edition.workspace = true
5+license.workspace = true
6+repository.workspace = true
7+rust-version.workspace = true
8+description = "anvil git forge daemon and admin CLI."
9+default-run = "anvild"
10+
11+[[bin]]
12+name = "anvild"
13+path = "src/main.rs"
14+
15+[dependencies]
16+anvil-core.workspace = true
17+anvil-web.workspace = true
18+anvil-ssh.workspace = true
19+tokio.workspace = true
20+clap.workspace = true
21+anyhow.workspace = true
22+tracing.workspace = true
23+tracing-subscriber.workspace = true
addedcrates/anvil/src/main.rs+214 −0
1+//! `anvild` — the anvil git forge daemon and admin CLI.
2+
3+use anvil_core::{
4+ App,
5+ Config,
6+ repos,
7+ ssh_keys,
8+ users,
9+};
10+use anyhow::{
11+ Context,
12+ Result,
13+};
14+use clap::{
15+ Parser,
16+ Subcommand,
17+};
18+
19+#[derive(Parser)]
20+#[command(name = "anvild", version, about = "anvil git forge")]
21+struct Cli {
22+ /// Path to the configuration file (TOML). Defaults are used if absent.
23+ #[arg(long, short, default_value = "anvil.toml", global = true)]
24+ config: String,
25+
26+ /// Override the data directory from config.
27+ #[arg(long, global = true)]
28+ data_dir: Option<String>,
29+
30+ #[command(subcommand)]
31+ command: Option<Command>,
32+}
33+
34+#[derive(Subcommand)]
35+enum Command {
36+ /// Run the server (default).
37+ Serve,
38+ /// Apply database migrations and exit.
39+ Migrate,
40+ /// Manage users.
41+ User {
42+ #[command(subcommand)]
43+ command: UserCommand,
44+ },
45+ /// Manage repositories.
46+ Repo {
47+ #[command(subcommand)]
48+ command: RepoCommand,
49+ },
50+}
51+
52+#[derive(Subcommand)]
53+enum UserCommand {
54+ /// Create a new user.
55+ Create {
56+ username: String,
57+ #[arg(long, default_value = "")]
58+ email: String,
59+ #[arg(long)]
60+ password: String,
61+ #[arg(long)]
62+ admin: bool,
63+ },
64+ /// Register an SSH public key for a user (for git-over-SSH access).
65+ AddKey {
66+ username: String,
67+ /// The OpenSSH public key line. Mutually exclusive with --key-file.
68+ #[arg(long)]
69+ key: Option<String>,
70+ /// Path to a `.pub` file (e.g. ~/.ssh/id_ed25519.pub).
71+ #[arg(long)]
72+ key_file: Option<String>,
73+ #[arg(long, default_value = "")]
74+ title: String,
75+ },
76+}
77+
78+#[derive(Subcommand)]
79+enum RepoCommand {
80+ /// Create a repository, given as `owner/name`.
81+ Create {
82+ /// Repository in `owner/name` form.
83+ path: String,
84+ #[arg(long, default_value = "")]
85+ description: String,
86+ #[arg(long)]
87+ private: bool,
88+ },
89+}
90+
91+#[tokio::main]
92+async fn main() -> Result<()> {
93+ tracing_subscriber::fmt()
94+ .with_env_filter(
95+ tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
96+ )
97+ .init();
98+
99+ let cli = Cli::parse();
100+
101+ let mut config = Config::load_or_default(&cli.config)
102+ .with_context(|| format!("loading config from {}", cli.config))?;
103+ if let Some(dir) = &cli.data_dir {
104+ config.data_dir = dir.into();
105+ }
106+
107+ match cli.command.unwrap_or(Command::Serve) {
108+ Command::Serve => serve(config).await,
109+ Command::Migrate => migrate(config).await,
110+ Command::User { command } => user(config, command).await,
111+ Command::Repo { command } => repo(config, command).await,
112+ }
113+}
114+
115+async fn serve(config: Config) -> Result<()> {
116+ let app = App::bootstrap(config).await?;
117+ if app.config.ssh.enabled {
118+ // Run the HTTP and SSH servers concurrently; if either exits, stop.
119+ tokio::try_join!(anvil_web::serve(app.clone()), anvil_ssh::serve(app))?;
120+ } else {
121+ anvil_web::serve(app).await?;
122+ }
123+ Ok(())
124+}
125+
126+async fn migrate(config: Config) -> Result<()> {
127+ App::bootstrap(config).await?;
128+ println!("migrations applied");
129+ Ok(())
130+}
131+
132+async fn user(config: Config, command: UserCommand) -> Result<()> {
133+ let app = App::bootstrap(config).await?;
134+ match command {
135+ UserCommand::Create {
136+ username,
137+ email,
138+ password,
139+ admin,
140+ } => {
141+ let user = users::create(&app.db, &username, &email, &password, admin).await?;
142+ println!(
143+ "created user {} (id {}){}",
144+ user.username,
145+ user.id,
146+ if user.is_admin { " [admin]" } else { "" }
147+ );
148+ }
149+ UserCommand::AddKey {
150+ username,
151+ key,
152+ key_file,
153+ title,
154+ } => {
155+ let user = users::find_by_username(&app.db, &username)
156+ .await?
157+ .with_context(|| format!("no such user: {username}"))?;
158+ let openssh = match (key, key_file) {
159+ (Some(k), None) => k,
160+ (None, Some(path)) => std::fs::read_to_string(&path)
161+ .with_context(|| format!("reading key file {path}"))?,
162+ (Some(_), Some(_)) => anyhow::bail!("pass only one of --key / --key-file"),
163+ (None, None) => anyhow::bail!("pass --key or --key-file"),
164+ };
165+ let (fingerprint, content) = ssh_keys::parse_public_key(&openssh)?;
166+ let saved = ssh_keys::add(&app.db, user.id, &title, &fingerprint, &content).await?;
167+ println!(
168+ "added ssh key for {} ({})",
169+ user.username, saved.fingerprint
170+ );
171+ }
172+ }
173+ Ok(())
174+}
175+
176+async fn repo(config: Config, command: RepoCommand) -> Result<()> {
177+ let app = App::bootstrap(config).await?;
178+ match command {
179+ RepoCommand::Create {
180+ path,
181+ description,
182+ private,
183+ } => {
184+ let (owner_name, name) = path
185+ .split_once('/')
186+ .context("repository path must be in `owner/name` form")?;
187+ let owner = users::find_by_username(&app.db, owner_name)
188+ .await?
189+ .with_context(|| format!("no such user: {owner_name}"))?;
190+ let repo = repos::create(
191+ &app.db,
192+ &app.config.repositories_dir(),
193+ &owner,
194+ name,
195+ &description,
196+ private,
197+ )
198+ .await?;
199+ println!(
200+ "created repository {}/{} (id {}) at {}",
201+ owner.username,
202+ repo.name,
203+ repo.id,
204+ anvil_core::storage::repo_path(
205+ &app.config.repositories_dir(),
206+ &owner.username,
207+ name
208+ )
209+ .display()
210+ );
211+ }
212+ }
213+ Ok(())
214+}
addedrustfmt.toml+7 −0
1+# https://github.com/rust-lang/rustfmt/blob/master/Configurations.md
2+
3+# using nightly rustfmt options
4+# use: cargo +nightly fmt
5+
6+group_imports = "StdExternalCrate"
7+imports_layout = "Vertical"
addedvendor/gitserver-core/Cargo.toml+25 −0
1+[package]
2+name = "gitserver-core"
3+description = "Core Git protocol operations (vendored into anvil from WJQSERVER/gitserver, bumped to gix 0.84)."
4+version = "0.0.3"
5+edition = "2024"
6+license = "MPL-2.0"
7+repository = "https://github.com/WJQSERVER/gitserver"
8+
9+[dependencies]
10+# gix / gix-pack come from the anvil workspace (bumped to 0.84 / 0.71 here).
11+gix = { workspace = true }
12+gix-pack = { workspace = true }
13+tokio = { workspace = true }
14+tracing = { workspace = true }
15+serde = { workspace = true }
16+thiserror = { workspace = true }
17+miniz_oxide = "0.8"
18+sha1 = "0.10"
19+tokio-stream = "0.1"
20+tokio-util = { version = "0.7", features = ["io", "io-util"] }
21+bytes = "1"
22+
23+[dev-dependencies]
24+tempfile = "3"
25+tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
addedvendor/gitserver-core/LICENSE+373 −0
1+Mozilla Public License Version 2.0
2+==================================
3+
4+1. Definitions
5+--------------
6+
7+1.1. "Contributor"
8+ means each individual or legal entity that creates, contributes to
9+ the creation of, or owns Covered Software.
10+
11+1.2. "Contributor Version"
12+ means the combination of the Contributions of others (if any) used
13+ by a Contributor and that particular Contributor's Contribution.
14+
15+1.3. "Contribution"
16+ means Covered Software of a particular Contributor.
17+
18+1.4. "Covered Software"
19+ means Source Code Form to which the initial Contributor has attached
20+ the notice in Exhibit A, the Executable Form of such Source Code
21+ Form, and Modifications of such Source Code Form, in each case
22+ including portions thereof.
23+
24+1.5. "Incompatible With Secondary Licenses"
25+ means
26+
27+ (a) that the initial Contributor has attached the notice described
28+ in Exhibit B to the Covered Software; or
29+
30+ (b) that the Covered Software was made available under the terms of
31+ version 1.1 or earlier of the License, but not also under the
32+ terms of a Secondary License.
33+
34+1.6. "Executable Form"
35+ means any form of the work other than Source Code Form.
36+
37+1.7. "Larger Work"
38+ means a work that combines Covered Software with other material, in
39+ a separate file or files, that is not Covered Software.
40+
41+1.8. "License"
42+ means this document.
43+
44+1.9. "Licensable"
45+ means having the right to grant, to the maximum extent possible,
46+ whether at the time of the initial grant or subsequently, any and
47+ all of the rights conveyed by this License.
48+
49+1.10. "Modifications"
50+ means any of the following:
51+
52+ (a) any file in Source Code Form that results from an addition to,
53+ deletion from, or modification of the contents of Covered
54+ Software; or
55+
56+ (b) any new file in Source Code Form that contains any Covered
57+ Software.
58+
59+1.11. "Patent Claims" of a Contributor
60+ means any patent claim(s), including without limitation, method,
61+ process, and apparatus claims, in any patent Licensable by such
62+ Contributor that would be infringed, but for the grant of the
63+ License, by the making, using, selling, offering for sale, having
64+ made, import, or transfer of either its Contributions or its
65+ Contributor Version.
66+
67+1.12. "Secondary License"
68+ means either the GNU General Public License, Version 2.0, the GNU
69+ Lesser General Public License, Version 2.1, the GNU Affero General
70+ Public License, Version 3.0, or any later versions of those
71+ licenses.
72+
73+1.13. "Source Code Form"
74+ means the form of the work preferred for making modifications.
75+
76+1.14. "You" (or "Your")
77+ means an individual or a legal entity exercising rights under this
78+ License. For legal entities, "You" includes any entity that
79+ controls, is controlled by, or is under common control with You. For
80+ purposes of this definition, "control" means (a) the power, direct
81+ or indirect, to cause the direction or management of such entity,
82+ whether by contract or otherwise, or (b) ownership of more than
83+ fifty percent (50%) of the outstanding shares or beneficial
84+ ownership of such entity.
85+
86+2. License Grants and Conditions
87+--------------------------------
88+
89+2.1. Grants
90+
91+Each Contributor hereby grants You a world-wide, royalty-free,
92+non-exclusive license:
93+
94+(a) under intellectual property rights (other than patent or trademark)
95+ Licensable by such Contributor to use, reproduce, make available,
96+ modify, display, perform, distribute, and otherwise exploit its
97+ Contributions, either on an unmodified basis, with Modifications, or
98+ as part of a Larger Work; and
99+
100+(b) under Patent Claims of such Contributor to make, use, sell, offer
101+ for sale, have made, import, and otherwise transfer either its
102+ Contributions or its Contributor Version.
103+
104+2.2. Effective Date
105+
106+The licenses granted in Section 2.1 with respect to any Contribution
107+become effective for each Contribution on the date the Contributor first
108+distributes such Contribution.
109+
110+2.3. Limitations on Grant Scope
111+
112+The licenses granted in this Section 2 are the only rights granted under
113+this License. No additional rights or licenses will be implied from the
114+distribution or licensing of Covered Software under this License.
115+Notwithstanding Section 2.1(b) above, no patent license is granted by a
116+Contributor:
117+
118+(a) for any code that a Contributor has removed from Covered Software;
119+ or
120+
121+(b) for infringements caused by: (i) Your and any other third party's
122+ modifications of Covered Software, or (ii) the combination of its
123+ Contributions with other software (except as part of its Contributor
124+ Version); or
125+
126+(c) under Patent Claims infringed by Covered Software in the absence of
127+ its Contributions.
128+
129+This License does not grant any rights in the trademarks, service marks,
130+or logos of any Contributor (except as may be necessary to comply with
131+the notice requirements in Section 3.4).
132+
133+2.4. Subsequent Licenses
134+
135+No Contributor makes additional grants as a result of Your choice to
136+distribute the Covered Software under a subsequent version of this
137+License (see Section 10.2) or under the terms of a Secondary License (if
138+permitted under the terms of Section 3.3).
139+
140+2.5. Representation
141+
142+Each Contributor represents that the Contributor believes its
143+Contributions are its original creation(s) or it has sufficient rights
144+to grant the rights to its Contributions conveyed by this License.
145+
146+2.6. Fair Use
147+
148+This License is not intended to limit any rights You have under
149+applicable copyright doctrines of fair use, fair dealing, or other
150+equivalents.
151+
152+2.7. Conditions
153+
154+Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
155+in Section 2.1.
156+
157+3. Responsibilities
158+-------------------
159+
160+3.1. Distribution of Source Form
161+
162+All distribution of Covered Software in Source Code Form, including any
163+Modifications that You create or to which You contribute, must be under
164+the terms of this License. You must inform recipients that the Source
165+Code Form of the Covered Software is governed by the terms of this
166+License, and how they can obtain a copy of this License. You may not
167+attempt to alter or restrict the recipients' rights in the Source Code
168+Form.
169+
170+3.2. Distribution of Executable Form
171+
172+If You distribute Covered Software in Executable Form then:
173+
174+(a) such Covered Software must also be made available in Source Code
175+ Form, as described in Section 3.1, and You must inform recipients of
176+ the Executable Form how they can obtain a copy of such Source Code
177+ Form by reasonable means in a timely manner, at a charge no more
178+ than the cost of distribution to the recipient; and
179+
180+(b) You may distribute such Executable Form under the terms of this
181+ License, or sublicense it under different terms, provided that the
182+ license for the Executable Form does not attempt to limit or alter
183+ the recipients' rights in the Source Code Form under this License.
184+
185+3.3. Distribution of a Larger Work
186+
187+You may create and distribute a Larger Work under terms of Your choice,
188+provided that You also comply with the requirements of this License for
189+the Covered Software. If the Larger Work is a combination of Covered
190+Software with a work governed by one or more Secondary Licenses, and the
191+Covered Software is not Incompatible With Secondary Licenses, this
192+License permits You to additionally distribute such Covered Software
193+under the terms of such Secondary License(s), so that the recipient of
194+the Larger Work may, at their option, further distribute the Covered
195+Software under the terms of either this License or such Secondary
196+License(s).
197+
198+3.4. Notices
199+
200+You may not remove or alter the substance of any license notices
201+(including copyright notices, patent notices, disclaimers of warranty,
202+or limitations of liability) contained within the Source Code Form of
203+the Covered Software, except that You may alter any license notices to
204+the extent required to remedy known factual inaccuracies.
205+
206+3.5. Application of Additional Terms
207+
208+You may choose to offer, and to charge a fee for, warranty, support,
209+indemnity or liability obligations to one or more recipients of Covered
210+Software. However, You may do so only on Your own behalf, and not on
211+behalf of any Contributor. You must make it absolutely clear that any
212+such warranty, support, indemnity, or liability obligation is offered by
213+You alone, and You hereby agree to indemnify every Contributor for any
214+liability incurred by such Contributor as a result of warranty, support,
215+indemnity or liability terms You offer. You may include additional
216+disclaimers of warranty and limitations of liability specific to any
217+jurisdiction.
218+
219+4. Inability to Comply Due to Statute or Regulation
220+---------------------------------------------------
221+
222+If it is impossible for You to comply with any of the terms of this
223+License with respect to some or all of the Covered Software due to
224+statute, judicial order, or regulation then You must: (a) comply with
225+the terms of this License to the maximum extent possible; and (b)
226+describe the limitations and the code they affect. Such description must
227+be placed in a text file included with all distributions of the Covered
228+Software under this License. Except to the extent prohibited by statute
229+or regulation, such description must be sufficiently detailed for a
230+recipient of ordinary skill to be able to understand it.
231+
232+5. Termination
233+--------------
234+
235+5.1. The rights granted under this License will terminate automatically
236+if You fail to comply with any of its terms. However, if You become
237+compliant, then the rights granted under this License from a particular
238+Contributor are reinstated (a) provisionally, unless and until such
239+Contributor explicitly and finally terminates Your grants, and (b) on an
240+ongoing basis, if such Contributor fails to notify You of the
241+non-compliance by some reasonable means prior to 60 days after You have
242+come back into compliance. Moreover, Your grants from a particular
243+Contributor are reinstated on an ongoing basis if such Contributor
244+notifies You of the non-compliance by some reasonable means, this is the
245+first time You have received notice of non-compliance with this License
246+from such Contributor, and You become compliant prior to 30 days after
247+Your receipt of the notice.
248+
249+5.2. If You initiate litigation against any entity by asserting a patent
250+infringement claim (excluding declaratory judgment actions,
251+counter-claims, and cross-claims) alleging that a Contributor Version
252+directly or indirectly infringes any patent, then the rights granted to
253+You by any and all Contributors for the Covered Software under Section
254+2.1 of this License shall terminate.
255+
256+5.3. In the event of termination under Sections 5.1 or 5.2 above, all
257+end user license agreements (excluding distributors and resellers) which
258+have been validly granted by You or Your distributors under this License
259+prior to termination shall survive termination.
260+
261+************************************************************************
262+* *
263+* 6. Disclaimer of Warranty *
264+* ------------------------- *
265+* *
266+* Covered Software is provided under this License on an "as is" *
267+* basis, without warranty of any kind, either expressed, implied, or *
268+* statutory, including, without limitation, warranties that the *
269+* Covered Software is free of defects, merchantable, fit for a *
270+* particular purpose or non-infringing. The entire risk as to the *
271+* quality and performance of the Covered Software is with You. *
272+* Should any Covered Software prove defective in any respect, You *
273+* (not any Contributor) assume the cost of any necessary servicing, *
274+* repair, or correction. This disclaimer of warranty constitutes an *
275+* essential part of this License. No use of any Covered Software is *
276+* authorized under this License except under this disclaimer. *
277+* *
278+************************************************************************
279+
280+************************************************************************
281+* *
282+* 7. Limitation of Liability *
283+* -------------------------- *
284+* *
285+* Under no circumstances and under no legal theory, whether tort *
286+* (including negligence), contract, or otherwise, shall any *
287+* Contributor, or anyone who distributes Covered Software as *
288+* permitted above, be liable to You for any direct, indirect, *
289+* special, incidental, or consequential damages of any character *
290+* including, without limitation, damages for lost profits, loss of *
291+* goodwill, work stoppage, computer failure or malfunction, or any *
292+* and all other commercial damages or losses, even if such party *
293+* shall have been informed of the possibility of such damages. This *
294+* limitation of liability shall not apply to liability for death or *
295+* personal injury resulting from such party's negligence to the *
296+* extent applicable law prohibits such limitation. Some *
297+* jurisdictions do not allow the exclusion or limitation of *
298+* incidental or consequential damages, so this exclusion and *
299+* limitation may not apply to You. *
300+* *
301+************************************************************************
302+
303+8. Litigation
304+-------------
305+
306+Any litigation relating to this License may be brought only in the
307+courts of a jurisdiction where the defendant maintains its principal
308+place of business and such litigation shall be governed by laws of that
309+jurisdiction, without reference to its conflict-of-law provisions.
310+Nothing in this Section shall prevent a party's ability to bring
311+cross-claims or counter-claims.
312+
313+9. Miscellaneous
314+----------------
315+
316+This License represents the complete agreement concerning the subject
317+matter hereof. If any provision of this License is held to be
318+unenforceable, such provision shall be reformed only to the extent
319+necessary to make it enforceable. Any law or regulation which provides
320+that the language of a contract shall be construed against the drafter
321+shall not be used to construe this License against a Contributor.
322+
323+10. Versions of the License
324+---------------------------
325+
326+10.1. New Versions
327+
328+Mozilla Foundation is the license steward. Except as provided in Section
329+10.3, no one other than the license steward has the right to modify or
330+publish new versions of this License. Each version will be given a
331+distinguishing version number.
332+
333+10.2. Effect of New Versions
334+
335+You may distribute the Covered Software under the terms of the version
336+of the License under which You originally received the Covered Software,
337+or under the terms of any subsequent version published by the license
338+steward.
339+
340+10.3. Modified Versions
341+
342+If you create software not governed by this License, and you want to
343+create a new license for such software, you may create and use a
344+modified version of this License if you rename the license and remove
345+any references to the name of the license steward (except to note that
346+such modified license differs from this License).
347+
348+10.4. Distributing Source Code Form that is Incompatible With Secondary
349+Licenses
350+
351+If You choose to distribute Source Code Form that is Incompatible With
352+Secondary Licenses under the terms of this version of the License, the
353+notice described in Exhibit B of this License must be attached.
354+
355+Exhibit A - Source Code Form License Notice
356+-------------------------------------------
357+
358+ This Source Code Form is subject to the terms of the Mozilla Public
359+ License, v. 2.0. If a copy of the MPL was not distributed with this
360+ file, You can obtain one at https://mozilla.org/MPL/2.0/.
361+
362+If it is not possible or desirable to put the notice in a particular
363+file, then You may include the notice in a location (such as a LICENSE
364+file in a relevant directory) where a recipient would be likely to look
365+for such a notice.
366+
367+You may add additional accurate notices of copyright ownership.
368+
369+Exhibit B - "Incompatible With Secondary Licenses" Notice
370+---------------------------------------------------------
371+
372+ This Source Code Form is "Incompatible With Secondary Licenses", as
373+ defined by the Mozilla Public License, v. 2.0.
addedvendor/gitserver-core/NOTICE+7 −0
1+Copyright (c) 2026 WJQSERVER
2+
3+Repository licensing summary:
4+- Primary repository license: MPL-2.0 (see `LICENSE`)
5+- Upstream project: https://github.com/ggueret/git-server
6+- Upstream MIT license text is preserved in `UPSTREAM-LICENSE` and in each crate's `license/UPSTREAM-LICENSE`
7+- File-level notices and preserved upstream license files govern specific files where applicable
addedvendor/gitserver-core/UPSTREAM-LICENSE+21 −0
1+MIT License
2+
3+Copyright (c) 2026 Geoffrey Guéret
4+
5+Permission is hereby granted, free of charge, to any person obtaining a copy
6+of this software and associated documentation files (the "Software"), to deal
7+in the Software without restriction, including without limitation the rights
8+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+copies of the Software, and to permit persons to whom the Software is
10+furnished to do so, subject to the following conditions:
11+
12+The above copyright notice and this permission notice shall be included in all
13+copies or substantial portions of the Software.
14+
15+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+SOFTWARE.
addedvendor/gitserver-core/VENDOR.md+27 −0
1+# Vendored: gitserver-core
2+
3+This is a vendored, modified copy of the `gitserver-core` crate from
4+[WJQSERVER/gitserver](https://github.com/WJQSERVER/gitserver), which implements
5+the server side of the git wire protocol (pkt-line, protocol v2 `ls-refs` /
6+`fetch` / `upload-pack`, and `receive-pack`) on top of `gix` — the piece gix
7+itself does not provide.
8+
9+- **Upstream commit:** `3cec05b02eea3220c67782e4d9d0404d8b001aa3` (2026-04-27)
10+- **License:** MPL-2.0 (see `LICENSE`); upstream MIT material noted in
11+ `UPSTREAM-LICENSE` / `NOTICE`. MPL is file-level copyleft: modifications to
12+ these files stay MPL, but anvil as a whole may carry its own license.
13+
14+## Why vendored
15+
16+We vendor rather than depend on crates.io so we can (1) bump `gix` from the
17+upstream-pinned 0.80 to the workspace version (0.84) and keep it in lockstep
18+with the rest of anvil, and (2) modify the protocol code as `anvil-git` evolves.
19+
20+`anvil-git` wraps this crate behind its own interface so the dependency can be
21+incrementally replaced with an upstream-shaped, gix-native implementation.
22+
23+## Local modifications
24+
25+- `Cargo.toml`: standalone manifest using the anvil workspace's `gix` (0.84) and
26+ `gix-pack` (0.71) instead of upstream's pinned 0.80 / 0.67.
27+- Source changes required by the gix version bump are recorded in git history.
addedvendor/gitserver-core/src/backend.rs+251 −0
1+use std::path::PathBuf;
2+use std::pin::Pin;
3+use std::sync::Arc;
4+use std::sync::atomic::{
5+ AtomicBool,
6+ Ordering,
7+};
8+use std::task::{
9+ Context,
10+ Poll,
11+};
12+
13+use tokio::io::AsyncRead;
14+use tokio::time::{
15+ Duration,
16+ Sleep,
17+ sleep,
18+};
19+use tokio_util::io::SyncIoBridge;
20+
21+use crate::error::Result;
22+use crate::pack::UploadPackRequest;
23+
24+pub const RECEIVE_PACK_TIMEOUT: Duration = Duration::from_secs(300);
25+const RECEIVE_PACK_IDLE_TIMEOUT: Duration = Duration::from_secs(30);
26+
27+struct TimedAsyncRead<R> {
28+ inner: R,
29+ timeout: Duration,
30+ sleep: Option<Pin<Box<Sleep>>>,
31+ interrupt: Arc<AtomicBool>,
32+}
33+
34+impl<R> TimedAsyncRead<R> {
35+ fn new(inner: R, timeout: Duration, interrupt: Arc<AtomicBool>) -> Self {
36+ Self {
37+ inner,
38+ timeout,
39+ sleep: None,
40+ interrupt,
41+ }
42+ }
43+}
44+
45+impl<R> AsyncRead for TimedAsyncRead<R>
46+where
47+ R: AsyncRead + Unpin,
48+{
49+ fn poll_read(
50+ mut self: Pin<&mut Self>,
51+ cx: &mut Context<'_>,
52+ buf: &mut tokio::io::ReadBuf<'_>,
53+ ) -> Poll<std::io::Result<()>> {
54+ if self.sleep.is_none() {
55+ self.sleep = Some(Box::pin(sleep(self.timeout)));
56+ }
57+
58+ let before = buf.filled().len();
59+ match Pin::new(&mut self.inner).poll_read(cx, buf) {
60+ Poll::Ready(Ok(())) => {
61+ if buf.filled().len() > before {
62+ self.sleep = None;
63+ }
64+ Poll::Ready(Ok(()))
65+ }
66+ Poll::Ready(Err(err)) => Poll::Ready(Err(err)),
67+ Poll::Pending => {
68+ if self
69+ .sleep
70+ .as_mut()
71+ .expect("timeout sleep must exist")
72+ .as_mut()
73+ .poll(cx)
74+ .is_ready()
75+ {
76+ self.interrupt.store(true, Ordering::Relaxed);
77+ Poll::Ready(Err(std::io::Error::new(
78+ std::io::ErrorKind::TimedOut,
79+ "receive-pack read timed out",
80+ )))
81+ } else {
82+ Poll::Pending
83+ }
84+ }
85+ }
86+ }
87+}
88+
89+pub struct GitBackend {
90+ repo_path: PathBuf,
91+}
92+
93+impl GitBackend {
94+ pub fn new(repo_path: PathBuf) -> Self {
95+ Self { repo_path }
96+ }
97+
98+ pub fn advertise_refs(&self) -> Result<Vec<u8>> {
99+ crate::refs::advertise_refs(&self.repo_path)
100+ }
101+
102+ pub fn advertise_receive_refs(&self) -> Result<Vec<u8>> {
103+ crate::receive_pack::advertise_receive_refs(&self.repo_path)
104+ }
105+
106+ pub async fn upload_pack(&self, request: &UploadPackRequest) -> Result<impl AsyncRead + use<>> {
107+ crate::pack::generate_pack(&self.repo_path, request)
108+ }
109+
110+ pub async fn receive_pack<R>(&self, request: R) -> Result<Vec<u8>>
111+ where
112+ R: AsyncRead + Unpin + Send + 'static,
113+ {
114+ self.receive_pack_with_timeout(request, RECEIVE_PACK_TIMEOUT)
115+ .await
116+ }
117+
118+ async fn receive_pack_with_timeout<R>(
119+ &self,
120+ request: R,
121+ timeout_duration: Duration,
122+ ) -> Result<Vec<u8>>
123+ where
124+ R: AsyncRead + Unpin + Send + 'static,
125+ {
126+ let repo_path = self.repo_path.clone();
127+ let interrupt = Arc::new(AtomicBool::new(false));
128+ let watchdog_interrupt = interrupt.clone();
129+ let watchdog = tokio::spawn(async move {
130+ sleep(timeout_duration).await;
131+ watchdog_interrupt.store(true, Ordering::Relaxed);
132+ });
133+
134+ let join = tokio::task::spawn_blocking(move || {
135+ let request =
136+ TimedAsyncRead::new(request, RECEIVE_PACK_IDLE_TIMEOUT, interrupt.clone());
137+ let mut request = SyncIoBridge::new(request);
138+ crate::receive_pack::receive_pack_with_interrupt(
139+ &repo_path,
140+ &mut request,
141+ interrupt.as_ref(),
142+ )
143+ })
144+ .await
145+ .map_err(|e| crate::error::Error::Protocol(format!("receive-pack task panicked: {e}")));
146+
147+ watchdog.abort();
148+ join?
149+ }
150+}
151+
152+#[cfg(test)]
153+mod tests {
154+ use std::process::Command;
155+
156+ use tempfile::TempDir;
157+
158+ use super::*;
159+
160+ fn create_repo_with_commit(root: &std::path::Path) -> PathBuf {
161+ let repo_path = root.join("test.git");
162+ let work_dir = root.join("work");
163+ std::fs::create_dir(&work_dir).unwrap();
164+ Command::new("git")
165+ .args(["init", "--bare", repo_path.to_str().unwrap()])
166+ .output()
167+ .unwrap();
168+ Command::new("git")
169+ .args(["symbolic-ref", "HEAD", "refs/heads/main"])
170+ .current_dir(&repo_path)
171+ .output()
172+ .unwrap();
173+ Command::new("git")
174+ .args([
175+ "clone",
176+ repo_path.to_str().unwrap(),
177+ work_dir.to_str().unwrap(),
178+ ])
179+ .output()
180+ .unwrap();
181+ Command::new("git")
182+ .current_dir(&work_dir)
183+ .args(["commit", "--allow-empty", "-m", "init"])
184+ .env("GIT_AUTHOR_NAME", "Test")
185+ .env("GIT_AUTHOR_EMAIL", "t@t.com")
186+ .env("GIT_COMMITTER_NAME", "Test")
187+ .env("GIT_COMMITTER_EMAIL", "t@t.com")
188+ .output()
189+ .unwrap();
190+ Command::new("git")
191+ .current_dir(&work_dir)
192+ .args(["push", "origin", "main"])
193+ .output()
194+ .unwrap();
195+ repo_path
196+ }
197+
198+ #[test]
199+ fn backend_advertise_refs() {
200+ let root = TempDir::new().unwrap();
201+ let repo_path = create_repo_with_commit(root.path());
202+ let backend = GitBackend::new(repo_path);
203+ let output = backend.advertise_refs().unwrap();
204+ let output_str = String::from_utf8_lossy(&output);
205+ assert!(output_str.contains("refs/heads/main"));
206+ }
207+
208+ #[tokio::test]
209+ async fn backend_upload_pack() {
210+ let root = TempDir::new().unwrap();
211+ let repo_path = create_repo_with_commit(root.path());
212+ let repo = gix::open(&repo_path).unwrap();
213+ let head = repo.head_id().unwrap();
214+
215+ let backend = GitBackend::new(repo_path);
216+ let request = UploadPackRequest {
217+ wants: vec![head.detach()],
218+ haves: vec![],
219+ done: true,
220+ capabilities: Default::default(),
221+ shallow: Default::default(),
222+ object_ids: None,
223+ };
224+ let reader = backend.upload_pack(&request).await.unwrap();
225+ let mut buf = Vec::new();
226+ tokio::io::AsyncReadExt::read_to_end(&mut tokio::io::BufReader::new(reader), &mut buf)
227+ .await
228+ .unwrap();
229+ assert!(buf.windows(4).any(|w| w == b"PACK"));
230+ }
231+
232+ #[tokio::test]
233+ async fn backend_receive_pack_times_out_on_stalled_reader() {
234+ let root = TempDir::new().unwrap();
235+ let repo_path = create_repo_with_commit(root.path());
236+ let backend = GitBackend::new(repo_path);
237+ let (reader, _writer) = tokio::io::duplex(1);
238+
239+ let err = backend
240+ .receive_pack_with_timeout(reader, Duration::from_millis(50))
241+ .await
242+ .unwrap_err();
243+
244+ match err {
245+ crate::error::Error::Io(inner) => {
246+ assert_eq!(inner.kind(), std::io::ErrorKind::TimedOut);
247+ }
248+ other => panic!("expected timeout io error, got {other}"),
249+ }
250+ }
251+}
addedvendor/gitserver-core/src/discovery.rs+305 −0
1+use std::fs;
2+use std::path::{
3+ Path,
4+ PathBuf,
5+};
6+
7+use serde::Serialize;
8+
9+pub use crate::dynamic_registry::{
10+ DynamicRepoRegistry,
11+ MutableRepoRegistry,
12+ RepoResolver,
13+};
14+use crate::error::{
15+ Error,
16+ Result,
17+};
18+
19+const DEFAULT_GIT_DESCRIPTION: &str =
20+ "Unnamed repository; edit this file 'description' to name the repository.";
21+
22+/// Information about a discovered bare git repository.
23+#[derive(Debug, Clone, Serialize)]
24+pub struct RepoInfo {
25+ pub name: String,
26+ pub relative_path: String,
27+ #[serde(skip)]
28+ pub absolute_path: PathBuf,
29+ #[serde(skip_serializing_if = "Option::is_none")]
30+ pub description: Option<String>,
31+}
32+
33+/// A store of discovered repositories under a root directory.
34+#[derive(Debug, Clone)]
35+pub struct RepoStore {
36+ root: PathBuf,
37+ max_depth: u32,
38+ repos: Vec<RepoInfo>,
39+}
40+
41+impl RepoStore {
42+ /// Scan `root` recursively up to `max_depth` levels for bare git repositories.
43+ ///
44+ /// `max_depth = 0` means only repositories directly inside `root`.
45+ /// `max_depth = 3` means up to 3 levels of subdirectories below `root`.
46+ pub fn discover(root: PathBuf, max_depth: u32) -> Result<Self> {
47+ let root = root.canonicalize()?;
48+ let mut repos = Vec::new();
49+
50+ // Walk starts at depth 0 (root itself). We scan children of root at depth 1,
51+ // and allow descending up to max_depth subdirectory levels below root.
52+ walk_dir(&root, &root, 0, max_depth, &mut repos)?;
53+
54+ repos.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
55+
56+ Ok(Self {
57+ root,
58+ max_depth,
59+ repos,
60+ })
61+ }
62+
63+ /// Resolve a relative path to a `RepoInfo`.
64+ ///
65+ /// Uses `crate::path::resolve_repo_path` for validation, then matches by
66+ /// canonical absolute path.
67+ pub fn resolve(&self, relative: &str) -> Result<&RepoInfo> {
68+ let canonical = crate::path::resolve_repo_path(&self.root, relative)?;
69+ self.repos
70+ .iter()
71+ .find(|r| r.absolute_path == canonical)
72+ .ok_or_else(|| Error::RepoNotFound(relative.to_string()))
73+ }
74+
75+ /// Returns all discovered repositories, sorted by relative path.
76+ pub fn list(&self) -> &[RepoInfo] {
77+ &self.repos
78+ }
79+
80+ /// Returns the root directory used for discovery.
81+ pub fn root(&self) -> &Path {
82+ &self.root
83+ }
84+
85+ /// Returns the configured maximum discovery depth.
86+ pub fn max_depth(&self) -> u32 {
87+ self.max_depth
88+ }
89+
90+ /// Re-run repository discovery using the original root and max depth.
91+ pub fn refresh(&mut self) -> Result<()> {
92+ let refreshed = Self::discover(self.root.clone(), self.max_depth)?;
93+ self.repos = refreshed.repos;
94+ Ok(())
95+ }
96+}
97+
98+/// Recursively walk `dir`, recording bare repositories.
99+///
100+/// `depth` is the current depth relative to `root` (root itself is depth 0).
101+/// Entries *inside* root are at depth 1. We descend while `depth <= max_depth`.
102+fn walk_dir(
103+ root: &Path,
104+ dir: &Path,
105+ depth: u32,
106+ max_depth: u32,
107+ repos: &mut Vec<RepoInfo>,
108+) -> Result<()> {
109+ let read = match fs::read_dir(dir) {
110+ Ok(r) => r,
111+ Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => return Ok(()),
112+ Err(e) => return Err(Error::Io(e)),
113+ };
114+
115+ for entry in read {
116+ let entry = entry?;
117+ let path = entry.path();
118+
119+ let metadata = match fs::metadata(&path) {
120+ Ok(m) => m,
121+ Err(_) => continue,
122+ };
123+
124+ if !metadata.is_dir() {
125+ continue;
126+ }
127+
128+ // Try to open as a git repository.
129+ match gix::open(&path) {
130+ Ok(repo) if repo.is_bare() => {
131+ let absolute_path = path.canonicalize()?;
132+ let relative_path = absolute_path
133+ .strip_prefix(root)
134+ .expect("discovered path must be inside root")
135+ .to_string_lossy()
136+ .into_owned();
137+ let name = absolute_path
138+ .file_name()
139+ .map(|n| n.to_string_lossy().into_owned())
140+ .unwrap_or_else(|| relative_path.clone());
141+ let description = read_description(&absolute_path);
142+
143+ repos.push(RepoInfo {
144+ name,
145+ relative_path,
146+ absolute_path,
147+ description,
148+ });
149+ // Do not descend into a repository directory.
150+ }
151+ _ => {
152+ // Not a bare repo (or open failed). Descend if within max_depth.
153+ if depth < max_depth {
154+ walk_dir(root, &path, depth + 1, max_depth, repos)?;
155+ }
156+ }
157+ }
158+ }
159+
160+ Ok(())
161+}
162+
163+/// Read the `description` file from a bare repository directory.
164+///
165+/// Returns `None` if the file is absent, unreadable, or contains the default
166+/// git placeholder text.
167+fn read_description(repo_path: &Path) -> Option<String> {
168+ let desc_path = repo_path.join("description");
169+ let content = fs::read_to_string(&desc_path).ok()?;
170+ let trimmed = content.trim().to_string();
171+ if trimmed.is_empty() || trimmed == DEFAULT_GIT_DESCRIPTION {
172+ None
173+ } else {
174+ Some(trimmed)
175+ }
176+}
177+
178+#[cfg(test)]
179+mod tests {
180+ use std::path::Path;
181+ use std::process::Command;
182+
183+ use tempfile::TempDir;
184+
185+ use super::*;
186+
187+ fn create_bare_repo(path: &Path) {
188+ Command::new("git")
189+ .args(["init", "--bare", path.to_str().unwrap()])
190+ .output()
191+ .expect("git init --bare failed");
192+ }
193+
194+ #[test]
195+ fn discover_finds_bare_repos() {
196+ let dir = TempDir::new().unwrap();
197+ create_bare_repo(&dir.path().join("alpha.git"));
198+ create_bare_repo(&dir.path().join("beta.git"));
199+
200+ let store = RepoStore::discover(dir.path().to_path_buf(), 0).unwrap();
201+ assert_eq!(store.list().len(), 2);
202+ }
203+
204+ #[test]
205+ fn discover_finds_nested_repos() {
206+ let dir = TempDir::new().unwrap();
207+ let repo_path = dir.path().join("org").join("project.git");
208+ std::fs::create_dir_all(&repo_path).unwrap();
209+ create_bare_repo(&repo_path);
210+
211+ let store = RepoStore::discover(dir.path().to_path_buf(), 1).unwrap();
212+ assert_eq!(store.list().len(), 1);
213+ assert_eq!(store.list()[0].relative_path, "org/project.git");
214+ }
215+
216+ #[test]
217+ fn discover_respects_max_depth() {
218+ let dir = TempDir::new().unwrap();
219+ let deep = dir.path().join("a").join("b").join("c").join("deep.git");
220+ std::fs::create_dir_all(&deep).unwrap();
221+ create_bare_repo(&deep);
222+
223+ // max_depth 2 should not find it (it is 3 levels below root)
224+ let store_shallow = RepoStore::discover(dir.path().to_path_buf(), 2).unwrap();
225+ assert_eq!(store_shallow.list().len(), 0);
226+
227+ // max_depth 3 should find it
228+ let store_deep = RepoStore::discover(dir.path().to_path_buf(), 3).unwrap();
229+ assert_eq!(store_deep.list().len(), 1);
230+ }
231+
232+ #[test]
233+ fn discover_max_depth_zero_only_root_level() {
234+ let dir = TempDir::new().unwrap();
235+ create_bare_repo(&dir.path().join("root-level.git"));
236+ let nested = dir.path().join("nested").join("deep.git");
237+ std::fs::create_dir_all(&nested).unwrap();
238+ create_bare_repo(&nested);
239+
240+ let store = RepoStore::discover(dir.path().to_path_buf(), 0).unwrap();
241+ assert_eq!(store.list().len(), 1);
242+ assert_eq!(store.list()[0].relative_path, "root-level.git");
243+ }
244+
245+ #[test]
246+ fn discover_ignores_non_bare_dirs() {
247+ let dir = TempDir::new().unwrap();
248+ // A plain directory -- not a git repo
249+ std::fs::create_dir(dir.path().join("just-a-dir")).unwrap();
250+
251+ let store = RepoStore::discover(dir.path().to_path_buf(), 0).unwrap();
252+ assert_eq!(store.list().len(), 0);
253+ }
254+
255+ #[test]
256+ fn resolve_existing_repo() {
257+ let dir = TempDir::new().unwrap();
258+ create_bare_repo(&dir.path().join("myrepo.git"));
259+
260+ let store = RepoStore::discover(dir.path().to_path_buf(), 0).unwrap();
261+ let info = store.resolve("myrepo.git").unwrap();
262+ assert_eq!(info.relative_path, "myrepo.git");
263+ assert_eq!(info.name, "myrepo.git");
264+ }
265+
266+ #[test]
267+ fn resolve_missing_repo() {
268+ let dir = TempDir::new().unwrap();
269+ create_bare_repo(&dir.path().join("exists.git"));
270+
271+ let store = RepoStore::discover(dir.path().to_path_buf(), 0).unwrap();
272+ let err = store.resolve("nope.git").unwrap_err();
273+ assert!(matches!(err, Error::RepoNotFound(_)));
274+ }
275+
276+ #[test]
277+ fn reads_description_file() {
278+ let dir = TempDir::new().unwrap();
279+ let repo_path = dir.path().join("described.git");
280+ create_bare_repo(&repo_path);
281+ std::fs::write(repo_path.join("description"), "A test repository\n").unwrap();
282+
283+ let store = RepoStore::discover(dir.path().to_path_buf(), 0).unwrap();
284+ assert_eq!(store.list().len(), 1);
285+ assert_eq!(
286+ store.list()[0].description.as_deref(),
287+ Some("A test repository")
288+ );
289+ }
290+
291+ #[test]
292+ fn refresh_picks_up_new_repositories() {
293+ let dir = TempDir::new().unwrap();
294+ create_bare_repo(&dir.path().join("alpha.git"));
295+
296+ let mut store = RepoStore::discover(dir.path().to_path_buf(), 0).unwrap();
297+ assert_eq!(store.list().len(), 1);
298+
299+ create_bare_repo(&dir.path().join("beta.git"));
300+ store.refresh().unwrap();
301+
302+ assert_eq!(store.list().len(), 2);
303+ assert!(store.list().iter().any(|repo| repo.name == "beta.git"));
304+ }
305+}
addedvendor/gitserver-core/src/dynamic_registry.rs+275 −0
1+// This Source Code Form is subject to the terms of the Mozilla Public
2+// License, v. 2.0. If a copy of the MPL was not distributed with this
3+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+//
5+// Copyright (c) 2026 WJQSERVER
6+
7+use std::path::{
8+ Path,
9+ PathBuf,
10+};
11+use std::sync::{
12+ Arc,
13+ RwLock,
14+};
15+
16+use crate::discovery::{
17+ RepoInfo,
18+ RepoStore,
19+};
20+use crate::error::{
21+ Error,
22+ Result,
23+};
24+
25+pub trait RepoResolver: Send + Sync {
26+ fn resolve(&self, relative: &str) -> Result<RepoInfo>;
27+ fn list(&self) -> Result<Vec<RepoInfo>>;
28+}
29+
30+pub trait MutableRepoRegistry: RepoResolver {
31+ fn register(&self, repo: RepoInfo) -> Result<()>;
32+ fn unregister(&self, relative: &str) -> Result<()>;
33+}
34+
35+#[derive(Clone, Default)]
36+pub struct DynamicRepoRegistry {
37+ repos: Arc<RwLock<Vec<RepoInfo>>>,
38+}
39+
40+impl DynamicRepoRegistry {
41+ pub fn new() -> Self {
42+ Self::default()
43+ }
44+
45+ pub fn from_repos(repos: Vec<RepoInfo>) -> Result<Self> {
46+ let registry = Self::new();
47+ for repo in repos {
48+ registry.register(repo)?;
49+ }
50+ Ok(registry)
51+ }
52+}
53+
54+impl RepoResolver for RepoStore {
55+ fn resolve(&self, relative: &str) -> Result<RepoInfo> {
56+ RepoStore::resolve(self, relative).cloned()
57+ }
58+
59+ fn list(&self) -> Result<Vec<RepoInfo>> {
60+ Ok(RepoStore::list(self).to_vec())
61+ }
62+}
63+
64+impl RepoResolver for DynamicRepoRegistry {
65+ fn resolve(&self, relative: &str) -> Result<RepoInfo> {
66+ let normalized = normalize_relative_repo_path(relative)?;
67+ self.repos
68+ .read()
69+ .expect("dynamic repo registry poisoned")
70+ .iter()
71+ .find(|repo| repo.relative_path == normalized)
72+ .cloned()
73+ .ok_or_else(|| Error::RepoNotFound(relative.to_string()))
74+ }
75+
76+ fn list(&self) -> Result<Vec<RepoInfo>> {
77+ Ok(self
78+ .repos
79+ .read()
80+ .expect("dynamic repo registry poisoned")
81+ .clone())
82+ }
83+}
84+
85+impl MutableRepoRegistry for DynamicRepoRegistry {
86+ fn register(&self, repo: RepoInfo) -> Result<()> {
87+ let relative_path = normalize_relative_repo_path(&repo.relative_path)?;
88+ let repo_path = repo.absolute_path.canonicalize()?;
89+ let opened = gix::open(&repo_path)?;
90+ if !opened.is_bare() {
91+ return Err(Error::Protocol(format!(
92+ "registered path is not a bare repository: {}",
93+ repo_path.display()
94+ )));
95+ }
96+
97+ let mut repos = self.repos.write().expect("dynamic repo registry poisoned");
98+ if repos
99+ .iter()
100+ .any(|existing| existing.relative_path == relative_path)
101+ {
102+ return Err(Error::Protocol(format!(
103+ "repository already registered: {}",
104+ relative_path
105+ )));
106+ }
107+
108+ let mut repo = repo;
109+ repo.relative_path = relative_path;
110+ repo.absolute_path = repo_path;
111+ repos.push(repo);
112+ repos.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
113+ Ok(())
114+ }
115+
116+ fn unregister(&self, relative: &str) -> Result<()> {
117+ let normalized = normalize_relative_repo_path(relative)?;
118+ let mut repos = self.repos.write().expect("dynamic repo registry poisoned");
119+ let original_len = repos.len();
120+ repos.retain(|repo| repo.relative_path != normalized);
121+ if repos.len() == original_len {
122+ return Err(Error::RepoNotFound(relative.to_string()));
123+ }
124+ Ok(())
125+ }
126+}
127+
128+fn normalize_relative_repo_path(relative: &str) -> Result<String> {
129+ let path = Path::new(relative);
130+ if path.is_absolute() {
131+ return Err(Error::PathTraversal(relative.to_string().into()));
132+ }
133+
134+ let mut normalized = PathBuf::new();
135+ for component in path.components() {
136+ match component {
137+ std::path::Component::Normal(part) => normalized.push(part),
138+ std::path::Component::CurDir => {}
139+ _ => return Err(Error::PathTraversal(relative.to_string().into())),
140+ }
141+ }
142+
143+ if normalized.as_os_str().is_empty() {
144+ return Err(Error::RepoNotFound(relative.to_string()));
145+ }
146+
147+ Ok(normalized.to_string_lossy().into_owned())
148+}
149+
150+#[cfg(test)]
151+mod tests {
152+ use std::path::Path;
153+ use std::process::Command;
154+
155+ use tempfile::TempDir;
156+
157+ use super::*;
158+
159+ fn create_bare_repo(path: &Path) {
160+ Command::new("git")
161+ .args(["init", "--bare", path.to_str().unwrap()])
162+ .output()
163+ .expect("git init --bare failed");
164+ }
165+
166+ #[test]
167+ fn dynamic_registry_registers_and_unregisters() {
168+ let dir = TempDir::new().unwrap();
169+ let repo_path = dir.path().join("alpha.git");
170+ create_bare_repo(&repo_path);
171+
172+ let registry = DynamicRepoRegistry::new();
173+ registry
174+ .register(RepoInfo {
175+ name: "alpha.git".into(),
176+ relative_path: "alpha.git".into(),
177+ absolute_path: repo_path.clone(),
178+ description: None,
179+ })
180+ .unwrap();
181+
182+ assert_eq!(registry.list().unwrap().len(), 1);
183+ assert_eq!(
184+ registry.resolve("alpha.git").unwrap().absolute_path,
185+ repo_path.canonicalize().unwrap()
186+ );
187+
188+ registry.unregister("alpha.git").unwrap();
189+ assert!(matches!(
190+ registry.resolve("alpha.git"),
191+ Err(Error::RepoNotFound(_))
192+ ));
193+ }
194+
195+ #[test]
196+ fn dynamic_registry_resolve_and_unregister_normalize_paths() {
197+ let dir = TempDir::new().unwrap();
198+ let repo_path = dir.path().join("alpha.git");
199+ create_bare_repo(&repo_path);
200+
201+ let registry = DynamicRepoRegistry::new();
202+ registry
203+ .register(RepoInfo {
204+ name: "alpha.git".into(),
205+ relative_path: "alpha.git".into(),
206+ absolute_path: repo_path,
207+ description: None,
208+ })
209+ .unwrap();
210+
211+ assert!(registry.resolve("./alpha.git").is_ok());
212+ registry.unregister("./alpha.git").unwrap();
213+ assert!(matches!(
214+ registry.resolve("alpha.git"),
215+ Err(Error::RepoNotFound(_))
216+ ));
217+ }
218+
219+ #[test]
220+ fn dynamic_registry_rejects_duplicate_registration() {
221+ let dir = TempDir::new().unwrap();
222+ let repo_path = dir.path().join("alpha.git");
223+ create_bare_repo(&repo_path);
224+
225+ let registry = DynamicRepoRegistry::new();
226+ let repo = RepoInfo {
227+ name: "alpha.git".into(),
228+ relative_path: "alpha.git".into(),
229+ absolute_path: repo_path,
230+ description: None,
231+ };
232+
233+ registry.register(repo.clone()).unwrap();
234+ let err = registry.register(repo).unwrap_err();
235+ assert!(matches!(err, Error::Protocol(_)));
236+ }
237+
238+ #[test]
239+ fn dynamic_registry_rejects_parent_relative_paths() {
240+ let dir = TempDir::new().unwrap();
241+ let repo_path = dir.path().join("alpha.git");
242+ create_bare_repo(&repo_path);
243+
244+ let registry = DynamicRepoRegistry::new();
245+ let err = registry
246+ .register(RepoInfo {
247+ name: "alpha.git".into(),
248+ relative_path: "./team/../alpha.git".into(),
249+ absolute_path: repo_path,
250+ description: None,
251+ })
252+ .unwrap_err();
253+
254+ assert!(matches!(err, Error::PathTraversal(_)));
255+ }
256+
257+ #[test]
258+ fn dynamic_registry_rejects_absolute_relative_path() {
259+ let dir = TempDir::new().unwrap();
260+ let repo_path = dir.path().join("alpha.git");
261+ create_bare_repo(&repo_path);
262+
263+ let registry = DynamicRepoRegistry::new();
264+ let err = registry
265+ .register(RepoInfo {
266+ name: "alpha.git".into(),
267+ relative_path: "/tmp/alpha.git".into(),
268+ absolute_path: repo_path,
269+ description: None,
270+ })
271+ .unwrap_err();
272+
273+ assert!(matches!(err, Error::PathTraversal(_)));
274+ }
275+}
addedvendor/gitserver-core/src/error.rs+47 −0
1+use std::path::PathBuf;
2+
3+#[derive(Debug, thiserror::Error)]
4+pub enum Error {
5+ #[error("repository not found: {0}")]
6+ RepoNotFound(String),
7+
8+ #[error("path traversal rejected: {0}")]
9+ PathTraversal(PathBuf),
10+
11+ #[error("invalid repository at {0}: {1}")]
12+ InvalidRepo(PathBuf, String),
13+
14+ #[error("protocol error: {0}")]
15+ Protocol(String),
16+
17+ #[error("git operation failed: {0}")]
18+ Git(Box<gix::open::Error>),
19+
20+ #[error("io error: {0}")]
21+ Io(#[from] std::io::Error),
22+}
23+
24+impl From<gix::open::Error> for Error {
25+ fn from(e: gix::open::Error) -> Self {
26+ Error::Git(Box::new(e))
27+ }
28+}
29+
30+pub type Result<T> = std::result::Result<T, Error>;
31+
32+#[cfg(test)]
33+mod tests {
34+ use super::*;
35+
36+ #[test]
37+ fn error_display_repo_not_found() {
38+ let err = Error::RepoNotFound("foo/bar.git".into());
39+ assert_eq!(err.to_string(), "repository not found: foo/bar.git");
40+ }
41+
42+ #[test]
43+ fn error_display_path_traversal() {
44+ let err = Error::PathTraversal(PathBuf::from("../etc/passwd"));
45+ assert_eq!(err.to_string(), "path traversal rejected: ../etc/passwd");
46+ }
47+}
addedvendor/gitserver-core/src/lib.rs+10 −0
1+pub mod backend;
2+pub mod discovery;
3+pub mod dynamic_registry;
4+pub mod error;
5+pub mod pack;
6+pub mod path;
7+pub mod pktline;
8+pub mod protocol_v2;
9+pub mod receive_pack;
10+pub mod refs;
addedvendor/gitserver-core/src/pack.rs+848 −0
1+use std::collections::HashSet;
2+use std::path::Path;
3+
4+use bytes::Bytes;
5+use sha1::{
6+ Digest,
7+ Sha1,
8+};
9+use tokio::io::AsyncRead;
10+use tokio_util::io::StreamReader;
11+
12+use crate::error::{
13+ Error,
14+ Result,
15+};
16+use crate::pktline;
17+
18+#[derive(Debug, Clone, Default)]
19+pub struct UploadPackCapabilities {
20+ pub ofs_delta: bool,
21+ pub multi_ack: bool,
22+ pub multi_ack_detailed: bool,
23+}
24+
25+#[derive(Debug, Clone, Default)]
26+pub struct ShallowRequest {
27+ pub depth: Option<usize>,
28+ pub client_shallows: Vec<gix::ObjectId>,
29+ pub deepen_relative: bool,
30+}
31+
32+/// A parsed upload-pack request from a Git client.
33+pub struct UploadPackRequest {
34+ pub wants: Vec<gix::ObjectId>,
35+ pub haves: Vec<gix::ObjectId>,
36+ pub done: bool,
37+ pub capabilities: UploadPackCapabilities,
38+ pub shallow: ShallowRequest,
39+ pub object_ids: Option<Vec<gix::ObjectId>>,
40+}
41+
42+impl UploadPackRequest {
43+ /// Parse a pkt-line encoded upload-pack request body.
44+ ///
45+ /// The body contains:
46+ /// - "want <oid> [capabilities]\n" lines
47+ /// - flush packet "0000"
48+ /// - "have <oid>\n" lines (optional)
49+ /// - "done\n"
50+ pub fn parse(body: &[u8]) -> Result<Self> {
51+ let mut wants = Vec::new();
52+ let mut haves = Vec::new();
53+ let mut done = false;
54+ let mut capabilities = UploadPackCapabilities::default();
55+ let mut shallow = ShallowRequest::default();
56+ let mut pos = 0;
57+
58+ while pos < body.len() {
59+ // Check for flush packet
60+ if body[pos..].starts_with(b"0000") {
61+ pos += 4;
62+ continue;
63+ }
64+
65+ // Read 4-byte hex length prefix
66+ if pos + 4 > body.len() {
67+ break;
68+ }
69+ let len_str = std::str::from_utf8(&body[pos..pos + 4])
70+ .map_err(|_| Error::Protocol("invalid pkt-line length prefix".into()))?;
71+ let len = usize::from_str_radix(len_str, 16)
72+ .map_err(|_| Error::Protocol("invalid pkt-line length".into()))?;
73+
74+ if len == 0 {
75+ // flush packet already handled above, but just in case
76+ pos += 4;
77+ continue;
78+ }
79+
80+ if len < 4 || pos + len > body.len() {
81+ break;
82+ }
83+
84+ let payload = &body[pos + 4..pos + len];
85+ let line = std::str::from_utf8(payload)
86+ .map_err(|_| Error::Protocol("invalid UTF-8 in pkt-line".into()))?;
87+ let line = line.trim_end_matches('\n');
88+
89+ if line == "done" {
90+ done = true;
91+ } else if let Some(rest) = line.strip_prefix("deepen ") {
92+ let depth = rest
93+ .parse::<usize>()
94+ .map_err(|_| Error::Protocol(format!("invalid deepen value: {rest}")))?;
95+ shallow.depth = Some(depth);
96+ } else if line == "deepen-relative" {
97+ shallow.deepen_relative = true;
98+ } else if let Some(rest) = line.strip_prefix("shallow ") {
99+ let oid = gix::ObjectId::from_hex(rest.as_bytes())
100+ .map_err(|_| Error::Protocol(format!("invalid OID in shallow: {rest}")))?;
101+ shallow.client_shallows.push(oid);
102+ } else if let Some(rest) = line.strip_prefix("want ") {
103+ let mut parts = rest.split_ascii_whitespace();
104+ let oid_hex = parts
105+ .next()
106+ .ok_or_else(|| Error::Protocol("missing OID in want".into()))?;
107+ let oid = gix::ObjectId::from_hex(oid_hex.as_bytes())
108+ .map_err(|_| Error::Protocol(format!("invalid OID in want: {oid_hex}")))?;
109+ if wants.is_empty() {
110+ for capability in parts {
111+ if capability == "ofs-delta" {
112+ capabilities.ofs_delta = true;
113+ } else if capability == "multi_ack" {
114+ capabilities.multi_ack = true;
115+ } else if capability == "multi_ack_detailed" {
116+ capabilities.multi_ack = true;
117+ capabilities.multi_ack_detailed = true;
118+ }
119+ }
120+ }
121+ wants.push(oid);
122+ } else if let Some(rest) = line.strip_prefix("have ") {
123+ let oid_hex = rest
124+ .split_ascii_whitespace()
125+ .next()
126+ .ok_or_else(|| Error::Protocol("missing OID in have".into()))?;
127+ let oid = gix::ObjectId::from_hex(oid_hex.as_bytes())
128+ .map_err(|_| Error::Protocol(format!("invalid OID in have: {oid_hex}")))?;
129+ haves.push(oid);
130+ }
131+
132+ pos += len;
133+ }
134+
135+ Ok(Self {
136+ wants,
137+ haves,
138+ done,
139+ capabilities,
140+ shallow,
141+ object_ids: None,
142+ })
143+ }
144+}
145+
146+/// Encode the variable-length pack object header.
147+///
148+/// Format: first byte = MSB continuation + 3-bit type + 4-bit size
149+/// Subsequent bytes: 7-bit size chunks with MSB continuation
150+fn encode_pack_object_header(obj_type: u8, size: usize) -> Vec<u8> {
151+ let mut header = Vec::new();
152+ let mut byte = (obj_type << 4) | (size as u8 & 0x0f);
153+ let mut remaining = size >> 4;
154+
155+ if remaining > 0 {
156+ byte |= 0x80; // set continuation bit
157+ header.push(byte);
158+ while remaining > 0 {
159+ byte = remaining as u8 & 0x7f;
160+ remaining >>= 7;
161+ if remaining > 0 {
162+ byte |= 0x80;
163+ }
164+ header.push(byte);
165+ }
166+ } else {
167+ header.push(byte);
168+ }
169+
170+ header
171+}
172+
173+fn encode_ofs_delta_base_distance(mut distance: u64) -> Vec<u8> {
174+ debug_assert!(distance > 0, "offset deltas must point backwards");
175+
176+ let mut buf = [0u8; 10];
177+ let mut bytes_written = 1;
178+ buf[buf.len() - 1] = distance as u8 & 0x7f;
179+
180+ for out in buf.iter_mut().rev().skip(1) {
181+ distance >>= 7;
182+ if distance == 0 {
183+ break;
184+ }
185+ distance -= 1;
186+ *out = 0x80 | (distance as u8 & 0x7f);
187+ bytes_written += 1;
188+ }
189+
190+ buf[buf.len() - bytes_written..].to_vec()
191+}
192+
193+fn encode_delta_size(mut size: usize, out: &mut Vec<u8>) {
194+ loop {
195+ let mut byte = (size & 0x7f) as u8;
196+ size >>= 7;
197+ if size > 0 {
198+ byte |= 0x80;
199+ }
200+ out.push(byte);
201+ if size == 0 {
202+ break;
203+ }
204+ }
205+}
206+
207+fn encode_delta_copy_instruction(out: &mut Vec<u8>, offset: usize, size: usize) {
208+ debug_assert!(size > 0 && size <= 0x10000);
209+
210+ let command_pos = out.len();
211+ out.push(0x80);
212+ let mut command = 0x80;
213+
214+ if offset & 0xff != 0 {
215+ command |= 0x01;
216+ out.push(offset as u8);
217+ }
218+ if (offset >> 8) & 0xff != 0 {
219+ command |= 0x02;
220+ out.push((offset >> 8) as u8);
221+ }
222+ if (offset >> 16) & 0xff != 0 {
223+ command |= 0x04;
224+ out.push((offset >> 16) as u8);
225+ }
226+ if (offset >> 24) & 0xff != 0 {
227+ command |= 0x08;
228+ out.push((offset >> 24) as u8);
229+ }
230+
231+ if size != 0x10000 {
232+ if size & 0xff != 0 {
233+ command |= 0x10;
234+ out.push(size as u8);
235+ }
236+ if (size >> 8) & 0xff != 0 {
237+ command |= 0x20;
238+ out.push((size >> 8) as u8);
239+ }
240+ if (size >> 16) & 0xff != 0 {
241+ command |= 0x40;
242+ out.push((size >> 16) as u8);
243+ }
244+ }
245+
246+ out[command_pos] = command;
247+}
248+
249+fn encode_delta_copy(out: &mut Vec<u8>, mut offset: usize, mut size: usize) {
250+ while size > 0 {
251+ let chunk = size.min(0x10000);
252+ encode_delta_copy_instruction(out, offset, chunk);
253+ offset += chunk;
254+ size -= chunk;
255+ }
256+}
257+
258+fn encode_delta_insert(out: &mut Vec<u8>, data: &[u8]) {
259+ for chunk in data.chunks(0x7f) {
260+ out.push(chunk.len() as u8);
261+ out.extend_from_slice(chunk);
262+ }
263+}
264+
265+fn encode_blob_delta(base: &[u8], target: &[u8]) -> Option<Vec<u8>> {
266+ let mut prefix = 0;
267+ let max_prefix = base.len().min(target.len());
268+ while prefix < max_prefix && base[prefix] == target[prefix] {
269+ prefix += 1;
270+ }
271+
272+ let max_suffix = base
273+ .len()
274+ .saturating_sub(prefix)
275+ .min(target.len().saturating_sub(prefix));
276+ let mut suffix = 0;
277+ while suffix < max_suffix && base[base.len() - 1 - suffix] == target[target.len() - 1 - suffix]
278+ {
279+ suffix += 1;
280+ }
281+
282+ if prefix == 0 && suffix == 0 {
283+ return None;
284+ }
285+
286+ let mut delta = Vec::new();
287+ encode_delta_size(base.len(), &mut delta);
288+ encode_delta_size(target.len(), &mut delta);
289+
290+ if prefix > 0 {
291+ encode_delta_copy(&mut delta, 0, prefix);
292+ }
293+
294+ let insert_start = prefix;
295+ let insert_end = target.len() - suffix;
296+ encode_delta_insert(&mut delta, &target[insert_start..insert_end]);
297+
298+ if suffix > 0 {
299+ encode_delta_copy(&mut delta, base.len() - suffix, suffix);
300+ }
301+
302+ Some(delta)
303+}
304+
305+fn build_base_entry(kind: gix::object::Kind, data: &[u8]) -> Vec<u8> {
306+ let type_num = object_type_number(kind);
307+ let obj_header = encode_pack_object_header(type_num, data.len());
308+ let compressed = miniz_oxide::deflate::compress_to_vec_zlib(data, 6);
309+
310+ let mut entry = Vec::with_capacity(obj_header.len() + compressed.len());
311+ entry.extend_from_slice(&obj_header);
312+ entry.extend_from_slice(&compressed);
313+ entry
314+}
315+
316+fn build_ofs_delta_entry(
317+ pack_offset: u64,
318+ base_pack_offset: u64,
319+ base_data: &[u8],
320+ target_data: &[u8],
321+) -> Option<Vec<u8>> {
322+ let delta = encode_blob_delta(base_data, target_data)?;
323+ let obj_header = encode_pack_object_header(6, delta.len());
324+ let base_distance = encode_ofs_delta_base_distance(pack_offset - base_pack_offset);
325+ let compressed = miniz_oxide::deflate::compress_to_vec_zlib(&delta, 6);
326+
327+ let mut entry = Vec::with_capacity(obj_header.len() + base_distance.len() + compressed.len());
328+ entry.extend_from_slice(&obj_header);
329+ entry.extend_from_slice(&base_distance);
330+ entry.extend_from_slice(&compressed);
331+ Some(entry)
332+}
333+
334+struct BlobDeltaBase {
335+ pack_offset: u64,
336+ data: Vec<u8>,
337+}
338+
339+/// Map gix object kind to pack type number.
340+fn object_type_number(kind: gix::object::Kind) -> u8 {
341+ match kind {
342+ gix::object::Kind::Commit => 1,
343+ gix::object::Kind::Tree => 2,
344+ gix::object::Kind::Blob => 3,
345+ gix::object::Kind::Tag => 4,
346+ }
347+}
348+
349+/// Send raw bytes through the channel.
350+fn send(
351+ tx: &tokio::sync::mpsc::Sender<std::result::Result<Bytes, std::io::Error>>,
352+ data: &[u8],
353+) -> std::result::Result<(), Box<dyn std::error::Error + Send + Sync>> {
354+ tx.blocking_send(Ok(Bytes::copy_from_slice(data)))
355+ .map_err(|_| "receiver dropped".into())
356+}
357+
358+/// Send pack data through the channel wrapped in side-band-64k framing
359+/// (band 1 = pack data).
360+///
361+/// Respects LARGE_PACKET_MAX: each pkt-line frame carries at most
362+/// 65520 - 4 (prefix) - 1 (band byte) = 65515 bytes of payload.
363+fn send_sideband(
364+ tx: &tokio::sync::mpsc::Sender<std::result::Result<Bytes, std::io::Error>>,
365+ data: &[u8],
366+) -> std::result::Result<(), Box<dyn std::error::Error + Send + Sync>> {
367+ const MAX_DATA_PER_FRAME: usize = 65515;
368+
369+ for chunk in data.chunks(MAX_DATA_PER_FRAME) {
370+ let pkt_len = 4 + 1 + chunk.len();
371+ let mut frame = Vec::with_capacity(pkt_len);
372+ frame.extend_from_slice(format!("{pkt_len:04x}").as_bytes());
373+ frame.push(0x01); // band 1 = pack data
374+ frame.extend_from_slice(chunk);
375+ send(tx, &frame)?;
376+ }
377+
378+ Ok(())
379+}
380+
381+fn encode_ack_line(oid: gix::ObjectId, suffix: Option<&str>) -> Vec<u8> {
382+ let mut line = format!("ACK {oid}");
383+ if let Some(suffix) = suffix {
384+ line.push(' ');
385+ line.push_str(suffix);
386+ }
387+ line.push('\n');
388+ pktline::encode(line.as_bytes())
389+}
390+
391+/// Recursively collect tree and blob OIDs reachable from `tree_oid`.
392+///
393+/// Uses a single `find_object` call per object and parses raw tree
394+/// bytes via `TreeRefIter` to avoid a second ODB lookup.
395+fn collect_tree_oids(
396+ repo: &gix::Repository,
397+ tree_oid: gix::ObjectId,
398+ seen: &mut HashSet<gix::ObjectId>,
399+ oids: &mut Vec<gix::ObjectId>,
400+) -> std::result::Result<(), Box<dyn std::error::Error + Send + Sync>> {
401+ if !seen.insert(tree_oid) {
402+ return Ok(());
403+ }
404+
405+ let tree_obj = repo.find_object(tree_oid)?;
406+ let tree_data = tree_obj.data.to_vec();
407+ oids.push(tree_oid);
408+
409+ for entry_result in gix::objs::TreeRefIter::from_bytes(&tree_data, gix::hash::Kind::Sha1) {
410+ let entry = entry_result?;
411+ let entry_oid = entry.oid.to_owned();
412+ let entry_mode = entry.mode;
413+
414+ if entry_mode.is_tree() {
415+ collect_tree_oids(repo, entry_oid, seen, oids)?;
416+ } else if seen.insert(entry_oid) && !entry_mode.is_commit() {
417+ oids.push(entry_oid);
418+ }
419+ }
420+
421+ Ok(())
422+}
423+
424+/// Walk commits from `wants` (excluding `haves`) and collect all
425+/// reachable ObjectIds (commits, trees, blobs).
426+///
427+/// Pass 1 of the two-pass streaming approach: only OIDs are stored,
428+/// not object data.
429+fn collect_all_oids(
430+ repo: &gix::Repository,
431+ wants: &[gix::ObjectId],
432+ haves: &[gix::ObjectId],
433+) -> std::result::Result<Vec<gix::ObjectId>, Box<dyn std::error::Error + Send + Sync>> {
434+ let have_set: HashSet<gix::ObjectId> = haves.iter().copied().collect();
435+ let mut seen = HashSet::new();
436+ let mut oids = Vec::new();
437+
438+ // Mark have objects as already seen so we skip them
439+ for have in haves {
440+ seen.insert(*have);
441+ }
442+
443+ let walk = repo
444+ .rev_walk(wants.iter().copied())
445+ .with_hidden(haves.iter().copied())
446+ .all()?;
447+
448+ for info_result in walk {
449+ let info = info_result?;
450+ let commit_oid = info.id;
451+
452+ if have_set.contains(&commit_oid) || !seen.insert(commit_oid) {
453+ continue;
454+ }
455+
456+ // Extract tree OID from raw commit bytes (single ODB read)
457+ let commit_obj = repo.find_object(commit_oid)?;
458+ let tree_oid =
459+ gix::objs::CommitRefIter::from_bytes(&commit_obj.data, gix::hash::Kind::Sha1)
460+ .tree_id()?;
461+
462+ oids.push(commit_oid);
463+
464+ collect_tree_oids(repo, tree_oid, &mut seen, &mut oids)?;
465+ }
466+
467+ Ok(oids)
468+}
469+
470+fn common_haves(
471+ repo: &gix::Repository,
472+ wants: &[gix::ObjectId],
473+ haves: &[gix::ObjectId],
474+) -> std::result::Result<Vec<gix::ObjectId>, Box<dyn std::error::Error + Send + Sync>> {
475+ let want_set: HashSet<gix::ObjectId> =
476+ collect_all_oids(repo, wants, &[])?.into_iter().collect();
477+
478+ Ok(haves
479+ .iter()
480+ .copied()
481+ .filter(|oid| want_set.contains(oid))
482+ .collect())
483+}
484+
485+/// Generate the complete pack response for a Git upload-pack request.
486+///
487+/// Returns an `AsyncRead` producing the side-band-64k framed response that
488+/// can be streamed as the HTTP response body.
489+pub fn generate_pack(
490+ repo_path: &Path,
491+ request: &UploadPackRequest,
492+) -> Result<impl AsyncRead + Send + Unpin + use<>> {
493+ let repo_path = repo_path.to_path_buf();
494+ let wants: Vec<gix::ObjectId> = request.wants.clone();
495+ let haves: Vec<gix::ObjectId> = request.haves.clone();
496+ let object_ids = request.object_ids.clone();
497+ let done = request.done;
498+ let ofs_delta = request.capabilities.ofs_delta;
499+ let multi_ack = request.capabilities.multi_ack;
500+ let multi_ack_detailed = request.capabilities.multi_ack_detailed;
501+
502+ let (tx, rx) = tokio::sync::mpsc::channel::<std::result::Result<Bytes, std::io::Error>>(64);
503+
504+ let handle = tokio::task::spawn_blocking(move || {
505+ if let Err(e) = generate_pack_sync(
506+ &repo_path,
507+ &wants,
508+ &haves,
509+ object_ids,
510+ GeneratePackOptions {
511+ done,
512+ ofs_delta,
513+ multi_ack,
514+ multi_ack_detailed,
515+ },
516+ &tx,
517+ ) {
518+ let _ = tx.blocking_send(Err(std::io::Error::other(e.to_string())));
519+ }
520+ });
521+
522+ // Log panics from the blocking task without blocking the stream
523+ tokio::spawn(async move {
524+ if let Err(e) = handle.await {
525+ tracing::error!("pack generation task panicked: {e}");
526+ }
527+ });
528+
529+ let stream = tokio_stream::wrappers::ReceiverStream::new(rx);
530+ Ok(StreamReader::new(stream))
531+}
532+
533+/// Synchronous two-pass streaming pack generator.
534+///
535+/// Pass 1: collect OIDs only (lightweight -- no object data retained).
536+/// Pass 2: re-read each object, compress, and stream it through `tx`.
537+struct GeneratePackOptions {
538+ done: bool,
539+ ofs_delta: bool,
540+ multi_ack: bool,
541+ multi_ack_detailed: bool,
542+}
543+
544+fn generate_pack_sync(
545+ repo_path: &Path,
546+ wants: &[gix::ObjectId],
547+ haves: &[gix::ObjectId],
548+ object_ids: Option<Vec<gix::ObjectId>>,
549+ options: GeneratePackOptions,
550+ tx: &tokio::sync::mpsc::Sender<std::result::Result<Bytes, std::io::Error>>,
551+) -> std::result::Result<(), Box<dyn std::error::Error + Send + Sync>> {
552+ const MAX_DELTA_BASES: usize = 8;
553+ const MIN_DELTA_BLOB_SIZE: usize = 1024;
554+
555+ let repo = gix::open(repo_path)?;
556+
557+ let common = if !haves.is_empty() {
558+ common_haves(&repo, wants, haves)?
559+ } else {
560+ Vec::new()
561+ };
562+
563+ if options.multi_ack && !haves.is_empty() && !options.done {
564+ for oid in &common {
565+ let suffix = if options.multi_ack_detailed {
566+ "common"
567+ } else {
568+ "continue"
569+ };
570+ send(tx, &encode_ack_line(*oid, Some(suffix)))?;
571+ }
572+ send(tx, &pktline::encode(b"NAK\n"))?;
573+ return Ok(());
574+ }
575+
576+ if options.multi_ack && !common.is_empty() {
577+ send(tx, &encode_ack_line(*common.last().unwrap(), None))?;
578+ } else {
579+ // NAK line
580+ send(tx, &pktline::encode(b"NAK\n"))?;
581+ }
582+
583+ // Pass 1: collect OIDs only
584+ let oids = match object_ids {
585+ Some(oids) => oids,
586+ None => collect_all_oids(&repo, wants, haves)?,
587+ };
588+
589+ // Pass 2: stream each object
590+ let mut hasher = Sha1::new();
591+
592+ // Pack header
593+ let mut header = Vec::with_capacity(12);
594+ header.extend_from_slice(b"PACK");
595+ header.extend_from_slice(&2u32.to_be_bytes());
596+ header.extend_from_slice(&(oids.len() as u32).to_be_bytes());
597+ hasher.update(&header);
598+ send_sideband(tx, &header)?;
599+
600+ let mut pack_offset = header.len() as u64;
601+ let mut recent_blob_bases = Vec::<BlobDeltaBase>::new();
602+
603+ // Each object: read, compress, frame, send
604+ for oid in &oids {
605+ let obj = repo.find_object(*oid)?;
606+ let full_entry = build_base_entry(obj.kind, &obj.data);
607+ let mut used_delta = false;
608+ let entry = if options.ofs_delta
609+ && obj.kind == gix::object::Kind::Blob
610+ && obj.data.len() >= MIN_DELTA_BLOB_SIZE
611+ {
612+ recent_blob_bases
613+ .iter()
614+ .filter(|base| base.data.len() >= MIN_DELTA_BLOB_SIZE)
615+ .filter_map(|base| {
616+ build_ofs_delta_entry(pack_offset, base.pack_offset, &base.data, &obj.data)
617+ })
618+ .min_by_key(Vec::len)
619+ .filter(|delta_entry| delta_entry.len() < full_entry.len())
620+ .inspect(|_| {
621+ used_delta = true;
622+ })
623+ .unwrap_or(full_entry)
624+ } else {
625+ full_entry
626+ };
627+
628+ hasher.update(&entry);
629+ send_sideband(tx, &entry)?;
630+
631+ if obj.kind == gix::object::Kind::Blob
632+ && !used_delta
633+ && obj.data.len() >= MIN_DELTA_BLOB_SIZE
634+ {
635+ recent_blob_bases.push(BlobDeltaBase {
636+ pack_offset,
637+ data: obj.data.to_vec(),
638+ });
639+ if recent_blob_bases.len() > MAX_DELTA_BASES {
640+ recent_blob_bases.remove(0);
641+ }
642+ }
643+
644+ pack_offset += entry.len() as u64;
645+ }
646+
647+ // SHA-1 checksum over raw pack bytes
648+ let checksum = hasher.finalize();
649+ send_sideband(tx, &checksum)?;
650+
651+ // Flush
652+ send(tx, b"0000")?;
653+
654+ Ok(())
655+}
656+
657+#[cfg(test)]
658+mod tests {
659+ use std::path::{
660+ Path,
661+ PathBuf,
662+ };
663+ use std::process::Command;
664+
665+ use tempfile::TempDir;
666+ use tokio::io::AsyncReadExt;
667+
668+ use super::*;
669+
670+ fn make_pktline(data: &str) -> Vec<u8> {
671+ let len = data.len() + 4;
672+ format!("{len:04x}{data}").into_bytes()
673+ }
674+
675+ /// Create a bare repo with a single commit on the `main` branch.
676+ fn create_repo_with_commit(root: &Path) -> PathBuf {
677+ let bare_path = root.join("test.git");
678+ let clone_path = root.join("workdir");
679+
680+ let out = Command::new("git")
681+ .args(["init", "--bare", bare_path.to_str().unwrap()])
682+ .output()
683+ .expect("git init --bare failed");
684+ assert!(out.status.success(), "git init --bare failed: {:?}", out);
685+
686+ let out = Command::new("git")
687+ .args(["symbolic-ref", "HEAD", "refs/heads/main"])
688+ .current_dir(&bare_path)
689+ .output()
690+ .expect("git symbolic-ref failed");
691+ assert!(out.status.success());
692+
693+ let out = Command::new("git")
694+ .args([
695+ "clone",
696+ bare_path.to_str().unwrap(),
697+ clone_path.to_str().unwrap(),
698+ ])
699+ .output()
700+ .expect("git clone failed");
701+ assert!(out.status.success(), "git clone failed: {:?}", out);
702+
703+ for (key, val) in [("user.name", "Test User"), ("user.email", "test@test.com")] {
704+ Command::new("git")
705+ .args(["config", key, val])
706+ .current_dir(&clone_path)
707+ .output()
708+ .expect("git config failed");
709+ }
710+
711+ // Create a file and commit
712+ std::fs::write(clone_path.join("README.md"), "# Test\n").unwrap();
713+
714+ Command::new("git")
715+ .args(["add", "README.md"])
716+ .current_dir(&clone_path)
717+ .output()
718+ .expect("git add failed");
719+
720+ let out = Command::new("git")
721+ .args(["commit", "-m", "initial commit"])
722+ .current_dir(&clone_path)
723+ .env("GIT_AUTHOR_NAME", "Test User")
724+ .env("GIT_AUTHOR_EMAIL", "test@test.com")
725+ .env("GIT_COMMITTER_NAME", "Test User")
726+ .env("GIT_COMMITTER_EMAIL", "test@test.com")
727+ .output()
728+ .expect("git commit failed");
729+ assert!(out.status.success(), "git commit failed: {:?}", out);
730+
731+ let out = Command::new("git")
732+ .args(["push", "origin", "main"])
733+ .current_dir(&clone_path)
734+ .output()
735+ .expect("git push failed");
736+ assert!(out.status.success(), "git push failed: {:?}", out);
737+
738+ bare_path
739+ }
740+
741+ #[test]
742+ fn parse_simple_want() {
743+ let hash = "0000000000000000000000000000000000000001";
744+ let mut body = make_pktline(&format!("want {hash}\n"));
745+ body.extend_from_slice(b"00000009done\n");
746+ let req = UploadPackRequest::parse(&body).unwrap();
747+ assert_eq!(req.wants.len(), 1);
748+ assert!(req.haves.is_empty());
749+ assert!(req.done);
750+ assert!(!req.capabilities.ofs_delta);
751+ assert_eq!(req.shallow.depth, None);
752+ }
753+
754+ #[test]
755+ fn parse_wants_and_haves() {
756+ let want = "0000000000000000000000000000000000000001";
757+ let have = "0000000000000000000000000000000000000002";
758+ let mut body = make_pktline(&format!("want {want}\n"));
759+ body.extend_from_slice(b"0000");
760+ body.extend_from_slice(&make_pktline(&format!("have {have}\n")));
761+ body.extend_from_slice(b"0009done\n");
762+ let req = UploadPackRequest::parse(&body).unwrap();
763+ assert_eq!(req.wants.len(), 1);
764+ assert_eq!(req.haves.len(), 1);
765+ assert!(req.done);
766+ assert!(!req.capabilities.ofs_delta);
767+ assert!(req.shallow.client_shallows.is_empty());
768+ }
769+
770+ #[test]
771+ fn parse_ofs_delta_capability() {
772+ let hash = "0000000000000000000000000000000000000001";
773+ let mut body = make_pktline(&format!("want {hash} side-band-64k ofs-delta\n"));
774+ body.extend_from_slice(b"0009done\n");
775+ let req = UploadPackRequest::parse(&body).unwrap();
776+ assert!(req.capabilities.ofs_delta);
777+ }
778+
779+ #[test]
780+ fn parse_multi_ack_capability() {
781+ let hash = "0000000000000000000000000000000000000001";
782+ let mut body = make_pktline(&format!("want {hash} multi_ack side-band-64k\n"));
783+ body.extend_from_slice(b"0009done\n");
784+ let req = UploadPackRequest::parse(&body).unwrap();
785+ assert!(req.capabilities.multi_ack);
786+ }
787+
788+ #[test]
789+ fn parse_multi_ack_detailed_capability() {
790+ let hash = "0000000000000000000000000000000000000001";
791+ let mut body = make_pktline(&format!("want {hash} multi_ack_detailed side-band-64k\n"));
792+ body.extend_from_slice(b"0009done\n");
793+ let req = UploadPackRequest::parse(&body).unwrap();
794+ assert!(req.capabilities.multi_ack);
795+ assert!(req.capabilities.multi_ack_detailed);
796+ }
797+
798+ #[test]
799+ fn parse_shallow_request() {
800+ let hash = "0000000000000000000000000000000000000001";
801+ let mut body = make_pktline(&format!("want {hash}\n"));
802+ body.extend_from_slice(&make_pktline("deepen 2\n"));
803+ body.extend_from_slice(&make_pktline(&format!("shallow {hash}\n")));
804+ body.extend_from_slice(&make_pktline("deepen-relative\n"));
805+ body.extend_from_slice(b"0009done\n");
806+ let req = UploadPackRequest::parse(&body).unwrap();
807+ assert_eq!(req.shallow.depth, Some(2));
808+ assert_eq!(
809+ req.shallow.client_shallows,
810+ vec![gix::ObjectId::from_hex(hash.as_bytes()).unwrap()]
811+ );
812+ assert!(req.shallow.deepen_relative);
813+ }
814+
815+ #[tokio::test]
816+ async fn generate_pack_for_clone() {
817+ let dir = TempDir::new().unwrap();
818+ let repo_path = create_repo_with_commit(dir.path());
819+
820+ // Get HEAD OID
821+ let repo = gix::open(&repo_path).unwrap();
822+ let head_oid = repo.head_id().unwrap().detach();
823+ drop(repo);
824+
825+ let request = UploadPackRequest {
826+ wants: vec![head_oid],
827+ haves: vec![],
828+ done: true,
829+ capabilities: UploadPackCapabilities::default(),
830+ shallow: ShallowRequest::default(),
831+ object_ids: None,
832+ };
833+
834+ let mut reader = generate_pack(&repo_path, &request).unwrap();
835+ let mut buf = Vec::new();
836+ reader.read_to_end(&mut buf).await.unwrap();
837+
838+ let response = String::from_utf8_lossy(&buf);
839+ assert!(
840+ response.contains("NAK"),
841+ "response should contain NAK: {response:?}"
842+ );
843+
844+ // Find PACK signature in the binary response
845+ let pack_found = buf.windows(4).any(|window| window == b"PACK");
846+ assert!(pack_found, "response should contain PACK signature");
847+ }
848+}
addedvendor/gitserver-core/src/path.rs+103 −0
1+use std::path::{
2+ Component,
3+ Path,
4+ PathBuf,
5+};
6+
7+use crate::error::{
8+ Error,
9+ Result,
10+};
11+
12+/// Normalize a path by resolving `.` and `..` components lexically,
13+/// without touching the filesystem.
14+fn normalize(path: &Path) -> PathBuf {
15+ let mut out = PathBuf::new();
16+ for component in path.components() {
17+ match component {
18+ Component::ParentDir => {
19+ out.pop();
20+ }
21+ Component::CurDir => {}
22+ c => out.push(c),
23+ }
24+ }
25+ out
26+}
27+
28+/// Resolve a relative repo path against a root directory.
29+/// Returns the canonical absolute path if it is within root.
30+pub fn resolve_repo_path(root: &Path, relative: &str) -> Result<PathBuf> {
31+ let candidate = root.join(relative);
32+
33+ let canonical_root = root
34+ .canonicalize()
35+ .map_err(|_| Error::RepoNotFound(relative.to_string()))?;
36+
37+ // Lexically normalize the candidate to detect traversal before hitting the filesystem.
38+ let normalized = normalize(&canonical_root.join(relative));
39+ if !normalized.starts_with(&canonical_root) {
40+ return Err(Error::PathTraversal(candidate));
41+ }
42+
43+ let canonical = candidate
44+ .canonicalize()
45+ .map_err(|_| Error::RepoNotFound(relative.to_string()))?;
46+
47+ if !canonical.starts_with(&canonical_root) {
48+ return Err(Error::PathTraversal(candidate));
49+ }
50+
51+ Ok(canonical)
52+}
53+
54+#[cfg(test)]
55+mod tests {
56+ use tempfile::TempDir;
57+
58+ use super::*;
59+
60+ #[test]
61+ fn resolve_simple_path() {
62+ let root = TempDir::new().unwrap();
63+ let repo_dir = root.path().join("myrepo.git");
64+ std::fs::create_dir(&repo_dir).unwrap();
65+
66+ let resolved = resolve_repo_path(root.path(), "myrepo.git").unwrap();
67+ assert_eq!(resolved, repo_dir.canonicalize().unwrap());
68+ }
69+
70+ #[test]
71+ fn resolve_nested_path() {
72+ let root = TempDir::new().unwrap();
73+ let repo_dir = root.path().join("org/project.git");
74+ std::fs::create_dir_all(&repo_dir).unwrap();
75+
76+ let resolved = resolve_repo_path(root.path(), "org/project.git").unwrap();
77+ assert_eq!(resolved, repo_dir.canonicalize().unwrap());
78+ }
79+
80+ #[test]
81+ fn reject_traversal() {
82+ let root = TempDir::new().unwrap();
83+ let err = resolve_repo_path(root.path(), "../etc/passwd").unwrap_err();
84+ assert!(matches!(err, Error::PathTraversal(_)));
85+ }
86+
87+ #[test]
88+ fn reject_traversal_in_middle() {
89+ let root = TempDir::new().unwrap();
90+ let repo_dir = root.path().join("legit");
91+ std::fs::create_dir(&repo_dir).unwrap();
92+
93+ let err = resolve_repo_path(root.path(), "legit/../../etc/passwd").unwrap_err();
94+ assert!(matches!(err, Error::PathTraversal(_)));
95+ }
96+
97+ #[test]
98+ fn reject_nonexistent_path() {
99+ let root = TempDir::new().unwrap();
100+ let err = resolve_repo_path(root.path(), "nonexistent.git").unwrap_err();
101+ assert!(matches!(err, Error::RepoNotFound(_)));
102+ }
103+}
addedvendor/gitserver-core/src/pktline.rs+58 −0
1+//! pkt-line encoding as defined by Git protocol.
2+//! Format: 4-hex-digit length prefix (includes the 4 bytes) + content.
3+//! Flush packet: "0000"
4+
5+/// Encode a single pkt-line.
6+pub fn encode(data: &[u8]) -> Vec<u8> {
7+ let len = data.len() + 4;
8+ let mut buf = format!("{len:04x}").into_bytes();
9+ buf.extend_from_slice(data);
10+ buf
11+}
12+
13+/// Encode a flush packet.
14+pub fn flush() -> &'static [u8] {
15+ b"0000"
16+}
17+
18+/// Encode a comment line (e.g. "# service=git-upload-pack\n").
19+pub fn encode_comment(comment: &str) -> Vec<u8> {
20+ encode(format!("# {comment}\n").as_bytes())
21+}
22+
23+#[cfg(test)]
24+mod tests {
25+ use super::*;
26+
27+ #[test]
28+ fn encode_simple_line() {
29+ let encoded = encode(b"hello\n");
30+ assert_eq!(&encoded, b"000ahello\n");
31+ }
32+
33+ #[test]
34+ fn encode_empty_content() {
35+ let encoded = encode(b"");
36+ assert_eq!(&encoded, b"0004");
37+ }
38+
39+ #[test]
40+ fn encode_comment_line() {
41+ let encoded = encode_comment("service=git-upload-pack");
42+ assert_eq!(&encoded, b"001e# service=git-upload-pack\n");
43+ }
44+
45+ #[test]
46+ fn flush_packet() {
47+ assert_eq!(flush(), b"0000");
48+ }
49+
50+ #[test]
51+ fn encode_ref_line() {
52+ let hash = "0000000000000000000000000000000000000000";
53+ let line = format!("{hash} refs/heads/main\n");
54+ let encoded = encode(line.as_bytes());
55+ // 4 (prefix) + 40 (hash) + 1 (space) + 15 (refs/heads/main) + 1 (\n) = 61 = 0x3d
56+ assert_eq!(&encoded[..4], b"003d");
57+ }
58+}
addedvendor/gitserver-core/src/protocol_v2.rs+752 −0
1+// This Source Code Form is subject to the terms of the Mozilla Public
2+// License, v. 2.0. If a copy of the MPL was not distributed with this
3+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+//
5+// Copyright (c) 2026 WJQSERVER
6+
7+use std::collections::BTreeSet;
8+use std::collections::HashSet;
9+use std::io::Cursor;
10+use std::path::Path;
11+
12+use crate::error::{
13+ Error,
14+ Result,
15+};
16+use crate::pack::UploadPackRequest;
17+use crate::pktline;
18+
19+const CAPABILITIES: &[&str] = &[
20+ "ls-refs=unborn",
21+ "fetch=shallow wait-for-done",
22+ "object-format=sha1",
23+];
24+
25+pub enum Command {
26+ LsRefs(LsRefsRequest),
27+ Fetch(FetchRequest),
28+}
29+
30+#[derive(Default)]
31+pub struct LsRefsRequest {
32+ pub peel: bool,
33+ pub symrefs: bool,
34+ pub unborn: bool,
35+ pub ref_prefixes: Vec<String>,
36+}
37+
38+pub struct FetchRequest {
39+ pub upload_request: UploadPackRequest,
40+}
41+
42+pub struct ShallowUpdate {
43+ pub shallow: Vec<gix::ObjectId>,
44+ pub unshallow: Vec<gix::ObjectId>,
45+}
46+
47+pub fn advertise_capabilities() -> Vec<u8> {
48+ let mut out = Vec::new();
49+ out.extend_from_slice(&pktline::encode_comment("service=git-upload-pack"));
50+ out.extend_from_slice(pktline::flush());
51+ out.extend_from_slice(&pktline::encode(b"version 2\n"));
52+ for capability in CAPABILITIES {
53+ out.extend_from_slice(&pktline::encode(format!("{capability}\n").as_bytes()));
54+ }
55+ out.extend_from_slice(pktline::flush());
56+ out
57+}
58+
59+pub fn parse_command_request(body: &[u8]) -> Result<Command> {
60+ let lines = decode_pkt_lines(body)?;
61+ let mut iter = lines.into_iter();
62+
63+ let command = iter
64+ .next()
65+ .ok_or_else(|| Error::Protocol("missing protocol v2 command".into()))?;
66+ let command = command
67+ .strip_prefix("command=")
68+ .ok_or_else(|| Error::Protocol("invalid protocol v2 command line".into()))?;
69+
70+ let mut args = Vec::new();
71+ let mut saw_delim = false;
72+ for line in iter {
73+ if line.is_empty() {
74+ continue;
75+ }
76+ if line == "0001" {
77+ saw_delim = true;
78+ continue;
79+ }
80+ if saw_delim {
81+ args.push(line);
82+ }
83+ }
84+
85+ match command {
86+ "ls-refs" => parse_ls_refs(args),
87+ "fetch" => parse_fetch(args),
88+ _ => Err(Error::Protocol(format!(
89+ "unsupported protocol v2 command: {command}"
90+ ))),
91+ }
92+}
93+
94+pub fn ls_refs(repo_path: &Path, request: &LsRefsRequest) -> Result<Vec<u8>> {
95+ let repo = gix::open(repo_path)?;
96+ let mut refs = BTreeSet::new();
97+
98+ if let Ok(mut head) = repo.head() {
99+ if let Some(id) = head
100+ .try_peel_to_id()
101+ .map_err(|e| Error::Protocol(e.to_string()))?
102+ {
103+ let mut line = format!("{} HEAD", id.detach());
104+ if request.symrefs
105+ && let Some(target) = head.referent_name()
106+ {
107+ line.push_str(&format!(" symref-target:{}", target.as_bstr()));
108+ }
109+ refs.insert(line);
110+ } else if request.unborn
111+ && let Some(target) = head.referent_name()
112+ {
113+ refs.insert(format!("unborn HEAD symref-target:{}", target.as_bstr()));
114+ }
115+ }
116+
117+ if let Ok(platform) = repo.references()
118+ && let Ok(iter) = platform.all()
119+ {
120+ for mut reference in iter.flatten() {
121+ let name = reference.name().as_bstr().to_string();
122+ if !request.ref_prefixes.is_empty()
123+ && !request
124+ .ref_prefixes
125+ .iter()
126+ .any(|prefix| name.starts_with(prefix))
127+ {
128+ continue;
129+ }
130+
131+ let mut line = match reference.try_id() {
132+ Some(id) => format!("{} {name}", id.detach()),
133+ None => match reference.peel_to_id() {
134+ Ok(id) => format!("{} {name}", id.detach()),
135+ Err(_) => continue,
136+ },
137+ };
138+
139+ if request.symrefs
140+ && let Some(target) = reference.target().try_name()
141+ {
142+ line.push_str(&format!(" symref-target:{}", target.as_bstr()));
143+ }
144+
145+ if request.peel
146+ && let Ok(peeled) = reference.peel_to_id()
147+ {
148+ line.push_str(&format!(" peeled:{}", peeled.detach()));
149+ }
150+
151+ refs.insert(line);
152+ }
153+ }
154+
155+ let mut out = Vec::new();
156+ for line in refs {
157+ out.extend_from_slice(&pktline::encode(format!("{line}\n").as_bytes()));
158+ }
159+ out.extend_from_slice(pktline::flush());
160+ Ok(out)
161+}
162+
163+pub fn encode_fetch_pack_response(pack_bytes: &[u8]) -> Vec<u8> {
164+ let mut out = Vec::new();
165+ out.extend_from_slice(&pktline::encode(b"packfile\n"));
166+
167+ let mut pos = 0;
168+ while pos + 4 <= pack_bytes.len() {
169+ let len_str = match std::str::from_utf8(&pack_bytes[pos..pos + 4]) {
170+ Ok(v) => v,
171+ Err(_) => break,
172+ };
173+ pos += 4;
174+
175+ if len_str == "0000" {
176+ out.extend_from_slice(b"0000");
177+ break;
178+ }
179+
180+ let len = match usize::from_str_radix(len_str, 16) {
181+ Ok(v) if v >= 4 && pos + (v - 4) <= pack_bytes.len() => v,
182+ _ => break,
183+ };
184+
185+ let frame = &pack_bytes[pos - 4..pos + (len - 4)];
186+ let payload = &pack_bytes[pos..pos + (len - 4)];
187+ pos += len - 4;
188+
189+ if payload.starts_with(&[0x01])
190+ || payload.starts_with(&[0x02])
191+ || payload.starts_with(&[0x03])
192+ {
193+ out.extend_from_slice(frame);
194+ }
195+ }
196+
197+ out
198+}
199+
200+pub struct PrefixThenReader<R> {
201+ prefix: Cursor<Vec<u8>>,
202+ reader: R,
203+}
204+
205+impl<R> PrefixThenReader<R> {
206+ pub fn new(prefix: Vec<u8>, reader: R) -> Self {
207+ Self {
208+ prefix: Cursor::new(prefix),
209+ reader,
210+ }
211+ }
212+}
213+
214+pub struct PackSectionReader<R> {
215+ reader: R,
216+ buf: Vec<u8>,
217+ out: Cursor<Vec<u8>>,
218+ finished: bool,
219+}
220+
221+impl<R> PackSectionReader<R> {
222+ pub fn new(reader: R) -> Self {
223+ Self {
224+ reader,
225+ buf: Vec::new(),
226+ out: Cursor::new(Vec::new()),
227+ finished: false,
228+ }
229+ }
230+}
231+
232+impl<R: tokio::io::AsyncRead + Unpin> tokio::io::AsyncRead for PackSectionReader<R> {
233+ fn poll_read(
234+ mut self: std::pin::Pin<&mut Self>,
235+ cx: &mut std::task::Context<'_>,
236+ buf: &mut tokio::io::ReadBuf<'_>,
237+ ) -> std::task::Poll<std::io::Result<()>> {
238+ loop {
239+ if (self.out.position() as usize) < self.out.get_ref().len() {
240+ let remaining = &self.out.get_ref()[self.out.position() as usize..];
241+ let to_copy = remaining.len().min(buf.remaining());
242+ buf.put_slice(&remaining[..to_copy]);
243+ let next = self.out.position() + to_copy as u64;
244+ self.out.set_position(next);
245+ return std::task::Poll::Ready(Ok(()));
246+ }
247+
248+ if self.finished {
249+ return std::task::Poll::Ready(Ok(()));
250+ }
251+
252+ let mut frame_buf = [0u8; 8192];
253+ let mut read_buf = tokio::io::ReadBuf::new(&mut frame_buf);
254+ match std::pin::Pin::new(&mut self.reader).poll_read(cx, &mut read_buf) {
255+ std::task::Poll::Pending => return std::task::Poll::Pending,
256+ std::task::Poll::Ready(Err(err)) => return std::task::Poll::Ready(Err(err)),
257+ std::task::Poll::Ready(Ok(())) => {
258+ let filled = read_buf.filled();
259+ if filled.is_empty() {
260+ self.finished = true;
261+ return std::task::Poll::Ready(Ok(()));
262+ }
263+ self.buf.extend_from_slice(filled);
264+ }
265+ }
266+
267+ let mut emitted = Vec::new();
268+ loop {
269+ if self.buf.len() < 4 {
270+ break;
271+ }
272+ let len_str = match std::str::from_utf8(&self.buf[..4]) {
273+ Ok(v) => v,
274+ Err(_) => {
275+ self.finished = true;
276+ return std::task::Poll::Ready(Err(std::io::Error::other(
277+ "invalid pkt-line prefix in pack response",
278+ )));
279+ }
280+ };
281+
282+ if len_str == "0000" {
283+ emitted.extend_from_slice(b"0000");
284+ self.buf.drain(..4);
285+ self.finished = true;
286+ break;
287+ }
288+
289+ let len = match usize::from_str_radix(len_str, 16) {
290+ Ok(v) if v >= 4 => v,
291+ _ => {
292+ self.finished = true;
293+ return std::task::Poll::Ready(Err(std::io::Error::other(
294+ "invalid pkt-line length in pack response",
295+ )));
296+ }
297+ };
298+
299+ if self.buf.len() < len {
300+ break;
301+ }
302+
303+ let frame = self.buf[..len].to_vec();
304+ let payload = &frame[4..];
305+ if payload.starts_with(&[0x01])
306+ || payload.starts_with(&[0x02])
307+ || payload.starts_with(&[0x03])
308+ {
309+ emitted.extend_from_slice(&frame);
310+ }
311+ self.buf.drain(..len);
312+ }
313+
314+ if !emitted.is_empty() {
315+ self.out = Cursor::new(emitted);
316+ self.out.set_position(0);
317+ }
318+ }
319+ }
320+}
321+
322+impl<R: tokio::io::AsyncRead + Unpin> tokio::io::AsyncRead for PrefixThenReader<R> {
323+ fn poll_read(
324+ mut self: std::pin::Pin<&mut Self>,
325+ cx: &mut std::task::Context<'_>,
326+ buf: &mut tokio::io::ReadBuf<'_>,
327+ ) -> std::task::Poll<std::io::Result<()>> {
328+ if (self.prefix.position() as usize) < self.prefix.get_ref().len() {
329+ let remaining = &self.prefix.get_ref()[self.prefix.position() as usize..];
330+ let to_copy = remaining.len().min(buf.remaining());
331+ buf.put_slice(&remaining[..to_copy]);
332+ let next = self.prefix.position() + to_copy as u64;
333+ self.prefix.set_position(next);
334+ return std::task::Poll::Ready(Ok(()));
335+ }
336+
337+ std::pin::Pin::new(&mut self.reader).poll_read(cx, buf)
338+ }
339+}
340+
341+pub fn encode_fetch_ready_and_acknowledgments(common: &[gix::ObjectId]) -> Vec<u8> {
342+ let mut out = encode_fetch_acknowledgments(common);
343+ if !common.is_empty() {
344+ out.truncate(out.len() - 4);
345+ out.extend_from_slice(&pktline::encode(b"ready\n"));
346+ out.extend_from_slice(b"0001");
347+ }
348+ out
349+}
350+
351+pub fn encode_fetch_acknowledgments(common: &[gix::ObjectId]) -> Vec<u8> {
352+ let mut out = Vec::new();
353+ out.extend_from_slice(&pktline::encode(b"acknowledgments\n"));
354+
355+ if common.is_empty() {
356+ out.extend_from_slice(&pktline::encode(b"NAK\n"));
357+ } else {
358+ for oid in common {
359+ out.extend_from_slice(&pktline::encode(format!("ACK {oid}\n").as_bytes()));
360+ }
361+ }
362+
363+ out.extend_from_slice(pktline::flush());
364+ out
365+}
366+
367+pub fn encode_shallow_info(update: &ShallowUpdate) -> Vec<u8> {
368+ let mut out = Vec::new();
369+ if update.shallow.is_empty() && update.unshallow.is_empty() {
370+ return out;
371+ }
372+
373+ out.extend_from_slice(&pktline::encode(b"shallow-info\n"));
374+ for oid in &update.shallow {
375+ out.extend_from_slice(&pktline::encode(format!("shallow {oid}\n").as_bytes()));
376+ }
377+ for oid in &update.unshallow {
378+ out.extend_from_slice(&pktline::encode(format!("unshallow {oid}\n").as_bytes()));
379+ }
380+ out.extend_from_slice(b"0001");
381+ out
382+}
383+
384+pub fn common_haves(repo_path: &Path, request: &FetchRequest) -> Result<Vec<gix::ObjectId>> {
385+ let repo = gix::open(repo_path)?;
386+ let want_set: HashSet<gix::ObjectId> =
387+ collect_want_closure(&repo, &request.upload_request.wants)?
388+ .into_iter()
389+ .collect();
390+
391+ Ok(request
392+ .upload_request
393+ .haves
394+ .iter()
395+ .copied()
396+ .filter(|oid| want_set.contains(oid))
397+ .collect())
398+}
399+
400+pub fn apply_shallow_boundaries(
401+ repo_path: &Path,
402+ request: &mut FetchRequest,
403+) -> Result<ShallowUpdate> {
404+ let Some(depth) = request.upload_request.shallow.depth else {
405+ return Ok(ShallowUpdate {
406+ shallow: Vec::new(),
407+ unshallow: Vec::new(),
408+ });
409+ };
410+
411+ let repo = gix::open(repo_path)?;
412+ let previous_shallows = request.upload_request.shallow.client_shallows.clone();
413+ let state = collect_depth_limited_commits(&repo, &request.upload_request, depth)?;
414+
415+ request.upload_request.object_ids = Some(state.included_objects.clone());
416+ request
417+ .upload_request
418+ .haves
419+ .extend(previous_shallows.iter().copied());
420+
421+ let next_shallows: HashSet<_> = state.shallow_boundary.iter().copied().collect();
422+ let prev_shallows: HashSet<_> = previous_shallows.iter().copied().collect();
423+
424+ Ok(ShallowUpdate {
425+ shallow: state
426+ .shallow_boundary
427+ .iter()
428+ .copied()
429+ .filter(|oid| !prev_shallows.contains(oid))
430+ .collect(),
431+ unshallow: previous_shallows
432+ .into_iter()
433+ .filter(|oid| !next_shallows.contains(oid))
434+ .collect(),
435+ })
436+}
437+
438+struct DepthState {
439+ included_objects: Vec<gix::ObjectId>,
440+ shallow_boundary: Vec<gix::ObjectId>,
441+}
442+
443+fn parse_ls_refs(args: Vec<String>) -> Result<Command> {
444+ let mut request = LsRefsRequest::default();
445+
446+ for arg in args {
447+ match arg.as_str() {
448+ "peel" => request.peel = true,
449+ "symrefs" => request.symrefs = true,
450+ "unborn" => request.unborn = true,
451+ _ => {
452+ if let Some(prefix) = arg.strip_prefix("ref-prefix ") {
453+ request.ref_prefixes.push(prefix.to_owned());
454+ } else {
455+ return Err(Error::Protocol(format!(
456+ "unsupported ls-refs argument: {arg}"
457+ )));
458+ }
459+ }
460+ }
461+ }
462+
463+ Ok(Command::LsRefs(request))
464+}
465+
466+fn parse_fetch(args: Vec<String>) -> Result<Command> {
467+ let mut wants = Vec::new();
468+ let mut haves = Vec::new();
469+ let mut done = false;
470+ let mut capabilities = crate::pack::UploadPackCapabilities::default();
471+ let mut shallow = crate::pack::ShallowRequest::default();
472+
473+ for arg in args {
474+ if arg == "done" {
475+ done = true;
476+ } else if arg == "ofs-delta" {
477+ capabilities.ofs_delta = true;
478+ } else if arg == "deepen-relative" {
479+ shallow.deepen_relative = true;
480+ } else if arg == "thin-pack"
481+ || arg == "no-progress"
482+ || arg == "include-tag"
483+ || arg == "wait-for-done"
484+ {
485+ continue;
486+ } else if let Some(depth) = arg.strip_prefix("deepen ") {
487+ shallow.depth = Some(
488+ depth
489+ .parse::<usize>()
490+ .map_err(|_| Error::Protocol(format!("invalid deepen value: {depth}")))?,
491+ );
492+ } else if let Some(oid_hex) = arg.strip_prefix("shallow ") {
493+ let oid = gix::ObjectId::from_hex(oid_hex.as_bytes())
494+ .map_err(|_| Error::Protocol(format!("invalid OID in shallow: {oid_hex}")))?;
495+ shallow.client_shallows.push(oid);
496+ } else if let Some(oid_hex) = arg.strip_prefix("want ") {
497+ let oid = gix::ObjectId::from_hex(oid_hex.as_bytes())
498+ .map_err(|_| Error::Protocol(format!("invalid OID in want: {oid_hex}")))?;
499+ wants.push(oid);
500+ } else if let Some(oid_hex) = arg.strip_prefix("have ") {
501+ let oid = gix::ObjectId::from_hex(oid_hex.as_bytes())
502+ .map_err(|_| Error::Protocol(format!("invalid OID in have: {oid_hex}")))?;
503+ haves.push(oid);
504+ } else {
505+ return Err(Error::Protocol(format!(
506+ "unsupported fetch argument: {arg}"
507+ )));
508+ }
509+ }
510+
511+ Ok(Command::Fetch(FetchRequest {
512+ upload_request: UploadPackRequest {
513+ wants,
514+ haves,
515+ done,
516+ capabilities,
517+ shallow,
518+ object_ids: None,
519+ },
520+ }))
521+}
522+
523+fn decode_pkt_lines(body: &[u8]) -> Result<Vec<String>> {
524+ let mut pos = 0;
525+ let mut out = Vec::new();
526+
527+ while pos < body.len() {
528+ if pos + 4 > body.len() {
529+ return Err(Error::Protocol("truncated pkt-line prefix".into()));
530+ }
531+
532+ let len_str = std::str::from_utf8(&body[pos..pos + 4])
533+ .map_err(|_| Error::Protocol("invalid pkt-line length prefix".into()))?;
534+ pos += 4;
535+
536+ if len_str == "0000" {
537+ break;
538+ }
539+ if len_str == "0001" {
540+ out.push("0001".to_string());
541+ continue;
542+ }
543+
544+ let len = usize::from_str_radix(len_str, 16)
545+ .map_err(|_| Error::Protocol("invalid pkt-line length".into()))?;
546+ if len < 4 || pos + (len - 4) > body.len() {
547+ return Err(Error::Protocol("invalid pkt-line frame length".into()));
548+ }
549+
550+ let payload = &body[pos..pos + (len - 4)];
551+ pos += len - 4;
552+ let line = std::str::from_utf8(payload)
553+ .map_err(|_| Error::Protocol("invalid UTF-8 in pkt-line".into()))?;
554+ out.push(line.trim_end_matches('\n').to_owned());
555+ }
556+
557+ Ok(out)
558+}
559+
560+fn collect_want_closure(
561+ repo: &gix::Repository,
562+ wants: &[gix::ObjectId],
563+) -> Result<Vec<gix::ObjectId>> {
564+ let mut seen = HashSet::new();
565+ let mut out = Vec::new();
566+
567+ let walk = repo
568+ .rev_walk(wants.iter().copied())
569+ .all()
570+ .map_err(|e| Error::Protocol(e.to_string()))?;
571+ for info_result in walk {
572+ let info = info_result.map_err(|e| Error::Protocol(e.to_string()))?;
573+ let commit_oid = info.id;
574+ if !seen.insert(commit_oid) {
575+ continue;
576+ }
577+ out.push(commit_oid);
578+ }
579+
580+ Ok(out)
581+}
582+
583+fn collect_depth_limited_commits(
584+ repo: &gix::Repository,
585+ request: &crate::pack::UploadPackRequest,
586+ depth: usize,
587+) -> Result<DepthState> {
588+ use std::collections::{
589+ HashSet,
590+ VecDeque,
591+ };
592+
593+ let mut queue = VecDeque::new();
594+ let mut seen = HashSet::new();
595+ let mut included_commits = Vec::new();
596+ let mut included_objects = Vec::new();
597+ let mut shallow_boundary = Vec::new();
598+
599+ let base_depth = if request.shallow.deepen_relative {
600+ 1usize
601+ } else {
602+ 0
603+ };
604+ let limit = base_depth + depth;
605+
606+ for want in &request.wants {
607+ queue.push_back((*want, 1usize));
608+ }
609+
610+ while let Some((commit_oid, current_depth)) = queue.pop_front() {
611+ if !seen.insert(commit_oid) {
612+ continue;
613+ }
614+ included_commits.push(commit_oid);
615+ included_objects.push(commit_oid);
616+
617+ let commit_obj = repo
618+ .find_object(commit_oid)
619+ .map_err(|e| Error::Protocol(e.to_string()))?;
620+ let tree_oid =
621+ gix::objs::CommitRefIter::from_bytes(&commit_obj.data, gix::hash::Kind::Sha1)
622+ .tree_id()
623+ .map_err(|e| Error::Protocol(e.to_string()))?;
624+ collect_tree_oids(repo, tree_oid, &mut seen, &mut included_objects)?;
625+ let parents: Vec<_> =
626+ gix::objs::CommitRefIter::from_bytes(&commit_obj.data, gix::hash::Kind::Sha1)
627+ .parent_ids()
628+ .collect();
629+
630+ if current_depth >= limit || parents.is_empty() {
631+ shallow_boundary.push(commit_oid);
632+ continue;
633+ }
634+
635+ for parent in parents {
636+ queue.push_back((parent, current_depth + 1));
637+ }
638+ }
639+
640+ Ok(DepthState {
641+ included_objects,
642+ shallow_boundary,
643+ })
644+}
645+
646+fn collect_tree_oids(
647+ repo: &gix::Repository,
648+ root_tree_oid: gix::ObjectId,
649+ seen: &mut HashSet<gix::ObjectId>,
650+ oids: &mut Vec<gix::ObjectId>,
651+) -> Result<()> {
652+ let mut stack = vec![root_tree_oid];
653+
654+ while let Some(tree_oid) = stack.pop() {
655+ if !seen.insert(tree_oid) {
656+ continue;
657+ }
658+
659+ let tree_obj = repo
660+ .find_object(tree_oid)
661+ .map_err(|e| Error::Protocol(e.to_string()))?;
662+ oids.push(tree_oid);
663+
664+ for entry_result in
665+ gix::objs::TreeRefIter::from_bytes(&tree_obj.data, gix::hash::Kind::Sha1)
666+ {
667+ let entry = entry_result.map_err(|e| Error::Protocol(e.to_string()))?;
668+ let entry_oid = entry.oid.to_owned();
669+ let entry_mode = entry.mode;
670+
671+ if entry_mode.is_tree() {
672+ stack.push(entry_oid);
673+ } else if seen.insert(entry_oid) && !entry_mode.is_commit() {
674+ oids.push(entry_oid);
675+ }
676+ }
677+ }
678+
679+ Ok(())
680+}
681+
682+#[cfg(test)]
683+mod tests {
684+ use super::*;
685+
686+ fn pkt(data: &str) -> Vec<u8> {
687+ pktline::encode(data.as_bytes())
688+ }
689+
690+ #[test]
691+ fn parse_ls_refs_command() {
692+ let mut body = Vec::new();
693+ body.extend_from_slice(&pkt("command=ls-refs\n"));
694+ body.extend_from_slice(b"0001");
695+ body.extend_from_slice(&pkt("peel\n"));
696+ body.extend_from_slice(&pkt("symrefs\n"));
697+ body.extend_from_slice(&pkt("ref-prefix refs/heads/\n"));
698+ body.extend_from_slice(b"0000");
699+
700+ let Command::LsRefs(req) = parse_command_request(&body).unwrap() else {
701+ panic!("expected ls-refs command");
702+ };
703+ assert!(req.peel);
704+ assert!(req.symrefs);
705+ assert_eq!(req.ref_prefixes, vec!["refs/heads/"]);
706+ }
707+
708+ #[test]
709+ fn parse_fetch_command() {
710+ let mut body = Vec::new();
711+ body.extend_from_slice(&pkt("command=fetch\n"));
712+ body.extend_from_slice(b"0001");
713+ body.extend_from_slice(&pkt("ofs-delta\n"));
714+ body.extend_from_slice(&pkt("want 0000000000000000000000000000000000000001\n"));
715+ body.extend_from_slice(&pkt("done\n"));
716+ body.extend_from_slice(b"0000");
717+
718+ let Command::Fetch(req) = parse_command_request(&body).unwrap() else {
719+ panic!("expected fetch command");
720+ };
721+ assert_eq!(req.upload_request.wants.len(), 1);
722+ assert!(req.upload_request.done);
723+ assert!(req.upload_request.capabilities.ofs_delta);
724+ }
725+
726+ #[test]
727+ fn ls_refs_returns_unborn_head() {
728+ let root = tempfile::TempDir::new().unwrap();
729+ let repo_path = root.path().join("repo.git");
730+ std::process::Command::new("git")
731+ .args(["init", "--bare", repo_path.to_str().unwrap()])
732+ .output()
733+ .unwrap();
734+ std::process::Command::new("git")
735+ .args(["symbolic-ref", "HEAD", "refs/heads/main"])
736+ .current_dir(&repo_path)
737+ .output()
738+ .unwrap();
739+
740+ let out = ls_refs(
741+ &repo_path,
742+ &LsRefsRequest {
743+ unborn: true,
744+ symrefs: true,
745+ ..Default::default()
746+ },
747+ )
748+ .unwrap();
749+ let text = String::from_utf8(out).unwrap();
750+ assert!(text.contains("unborn HEAD symref-target:refs/heads/main"));
751+ }
752+}
addedvendor/gitserver-core/src/receive_pack.rs+562 −0
1+// This Source Code Form is subject to the terms of the Mozilla Public
2+// License, v. 2.0. If a copy of the MPL was not distributed with this
3+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+//
5+// Copyright (c) 2026 WJQSERVER
6+
7+use std::io::{
8+ BufRead,
9+ BufReader,
10+ Read,
11+};
12+use std::path::Path;
13+use std::sync::atomic::AtomicBool;
14+
15+use gix::objs::bstr::BString;
16+use gix::prelude::ObjectIdExt;
17+use gix::progress::Discard;
18+use gix::refs::Target;
19+use gix::refs::transaction::{
20+ Change,
21+ LogChange,
22+ PreviousValue,
23+ RefEdit,
24+ RefLog,
25+};
26+
27+use crate::error::{
28+ Error,
29+ Result,
30+};
31+use crate::pktline;
32+
33+const ZERO_ID: &str = "0000000000000000000000000000000000000000";
34+const CAPABILITIES: &str = concat!(
35+ "report-status report-status-v2 side-band-64k quiet ofs-delta object-format=sha1 agent=gitserver/",
36+ env!("CARGO_PKG_VERSION")
37+);
38+
39+pub fn advertise_receive_refs(repo_path: &Path) -> Result<Vec<u8>> {
40+ let repo = gix::open(repo_path)?;
41+ let mut out = Vec::new();
42+ let head_name = repo.head_name().ok().flatten();
43+ let mut refs: Vec<(String, gix::ObjectId)> = repo
44+ .references()
45+ .map_err(|e| Error::Protocol(format!("failed to open refs: {e}")))?
46+ .all()
47+ .map_err(|e| Error::Protocol(format!("failed to iterate refs: {e}")))?
48+ .flatten()
49+ .filter_map(|mut reference| {
50+ reference
51+ .peel_to_id()
52+ .ok()
53+ .map(|id| (reference.name().as_bstr().to_string(), id.detach()))
54+ })
55+ .collect();
56+ refs.sort_by(|a, b| a.0.cmp(&b.0));
57+
58+ if refs.is_empty() {
59+ out.extend_from_slice(&pktline::encode(
60+ format!("{ZERO_ID} capabilities^{{}}\0{CAPABILITIES}\n").as_bytes(),
61+ ));
62+ } else {
63+ let (first_name, first_id) = &refs[0];
64+ let mut first = format!("{} {}\0{CAPABILITIES}", first_id, first_name);
65+ if head_name
66+ .as_ref()
67+ .is_some_and(|head| head.as_bstr() == first_name.as_str())
68+ {
69+ first.push_str(&format!(" symref=HEAD:{first_name}"));
70+ }
71+ first.push('\n');
72+ out.extend_from_slice(&pktline::encode(first.as_bytes()));
73+
74+ for (name, id) in refs.into_iter().skip(1) {
75+ out.extend_from_slice(&pktline::encode(format!("{id} {name}\n").as_bytes()));
76+ }
77+ }
78+
79+ out.extend_from_slice(pktline::flush());
80+ Ok(out)
81+}
82+
83+pub fn receive_pack<R: Read>(repo_path: &Path, request: R) -> Result<Vec<u8>> {
84+ let interrupt = AtomicBool::new(false);
85+ receive_pack_with_interrupt(repo_path, request, &interrupt)
86+}
87+
88+pub fn receive_pack_with_interrupt<R: Read>(
89+ repo_path: &Path,
90+ request: R,
91+ interrupt: &AtomicBool,
92+) -> Result<Vec<u8>> {
93+ let repo = gix::open(repo_path)?;
94+ let mut parsed = parse_request(request, interrupt)?;
95+ let status = apply_commands(&repo, repo_path, &mut parsed, interrupt)?;
96+ Ok(encode_report_status(&parsed.capabilities, &status))
97+}
98+
99+#[derive(Default)]
100+struct ReceivePackCapabilities {
101+ report_status: bool,
102+ report_status_v2: bool,
103+}
104+
105+struct ReceivePackRequest<R> {
106+ commands: Vec<UpdateCommand>,
107+ pack: R,
108+ capabilities: ReceivePackCapabilities,
109+}
110+
111+struct UpdateCommand {
112+ old_id: String,
113+ new_id: String,
114+ refname: String,
115+}
116+
117+enum CommandStatus {
118+ Ok(String),
119+ Ng(String, String),
120+}
121+
122+fn parse_request<R: Read>(
123+ request: R,
124+ interrupt: &AtomicBool,
125+) -> Result<ReceivePackRequest<BufReader<R>>> {
126+ let mut request = BufReader::new(request);
127+ let mut commands = Vec::new();
128+ let mut capabilities = ReceivePackCapabilities::default();
129+
130+ loop {
131+ check_interrupt(interrupt)?;
132+ let mut prefix = [0u8; 4];
133+ match request.read_exact(&mut prefix) {
134+ Ok(()) => {}
135+ Err(err) if err.kind() == std::io::ErrorKind::UnexpectedEof => break,
136+ Err(err) => return Err(Error::Io(err)),
137+ }
138+
139+ let len_str = std::str::from_utf8(&prefix)
140+ .map_err(|_| Error::Protocol("invalid pkt-line length prefix".into()))?;
141+
142+ if len_str == "0000" {
143+ break;
144+ }
145+
146+ let len = usize::from_str_radix(len_str, 16)
147+ .map_err(|_| Error::Protocol("invalid pkt-line length".into()))?;
148+ if len < 4 {
149+ return Err(Error::Protocol("invalid pkt-line frame length".into()));
150+ }
151+
152+ check_interrupt(interrupt)?;
153+ let mut payload = vec![0u8; len - 4];
154+ request.read_exact(&mut payload)?;
155+
156+ let (command_bytes, capability_bytes) =
157+ if let Some(nul) = payload.iter().position(|b| *b == 0) {
158+ (&payload[..nul], Some(&payload[nul + 1..]))
159+ } else {
160+ (&payload[..], None)
161+ };
162+
163+ if let Some(capability_bytes) = capability_bytes {
164+ let capabilities_line = std::str::from_utf8(capability_bytes)
165+ .map_err(|_| Error::Protocol("invalid UTF-8 in receive-pack capabilities".into()))?
166+ .trim_end_matches('\n');
167+ for capability in capabilities_line.split_ascii_whitespace() {
168+ match capability {
169+ "report-status" => capabilities.report_status = true,
170+ "report-status-v2" => {
171+ capabilities.report_status = true;
172+ capabilities.report_status_v2 = true;
173+ }
174+ _ => {}
175+ }
176+ }
177+ }
178+
179+ let line = std::str::from_utf8(command_bytes)
180+ .map_err(|_| Error::Protocol("invalid UTF-8 in update command".into()))?
181+ .trim_end_matches('\n');
182+ let mut parts = line.split_ascii_whitespace();
183+ let Some(old_id) = parts.next() else { continue };
184+ let Some(new_id) = parts.next() else { continue };
185+ let Some(refname) = parts.next() else {
186+ continue;
187+ };
188+
189+ commands.push(UpdateCommand {
190+ old_id: old_id.to_owned(),
191+ new_id: new_id.to_owned(),
192+ refname: refname.to_owned(),
193+ });
194+ }
195+
196+ Ok(ReceivePackRequest {
197+ commands,
198+ pack: request,
199+ capabilities,
200+ })
201+}
202+
203+fn apply_commands<R: BufRead>(
204+ repo: &gix::Repository,
205+ repo_path: &Path,
206+ request: &mut ReceivePackRequest<R>,
207+ interrupt: &AtomicBool,
208+) -> Result<Vec<CommandStatus>> {
209+ check_interrupt(interrupt)?;
210+ if request.pack.fill_buf().map(|buf: &[u8]| !buf.is_empty())? {
211+ write_pack(repo_path, &mut request.pack, interrupt)?;
212+ }
213+
214+ let mut edits = Vec::with_capacity(request.commands.len());
215+ for (index, command) in request.commands.iter().enumerate() {
216+ check_interrupt(interrupt)?;
217+ match validate_ref_update(repo, command, interrupt) {
218+ Ok(edit) => edits.push((command.refname.clone(), edit)),
219+ Err(err) => {
220+ return Ok(request
221+ .commands
222+ .iter()
223+ .enumerate()
224+ .map(|(cmd_index, cmd)| {
225+ if cmd_index == index {
226+ CommandStatus::Ng(cmd.refname.clone(), err.to_string())
227+ } else {
228+ CommandStatus::Ng(
229+ cmd.refname.clone(),
230+ "transaction aborted due to another command failing validation"
231+ .into(),
232+ )
233+ }
234+ })
235+ .collect());
236+ }
237+ }
238+ }
239+
240+ check_interrupt(interrupt)?;
241+ match repo.edit_references(edits.into_iter().map(|(_, edit)| edit)) {
242+ Ok(_) => Ok(request
243+ .commands
244+ .iter()
245+ .map(|cmd| CommandStatus::Ok(cmd.refname.clone()))
246+ .collect()),
247+ Err(err) => Ok(request
248+ .commands
249+ .iter()
250+ .map(|cmd| CommandStatus::Ng(cmd.refname.clone(), format!("transaction failed: {err}")))
251+ .collect()),
252+ }
253+}
254+
255+fn write_pack<R: BufRead>(repo_path: &Path, pack: &mut R, interrupt: &AtomicBool) -> Result<()> {
256+ let mut progress = Discard;
257+ let outcome = gix_pack::Bundle::write_to_directory(
258+ pack,
259+ Some(repo_path.join("objects/pack").as_path()),
260+ &mut progress,
261+ interrupt,
262+ None::<&gix::Repository>,
263+ Default::default(),
264+ );
265+ if interrupt.load(std::sync::atomic::Ordering::Relaxed) {
266+ return Err(Error::Io(std::io::Error::new(
267+ std::io::ErrorKind::TimedOut,
268+ "receive-pack timed out",
269+ )));
270+ }
271+ let outcome =
272+ outcome.map_err(|e| Error::Protocol(format!("failed to write incoming pack: {e}")))?;
273+
274+ if let Some(keep) = outcome.keep_path {
275+ let _ = std::fs::remove_file(keep);
276+ }
277+ Ok(())
278+}
279+
280+fn check_interrupt(interrupt: &AtomicBool) -> Result<()> {
281+ if interrupt.load(std::sync::atomic::Ordering::Relaxed) {
282+ Err(Error::Io(std::io::Error::new(
283+ std::io::ErrorKind::TimedOut,
284+ "receive-pack timed out",
285+ )))
286+ } else {
287+ Ok(())
288+ }
289+}
290+
291+fn validate_ref_update(
292+ repo: &gix::Repository,
293+ command: &UpdateCommand,
294+ interrupt: &AtomicBool,
295+) -> Result<RefEdit> {
296+ if command.new_id == ZERO_ID {
297+ return Err(Error::Protocol(format!(
298+ "deletion prohibited for {}",
299+ command.refname
300+ )));
301+ }
302+
303+ let is_branch = command.refname.starts_with("refs/heads/");
304+ let is_tag = command.refname.starts_with("refs/tags/");
305+ let new_id = gix::ObjectId::from_hex(command.new_id.as_bytes())
306+ .map_err(|_| Error::Protocol(format!("invalid new object id: {}", command.new_id)))?;
307+ let new_header = repo
308+ .find_header(new_id)
309+ .map_err(|e| Error::Protocol(format!("missing new object {}: {e}", command.new_id)))?;
310+ if is_branch && new_header.kind() != gix::objs::Kind::Commit {
311+ return Err(Error::Protocol(format!(
312+ "updates to {} must point to a commit",
313+ command.refname
314+ )));
315+ }
316+
317+ let name: gix::refs::FullName = command
318+ .refname
319+ .as_str()
320+ .try_into()
321+ .map_err(|e| Error::Protocol(format!("invalid ref name {}: {e}", command.refname)))?;
322+
323+ let (expected, log_message) = if command.old_id == ZERO_ID {
324+ (PreviousValue::MustNotExist, BString::from("push create"))
325+ } else {
326+ if is_tag {
327+ return Err(Error::Protocol(format!(
328+ "updating existing tag {} is not allowed",
329+ command.refname
330+ )));
331+ }
332+
333+ let old_id = gix::ObjectId::from_hex(command.old_id.as_bytes())
334+ .map_err(|_| Error::Protocol(format!("invalid old object id: {}", command.old_id)))?;
335+ if is_branch {
336+ ensure_fast_forward(repo, old_id, new_id, &command.refname, interrupt)?;
337+ }
338+ (
339+ PreviousValue::MustExistAndMatch(Target::Object(old_id)),
340+ BString::from("push"),
341+ )
342+ };
343+
344+ Ok(RefEdit {
345+ change: Change::Update {
346+ log: LogChange {
347+ mode: RefLog::AndReference,
348+ force_create_reflog: false,
349+ message: log_message,
350+ },
351+ expected,
352+ new: Target::Object(new_id),
353+ },
354+ name,
355+ deref: false,
356+ })
357+}
358+
359+fn ensure_fast_forward(
360+ repo: &gix::Repository,
361+ old_id: gix::ObjectId,
362+ new_id: gix::ObjectId,
363+ refname: &str,
364+ interrupt: &AtomicBool,
365+) -> Result<()> {
366+ check_interrupt(interrupt)?;
367+ if old_id == new_id {
368+ return Ok(());
369+ }
370+
371+ let old_commit_time = repo
372+ .find_object(old_id)
373+ .map_err(|e| Error::Protocol(format!("failed to inspect current tip for {refname}: {e}")))?
374+ .try_into_commit()
375+ .map_err(|_| Error::Protocol(format!("current tip of {refname} is not a commit")))?
376+ .committer()
377+ .map_err(|e| Error::Protocol(format!("failed to read commit metadata for {refname}: {e}")))?
378+ .seconds();
379+
380+ let ancestors = new_id
381+ .attach(repo)
382+ .ancestors()
383+ .sorting(gix::revision::walk::Sorting::ByCommitTimeCutoff {
384+ order: Default::default(),
385+ seconds: old_commit_time,
386+ })
387+ .all()
388+ .map_err(|e| Error::Protocol(format!("failed to walk commits for {refname}: {e}")))?;
389+
390+ for id in ancestors {
391+ check_interrupt(interrupt)?;
392+ if id.is_ok_and(|commit| commit.id == old_id) {
393+ return Ok(());
394+ }
395+ }
396+
397+ Err(Error::Protocol(format!(
398+ "non-fast-forward update to {refname} is not allowed"
399+ )))
400+}
401+
402+fn encode_report_status(
403+ capabilities: &ReceivePackCapabilities,
404+ statuses: &[CommandStatus],
405+) -> Vec<u8> {
406+ if !capabilities.report_status {
407+ return pktline::flush().to_vec();
408+ }
409+
410+ let mut status_lines = Vec::new();
411+ status_lines.extend_from_slice(&pktline::encode(b"unpack ok\n"));
412+
413+ for status in statuses {
414+ match status {
415+ CommandStatus::Ok(refname) => {
416+ status_lines
417+ .extend_from_slice(&pktline::encode(format!("ok {refname}\n").as_bytes()));
418+ }
419+ CommandStatus::Ng(refname, message) => {
420+ status_lines.extend_from_slice(&pktline::encode(
421+ format!("ng {refname} {message}\n").as_bytes(),
422+ ));
423+ }
424+ }
425+ }
426+ status_lines.extend_from_slice(pktline::flush());
427+
428+ if capabilities.report_status_v2 {
429+ let mut sideband = Vec::new();
430+ const MAX_BAND_PAYLOAD: usize = 65519;
431+ for chunk in status_lines.chunks(MAX_BAND_PAYLOAD) {
432+ let len = 4 + 1 + chunk.len();
433+ sideband.extend_from_slice(format!("{len:04x}").as_bytes());
434+ sideband.push(0x01);
435+ sideband.extend_from_slice(chunk);
436+ }
437+ sideband.extend_from_slice(pktline::flush());
438+ sideband
439+ } else {
440+ status_lines
441+ }
442+}
443+
444+#[cfg(test)]
445+mod tests {
446+ use std::process::Command;
447+
448+ use tempfile::TempDir;
449+
450+ use super::*;
451+
452+ fn create_repo_with_commit(root: &std::path::Path) -> std::path::PathBuf {
453+ let repo_path = root.join("test.git");
454+ let work_dir = root.join("work");
455+ std::fs::create_dir(&work_dir).unwrap();
456+ Command::new("git")
457+ .args(["init", "--bare", repo_path.to_str().unwrap()])
458+ .output()
459+ .unwrap();
460+ Command::new("git")
461+ .args(["symbolic-ref", "HEAD", "refs/heads/main"])
462+ .current_dir(&repo_path)
463+ .output()
464+ .unwrap();
465+ Command::new("git")
466+ .args([
467+ "clone",
468+ repo_path.to_str().unwrap(),
469+ work_dir.to_str().unwrap(),
470+ ])
471+ .output()
472+ .unwrap();
473+ Command::new("git")
474+ .current_dir(&work_dir)
475+ .args(["commit", "--allow-empty", "-m", "init"])
476+ .env("GIT_AUTHOR_NAME", "Test")
477+ .env("GIT_AUTHOR_EMAIL", "t@t.com")
478+ .env("GIT_COMMITTER_NAME", "Test")
479+ .env("GIT_COMMITTER_EMAIL", "t@t.com")
480+ .output()
481+ .unwrap();
482+ Command::new("git")
483+ .current_dir(&work_dir)
484+ .args(["push", "origin", "main"])
485+ .output()
486+ .unwrap();
487+ repo_path
488+ }
489+
490+ #[test]
491+ fn advertise_receive_pack_refs() {
492+ let root = TempDir::new().unwrap();
493+ let repo_path = create_repo_with_commit(root.path());
494+ let output = advertise_receive_refs(&repo_path).unwrap();
495+ let output_str = String::from_utf8_lossy(&output);
496+ assert!(output_str.contains("refs/heads/main"));
497+ assert!(output_str.contains("report-status"));
498+ }
499+
500+ #[test]
501+ fn parse_receive_pack_request_with_capabilities() {
502+ let payload = b"0000000000000000000000000000000000000000 1111111111111111111111111111111111111111 refs/heads/main\0 report-status-v2 side-band-64k\n";
503+ let mut body = format!("{:04x}", payload.len() + 4).into_bytes();
504+ body.extend_from_slice(payload);
505+ body.extend_from_slice(b"0000PACK");
506+
507+ let interrupt = AtomicBool::new(false);
508+ let parsed = parse_request(std::io::Cursor::new(&body), &interrupt).unwrap();
509+ assert_eq!(parsed.commands.len(), 1);
510+ assert!(parsed.capabilities.report_status);
511+ assert!(parsed.capabilities.report_status_v2);
512+ let mut pack = String::new();
513+ let mut reader = parsed.pack;
514+ reader.read_to_string(&mut pack).unwrap();
515+ assert_eq!(pack.as_bytes(), b"PACK");
516+ }
517+
518+ #[test]
519+ fn branch_updates_require_commit_target() {
520+ let root = TempDir::new().unwrap();
521+ let repo_path = create_repo_with_commit(root.path());
522+ let repo = gix::open(repo_path).unwrap();
523+ let tree_id = Command::new("git")
524+ .args(["rev-parse", "HEAD^{tree}"])
525+ .current_dir(root.path().join("work"))
526+ .output()
527+ .unwrap();
528+ let tree_id = String::from_utf8(tree_id.stdout)
529+ .unwrap()
530+ .trim()
531+ .to_string();
532+
533+ let err = validate_ref_update(
534+ &repo,
535+ &UpdateCommand {
536+ old_id: ZERO_ID.into(),
537+ new_id: tree_id,
538+ refname: "refs/heads/feature".into(),
539+ },
540+ &AtomicBool::new(false),
541+ )
542+ .unwrap_err();
543+
544+ assert!(err.to_string().contains("must point to a commit"));
545+ }
546+
547+ #[test]
548+ fn ensure_fast_forward_respects_interrupt() {
549+ let root = TempDir::new().unwrap();
550+ let repo_path = create_repo_with_commit(root.path());
551+ let repo = gix::open(repo_path).unwrap();
552+ let head = repo.head_id().unwrap().detach();
553+ let interrupt = AtomicBool::new(true);
554+
555+ let err =
556+ ensure_fast_forward(&repo, head, head, "refs/heads/main", &interrupt).unwrap_err();
557+ match err {
558+ Error::Io(inner) => assert_eq!(inner.kind(), std::io::ErrorKind::TimedOut),
559+ other => panic!("expected timeout io error, got {other}"),
560+ }
561+ }
562+}
addedvendor/gitserver-core/src/refs.rs+223 −0
1+use std::path::Path;
2+
3+use crate::error::Result;
4+use crate::pktline;
5+
6+const CAPABILITIES: &str = "multi_ack multi_ack_detailed side-band-64k ofs-delta";
7+
8+const ZERO_OID: &str = "0000000000000000000000000000000000000000";
9+
10+/// Generate the complete pkt-line encoded ref advertisement response
11+/// for the Git smart HTTP protocol (`git-upload-pack`).
12+///
13+/// The output follows the format expected by `git clone` / `git fetch`:
14+///
15+/// ```text
16+/// 001e# service=git-upload-pack\n
17+/// 0000
18+/// <first-ref>\0<capabilities>\n
19+/// <ref-line>\n
20+/// ...
21+/// 0000
22+/// ```
23+pub fn advertise_refs(repo_path: &Path) -> Result<Vec<u8>> {
24+ let repo = gix::open(repo_path)?;
25+
26+ let mut output = Vec::new();
27+
28+ // Service header + flush
29+ output.extend_from_slice(&pktline::encode_comment("service=git-upload-pack"));
30+ output.extend_from_slice(pktline::flush());
31+
32+ // Collect refs: (oid_hex, refname)
33+ let mut refs: Vec<(String, String)> = Vec::new();
34+
35+ // Try to resolve HEAD first
36+ if let Ok(id) = repo.head_id() {
37+ refs.push((id.to_string(), "HEAD".to_string()));
38+ }
39+
40+ // Iterate all references
41+ if let Ok(platform) = repo.references()
42+ && let Ok(iter) = platform.all()
43+ {
44+ for mut r in iter.flatten() {
45+ let name = r.name().as_bstr().to_string();
46+ if let Ok(id) = r.peel_to_id() {
47+ refs.push((id.to_string(), name));
48+ }
49+ }
50+ }
51+
52+ if refs.is_empty() {
53+ // No refs at all: advertise capabilities with zero OID
54+ let line = format!("{ZERO_OID} capabilities^{{}}\0{CAPABILITIES}\n");
55+ output.extend_from_slice(&pktline::encode(line.as_bytes()));
56+ } else {
57+ // First ref line includes capabilities after NUL byte
58+ let (oid, name) = &refs[0];
59+ let first_line = format!("{oid} {name}\0{CAPABILITIES}\n");
60+ output.extend_from_slice(&pktline::encode(first_line.as_bytes()));
61+
62+ // Subsequent ref lines
63+ for (oid, name) in &refs[1..] {
64+ let line = format!("{oid} {name}\n");
65+ output.extend_from_slice(&pktline::encode(line.as_bytes()));
66+ }
67+ }
68+
69+ // Final flush
70+ output.extend_from_slice(pktline::flush());
71+
72+ Ok(output)
73+}
74+
75+#[cfg(test)]
76+mod tests {
77+ use std::path::{
78+ Path,
79+ PathBuf,
80+ };
81+ use std::process::Command;
82+
83+ use tempfile::TempDir;
84+
85+ use super::*;
86+
87+ /// Create a bare repo with a single commit on the `main` branch.
88+ ///
89+ /// Returns the path to the bare repository.
90+ fn create_repo_with_commit(root: &Path) -> PathBuf {
91+ let bare_path = root.join("test.git");
92+ let clone_path = root.join("workdir");
93+
94+ // Create a bare repository
95+ let out = Command::new("git")
96+ .args(["init", "--bare", bare_path.to_str().unwrap()])
97+ .output()
98+ .expect("git init --bare failed");
99+ assert!(out.status.success(), "git init --bare failed: {:?}", out);
100+
101+ // Set default branch to main in bare repo
102+ let out = Command::new("git")
103+ .args(["symbolic-ref", "HEAD", "refs/heads/main"])
104+ .current_dir(&bare_path)
105+ .output()
106+ .expect("git symbolic-ref failed");
107+ assert!(out.status.success());
108+
109+ // Clone the bare repo into a working directory
110+ let out = Command::new("git")
111+ .args([
112+ "clone",
113+ bare_path.to_str().unwrap(),
114+ clone_path.to_str().unwrap(),
115+ ])
116+ .output()
117+ .expect("git clone failed");
118+ assert!(out.status.success(), "git clone failed: {:?}", out);
119+
120+ // Configure user in the clone
121+ for (key, val) in [("user.name", "Test User"), ("user.email", "test@test.com")] {
122+ Command::new("git")
123+ .args(["config", key, val])
124+ .current_dir(&clone_path)
125+ .output()
126+ .expect("git config failed");
127+ }
128+
129+ // Create a commit
130+ let out = Command::new("git")
131+ .args(["commit", "--allow-empty", "-m", "initial commit"])
132+ .current_dir(&clone_path)
133+ .env("GIT_AUTHOR_NAME", "Test User")
134+ .env("GIT_AUTHOR_EMAIL", "test@test.com")
135+ .env("GIT_COMMITTER_NAME", "Test User")
136+ .env("GIT_COMMITTER_EMAIL", "test@test.com")
137+ .output()
138+ .expect("git commit failed");
139+ assert!(out.status.success(), "git commit failed: {:?}", out);
140+
141+ // Push to the bare repo
142+ let out = Command::new("git")
143+ .args(["push", "origin", "main"])
144+ .current_dir(&clone_path)
145+ .output()
146+ .expect("git push failed");
147+ assert!(out.status.success(), "git push failed: {:?}", out);
148+
149+ bare_path
150+ }
151+
152+ #[test]
153+ fn advertise_refs_starts_with_service_header() {
154+ let dir = TempDir::new().unwrap();
155+ let repo_path = create_repo_with_commit(dir.path());
156+ let output = advertise_refs(&repo_path).unwrap();
157+ let output_str = String::from_utf8_lossy(&output);
158+
159+ assert!(
160+ output_str.starts_with("001e# service=git-upload-pack\n0000"),
161+ "output does not start with service header: {output_str:?}"
162+ );
163+ }
164+
165+ #[test]
166+ fn advertise_refs_contains_capabilities() {
167+ let dir = TempDir::new().unwrap();
168+ let repo_path = create_repo_with_commit(dir.path());
169+ let output = advertise_refs(&repo_path).unwrap();
170+ let output_str = String::from_utf8_lossy(&output);
171+
172+ assert!(
173+ output_str.contains("multi_ack"),
174+ "output missing multi_ack: {output_str:?}"
175+ );
176+ assert!(
177+ output_str.contains("multi_ack_detailed"),
178+ "output missing multi_ack_detailed: {output_str:?}"
179+ );
180+ assert!(
181+ output_str.contains("side-band-64k"),
182+ "output missing side-band-64k: {output_str:?}"
183+ );
184+ assert!(
185+ output_str.contains("ofs-delta"),
186+ "output missing ofs-delta: {output_str:?}"
187+ );
188+ assert!(
189+ !output_str.contains("thin-pack"),
190+ "output should not advertise thin-pack: {output_str:?}"
191+ );
192+ assert!(
193+ !output_str.contains("shallow"),
194+ "output should not advertise shallow: {output_str:?}"
195+ );
196+ }
197+
198+ #[test]
199+ fn advertise_refs_contains_main_branch() {
200+ let dir = TempDir::new().unwrap();
201+ let repo_path = create_repo_with_commit(dir.path());
202+ let output = advertise_refs(&repo_path).unwrap();
203+ let output_str = String::from_utf8_lossy(&output);
204+
205+ assert!(
206+ output_str.contains("refs/heads/main"),
207+ "output missing refs/heads/main: {output_str:?}"
208+ );
209+ }
210+
211+ #[test]
212+ fn advertise_refs_ends_with_flush() {
213+ let dir = TempDir::new().unwrap();
214+ let repo_path = create_repo_with_commit(dir.path());
215+ let output = advertise_refs(&repo_path).unwrap();
216+
217+ assert!(
218+ output.ends_with(b"0000"),
219+ "output does not end with flush packet: {:?}",
220+ String::from_utf8_lossy(&output)
221+ );
222+ }
223+}