irongit

Git hosting and a container registry in one Rust binary (axum + Astro)

Foundation: workspace, schema, auth, permissions, R2 storage, git smart HTTP, push processing, size-limit hook, page shell

huncholanehuncholaneauthored
parent 04ffa68commit b9bb9b7c4972f858e0ae7755802c4ee612a86748Browse files

59 files changed, +11457 -0

+7-0.gitignore
@@ -5,3 +5,10 @@ gitea/
55 secrets.md
66 .env
77 .env.*
8+
9+# Build output and local state
10+target/
11+frontend/dist/
12+frontend/node_modules/
13+frontend/.astro/
14+data/
+5273-0Cargo.lock
@@ -0,0 +1,5273 @@
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.1"
14+source = "registry+https://github.com/rust-lang/crates.io-index"
15+checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99"
16+dependencies = [
17+ "crypto-common 0.2.2",
18+ "inout",
19+]
20+
21+[[package]]
22+name = "aes"
23+version = "0.9.3"
24+source = "registry+https://github.com/rust-lang/crates.io-index"
25+checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32"
26+dependencies = [
27+ "cipher",
28+ "cpubits",
29+ "cpufeatures 0.3.1",
30+ "zeroize",
31+]
32+
33+[[package]]
34+name = "aes-gcm"
35+version = "0.11.1"
36+source = "registry+https://github.com/rust-lang/crates.io-index"
37+checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f"
38+dependencies = [
39+ "aead",
40+ "aes",
41+ "cipher",
42+ "ctr",
43+ "ctutils",
44+ "ghash",
45+ "zeroize",
46+]
47+
48+[[package]]
49+name = "aho-corasick"
50+version = "1.1.5"
51+source = "registry+https://github.com/rust-lang/crates.io-index"
52+checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
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.6"
66+source = "registry+https://github.com/rust-lang/crates.io-index"
67+checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
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 0.61.2",
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 0.61.2",
120+]
121+
122+[[package]]
123+name = "anyhow"
124+version = "1.0.104"
125+source = "registry+https://github.com/rust-lang/crates.io-index"
126+checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
127+
128+[[package]]
129+name = "argon2"
130+version = "0.6.0"
131+source = "registry+https://github.com/rust-lang/crates.io-index"
132+checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c"
133+dependencies = [
134+ "base64ct",
135+ "blake2",
136+ "cpufeatures 0.3.1",
137+ "password-hash",
138+]
139+
140+[[package]]
141+name = "async-compression"
142+version = "0.4.50"
143+source = "registry+https://github.com/rust-lang/crates.io-index"
144+checksum = "ee19bd99b43e3691acbad4e840420a4881cea6c0b66a208125a824f8fd53f5a1"
145+dependencies = [
146+ "compression-codecs",
147+ "compression-core",
148+ "pin-project-lite",
149+ "tokio",
150+]
151+
152+[[package]]
153+name = "async-trait"
154+version = "0.1.92"
155+source = "registry+https://github.com/rust-lang/crates.io-index"
156+checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
157+dependencies = [
158+ "proc-macro2",
159+ "quote",
160+ "syn 3.0.6",
161+]
162+
163+[[package]]
164+name = "atoi"
165+version = "2.0.0"
166+source = "registry+https://github.com/rust-lang/crates.io-index"
167+checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528"
168+dependencies = [
169+ "num-traits",
170+]
171+
172+[[package]]
173+name = "atomic-waker"
174+version = "1.1.2"
175+source = "registry+https://github.com/rust-lang/crates.io-index"
176+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
177+
178+[[package]]
179+name = "autocfg"
180+version = "1.5.1"
181+source = "registry+https://github.com/rust-lang/crates.io-index"
182+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
183+
184+[[package]]
185+name = "aws-lc-rs"
186+version = "1.18.1"
187+source = "registry+https://github.com/rust-lang/crates.io-index"
188+checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
189+dependencies = [
190+ "aws-lc-sys",
191+ "untrusted 0.7.1",
192+ "zeroize",
193+]
194+
195+[[package]]
196+name = "aws-lc-sys"
197+version = "0.45.0"
198+source = "registry+https://github.com/rust-lang/crates.io-index"
199+checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"
200+dependencies = [
201+ "cc",
202+ "cmake",
203+ "dunce",
204+ "fs_extra",
205+ "pkg-config",
206+]
207+
208+[[package]]
209+name = "axum"
210+version = "0.8.9"
211+source = "registry+https://github.com/rust-lang/crates.io-index"
212+checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
213+dependencies = [
214+ "axum-core",
215+ "axum-macros",
216+ "base64 0.22.1",
217+ "bytes",
218+ "form_urlencoded",
219+ "futures-util",
220+ "http",
221+ "http-body",
222+ "http-body-util",
223+ "hyper",
224+ "hyper-util",
225+ "itoa",
226+ "matchit",
227+ "memchr",
228+ "mime",
229+ "multer",
230+ "percent-encoding",
231+ "pin-project-lite",
232+ "serde_core",
233+ "serde_json",
234+ "serde_path_to_error",
235+ "serde_urlencoded",
236+ "sha1 0.10.7",
237+ "sync_wrapper",
238+ "tokio",
239+ "tokio-tungstenite",
240+ "tower",
241+ "tower-layer",
242+ "tower-service",
243+ "tracing",
244+]
245+
246+[[package]]
247+name = "axum-core"
248+version = "0.5.6"
249+source = "registry+https://github.com/rust-lang/crates.io-index"
250+checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
251+dependencies = [
252+ "bytes",
253+ "futures-core",
254+ "http",
255+ "http-body",
256+ "http-body-util",
257+ "mime",
258+ "pin-project-lite",
259+ "sync_wrapper",
260+ "tower-layer",
261+ "tower-service",
262+ "tracing",
263+]
264+
265+[[package]]
266+name = "axum-extra"
267+version = "0.12.6"
268+source = "registry+https://github.com/rust-lang/crates.io-index"
269+checksum = "be44683b41ccb9ab2d23a5230015c9c3c55be97a25e4428366de8873103f7970"
270+dependencies = [
271+ "axum",
272+ "axum-core",
273+ "bytes",
274+ "cookie",
275+ "futures-core",
276+ "futures-util",
277+ "headers",
278+ "http",
279+ "http-body",
280+ "http-body-util",
281+ "mime",
282+ "pin-project-lite",
283+ "tower-layer",
284+ "tower-service",
285+ "tracing",
286+]
287+
288+[[package]]
289+name = "axum-macros"
290+version = "0.5.1"
291+source = "registry+https://github.com/rust-lang/crates.io-index"
292+checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca"
293+dependencies = [
294+ "proc-macro2",
295+ "quote",
296+ "syn 2.0.119",
297+]
298+
299+[[package]]
300+name = "base16ct"
301+version = "1.0.0"
302+source = "registry+https://github.com/rust-lang/crates.io-index"
303+checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6"
304+
305+[[package]]
306+name = "base64"
307+version = "0.22.1"
308+source = "registry+https://github.com/rust-lang/crates.io-index"
309+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
310+
311+[[package]]
312+name = "base64"
313+version = "0.23.1"
314+source = "registry+https://github.com/rust-lang/crates.io-index"
315+checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
316+
317+[[package]]
318+name = "base64ct"
319+version = "1.8.3"
320+source = "registry+https://github.com/rust-lang/crates.io-index"
321+checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
322+
323+[[package]]
324+name = "bcrypt-pbkdf"
325+version = "0.11.0"
326+source = "registry+https://github.com/rust-lang/crates.io-index"
327+checksum = "144e573728da132683b9488acd528274c790e07fc06ff81ee29f9d8f8b1041e0"
328+dependencies = [
329+ "blowfish",
330+ "pbkdf2",
331+ "sha2 0.11.0",
332+]
333+
334+[[package]]
335+name = "bincode"
336+version = "1.3.3"
337+source = "registry+https://github.com/rust-lang/crates.io-index"
338+checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
339+dependencies = [
340+ "serde",
341+]
342+
343+[[package]]
344+name = "bit-set"
345+version = "0.8.0"
346+source = "registry+https://github.com/rust-lang/crates.io-index"
347+checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
348+dependencies = [
349+ "bit-vec",
350+]
351+
352+[[package]]
353+name = "bit-vec"
354+version = "0.8.0"
355+source = "registry+https://github.com/rust-lang/crates.io-index"
356+checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
357+
358+[[package]]
359+name = "bitflags"
360+version = "1.3.2"
361+source = "registry+https://github.com/rust-lang/crates.io-index"
362+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
363+
364+[[package]]
365+name = "bitflags"
366+version = "2.13.2"
367+source = "registry+https://github.com/rust-lang/crates.io-index"
368+checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
369+dependencies = [
370+ "serde_core",
371+]
372+
373+[[package]]
374+name = "blake2"
375+version = "0.11.0"
376+source = "registry+https://github.com/rust-lang/crates.io-index"
377+checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f"
378+dependencies = [
379+ "digest 0.11.3",
380+]
381+
382+[[package]]
383+name = "block-buffer"
384+version = "0.10.4"
385+source = "registry+https://github.com/rust-lang/crates.io-index"
386+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
387+dependencies = [
388+ "generic-array 0.14.7",
389+]
390+
391+[[package]]
392+name = "block-buffer"
393+version = "0.12.1"
394+source = "registry+https://github.com/rust-lang/crates.io-index"
395+checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
396+dependencies = [
397+ "hybrid-array",
398+ "zeroize",
399+]
400+
401+[[package]]
402+name = "block-padding"
403+version = "0.4.2"
404+source = "registry+https://github.com/rust-lang/crates.io-index"
405+checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b"
406+dependencies = [
407+ "hybrid-array",
408+]
409+
410+[[package]]
411+name = "blowfish"
412+version = "0.10.0"
413+source = "registry+https://github.com/rust-lang/crates.io-index"
414+checksum = "62ce3946557b35e71d1bbe07ec385073ce9eda05043f95de134eb578fcf1a298"
415+dependencies = [
416+ "byteorder",
417+ "cipher",
418+]
419+
420+[[package]]
421+name = "bon"
422+version = "3.10.2"
423+source = "registry+https://github.com/rust-lang/crates.io-index"
424+checksum = "214f5df094ce551a10a30ffb9c70243d61f121a3d985a6495933e181dee6a7d2"
425+dependencies = [
426+ "bon-macros",
427+]
428+
429+[[package]]
430+name = "bon-macros"
431+version = "3.10.2"
432+source = "registry+https://github.com/rust-lang/crates.io-index"
433+checksum = "2706da6c749998cc555d04184909608956a69ad3f8ab1a076fbc867b4a3c3ce5"
434+dependencies = [
435+ "darling",
436+ "ident_case",
437+ "prettyplease",
438+ "proc-macro2",
439+ "quote",
440+ "syn 3.0.6",
441+]
442+
443+[[package]]
444+name = "bumpalo"
445+version = "3.20.3"
446+source = "registry+https://github.com/rust-lang/crates.io-index"
447+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
448+
449+[[package]]
450+name = "byteorder"
451+version = "1.5.0"
452+source = "registry+https://github.com/rust-lang/crates.io-index"
453+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
454+
455+[[package]]
456+name = "bytes"
457+version = "1.12.1"
458+source = "registry+https://github.com/rust-lang/crates.io-index"
459+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
460+
461+[[package]]
462+name = "caseless"
463+version = "0.2.2"
464+source = "registry+https://github.com/rust-lang/crates.io-index"
465+checksum = "8b6fd507454086c8edfd769ca6ada439193cdb209c7681712ef6275cccbfe5d8"
466+dependencies = [
467+ "unicode-normalization",
468+]
469+
470+[[package]]
471+name = "cbc"
472+version = "0.2.1"
473+source = "registry+https://github.com/rust-lang/crates.io-index"
474+checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896"
475+dependencies = [
476+ "cipher",
477+]
478+
479+[[package]]
480+name = "cc"
481+version = "1.6.0"
482+source = "registry+https://github.com/rust-lang/crates.io-index"
483+checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630"
484+dependencies = [
485+ "find-msvc-tools",
486+ "jobserver",
487+ "libc",
488+ "shlex",
489+]
490+
491+[[package]]
492+name = "cfg-if"
493+version = "1.0.5"
494+source = "registry+https://github.com/rust-lang/crates.io-index"
495+checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
496+
497+[[package]]
498+name = "cfg_aliases"
499+version = "0.2.2"
500+source = "registry+https://github.com/rust-lang/crates.io-index"
501+checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
502+
503+[[package]]
504+name = "chacha20"
505+version = "0.10.2"
506+source = "registry+https://github.com/rust-lang/crates.io-index"
507+checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
508+dependencies = [
509+ "cfg-if",
510+ "cipher",
511+ "cpufeatures 0.3.1",
512+ "rand_core 0.10.1",
513+ "zeroize",
514+]
515+
516+[[package]]
517+name = "chrono"
518+version = "0.4.45"
519+source = "registry+https://github.com/rust-lang/crates.io-index"
520+checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
521+dependencies = [
522+ "iana-time-zone",
523+ "js-sys",
524+ "num-traits",
525+ "serde",
526+ "wasm-bindgen",
527+ "windows-link",
528+]
529+
530+[[package]]
531+name = "cipher"
532+version = "0.5.2"
533+source = "registry+https://github.com/rust-lang/crates.io-index"
534+checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
535+dependencies = [
536+ "block-buffer 0.12.1",
537+ "crypto-common 0.2.2",
538+ "inout",
539+ "zeroize",
540+]
541+
542+[[package]]
543+name = "clap"
544+version = "4.6.7"
545+source = "registry+https://github.com/rust-lang/crates.io-index"
546+checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
547+dependencies = [
548+ "clap_builder",
549+ "clap_derive",
550+]
551+
552+[[package]]
553+name = "clap_builder"
554+version = "4.6.7"
555+source = "registry+https://github.com/rust-lang/crates.io-index"
556+checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
557+dependencies = [
558+ "anstream",
559+ "anstyle",
560+ "clap_lex",
561+ "strsim",
562+ "terminal_size",
563+]
564+
565+[[package]]
566+name = "clap_derive"
567+version = "4.6.7"
568+source = "registry+https://github.com/rust-lang/crates.io-index"
569+checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
570+dependencies = [
571+ "heck",
572+ "proc-macro2",
573+ "quote",
574+ "syn 3.0.6",
575+]
576+
577+[[package]]
578+name = "clap_lex"
579+version = "1.1.1"
580+source = "registry+https://github.com/rust-lang/crates.io-index"
581+checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
582+
583+[[package]]
584+name = "cmake"
585+version = "0.1.58"
586+source = "registry+https://github.com/rust-lang/crates.io-index"
587+checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
588+dependencies = [
589+ "cc",
590+]
591+
592+[[package]]
593+name = "cmov"
594+version = "0.5.4"
595+source = "registry+https://github.com/rust-lang/crates.io-index"
596+checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
597+
598+[[package]]
599+name = "colorchoice"
600+version = "1.0.5"
601+source = "registry+https://github.com/rust-lang/crates.io-index"
602+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
603+
604+[[package]]
605+name = "combine"
606+version = "4.6.8"
607+source = "registry+https://github.com/rust-lang/crates.io-index"
608+checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e"
609+dependencies = [
610+ "bytes",
611+ "memchr",
612+]
613+
614+[[package]]
615+name = "compression-codecs"
616+version = "0.4.45"
617+source = "registry+https://github.com/rust-lang/crates.io-index"
618+checksum = "98fc98460ba0ad5317075d3632b8dfc45d0be8c4a49347c2a38272019717614a"
619+dependencies = [
620+ "compression-core",
621+ "flate2",
622+ "memchr",
623+]
624+
625+[[package]]
626+name = "compression-core"
627+version = "0.4.33"
628+source = "registry+https://github.com/rust-lang/crates.io-index"
629+checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414"
630+
631+[[package]]
632+name = "comrak"
633+version = "0.56.0"
634+source = "registry+https://github.com/rust-lang/crates.io-index"
635+checksum = "2544c4d6b6da7d54e95ac2833b464bf3dffa0666237868f2bf4ec5e11c9ba8cc"
636+dependencies = [
637+ "bon",
638+ "caseless",
639+ "clap",
640+ "emojis",
641+ "entities",
642+ "finl_unicode",
643+ "fmt2io",
644+ "memchr-n",
645+ "phf 0.14.0",
646+ "phf_codegen",
647+ "rustc-hash",
648+ "shell-words",
649+ "smallvec",
650+ "syntect",
651+ "typed-arena",
652+ "xdg",
653+]
654+
655+[[package]]
656+name = "const-oid"
657+version = "0.10.2"
658+source = "registry+https://github.com/rust-lang/crates.io-index"
659+checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
660+
661+[[package]]
662+name = "cookie"
663+version = "0.18.2"
664+source = "registry+https://github.com/rust-lang/crates.io-index"
665+checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87"
666+dependencies = [
667+ "percent-encoding",
668+ "time",
669+ "version_check",
670+]
671+
672+[[package]]
673+name = "core-foundation"
674+version = "0.10.1"
675+source = "registry+https://github.com/rust-lang/crates.io-index"
676+checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
677+dependencies = [
678+ "core-foundation-sys",
679+ "libc",
680+]
681+
682+[[package]]
683+name = "core-foundation-sys"
684+version = "0.8.7"
685+source = "registry+https://github.com/rust-lang/crates.io-index"
686+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
687+
688+[[package]]
689+name = "core_detect"
690+version = "1.0.0"
691+source = "registry+https://github.com/rust-lang/crates.io-index"
692+checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
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.1"
712+source = "registry+https://github.com/rust-lang/crates.io-index"
713+checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
714+dependencies = [
715+ "libc",
716+]
717+
718+[[package]]
719+name = "crc"
720+version = "3.4.0"
721+source = "registry+https://github.com/rust-lang/crates.io-index"
722+checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"
723+dependencies = [
724+ "crc-catalog",
725+]
726+
727+[[package]]
728+name = "crc-catalog"
729+version = "2.5.0"
730+source = "registry+https://github.com/rust-lang/crates.io-index"
731+checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
732+
733+[[package]]
734+name = "crc32fast"
735+version = "1.5.2"
736+source = "registry+https://github.com/rust-lang/crates.io-index"
737+checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
738+dependencies = [
739+ "cfg-if",
740+]
741+
742+[[package]]
743+name = "crossbeam-channel"
744+version = "0.5.17"
745+source = "registry+https://github.com/rust-lang/crates.io-index"
746+checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1"
747+dependencies = [
748+ "crossbeam-utils",
749+]
750+
751+[[package]]
752+name = "crossbeam-queue"
753+version = "0.3.14"
754+source = "registry+https://github.com/rust-lang/crates.io-index"
755+checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae"
756+dependencies = [
757+ "crossbeam-utils",
758+]
759+
760+[[package]]
761+name = "crossbeam-utils"
762+version = "0.8.23"
763+source = "registry+https://github.com/rust-lang/crates.io-index"
764+checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
765+
766+[[package]]
767+name = "crypto-bigint"
768+version = "0.7.5"
769+source = "registry+https://github.com/rust-lang/crates.io-index"
770+checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271"
771+dependencies = [
772+ "cpubits",
773+ "ctutils",
774+ "getrandom 0.4.3",
775+ "hybrid-array",
776+ "num-traits",
777+ "rand_core 0.10.1",
778+ "serdect",
779+ "subtle",
780+ "zeroize",
781+]
782+
783+[[package]]
784+name = "crypto-common"
785+version = "0.1.7"
786+source = "registry+https://github.com/rust-lang/crates.io-index"
787+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
788+dependencies = [
789+ "generic-array 0.14.7",
790+ "typenum",
791+]
792+
793+[[package]]
794+name = "crypto-common"
795+version = "0.2.2"
796+source = "registry+https://github.com/rust-lang/crates.io-index"
797+checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
798+dependencies = [
799+ "getrandom 0.4.3",
800+ "hybrid-array",
801+ "rand_core 0.10.1",
802+]
803+
804+[[package]]
805+name = "crypto-primes"
806+version = "0.7.2"
807+source = "registry+https://github.com/rust-lang/crates.io-index"
808+checksum = "3633a51a39c69ebbaa4feaa694bd83d241e4093901c84a0963b19d9bb3f0cf8f"
809+dependencies = [
810+ "crypto-bigint",
811+ "rand_core 0.10.1",
812+]
813+
814+[[package]]
815+name = "ctr"
816+version = "0.10.1"
817+source = "registry+https://github.com/rust-lang/crates.io-index"
818+checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21"
819+dependencies = [
820+ "cipher",
821+]
822+
823+[[package]]
824+name = "ctutils"
825+version = "0.4.3"
826+source = "registry+https://github.com/rust-lang/crates.io-index"
827+checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574"
828+dependencies = [
829+ "cmov",
830+ "subtle",
831+]
832+
833+[[package]]
834+name = "curve25519-dalek"
835+version = "5.0.0"
836+source = "registry+https://github.com/rust-lang/crates.io-index"
837+checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23"
838+dependencies = [
839+ "cfg-if",
840+ "cpufeatures 0.3.1",
841+ "curve25519-dalek-derive",
842+ "digest 0.11.3",
843+ "fiat-crypto",
844+ "rand_core 0.10.1",
845+ "rustc_version",
846+ "subtle",
847+ "zeroize",
848+]
849+
850+[[package]]
851+name = "curve25519-dalek-derive"
852+version = "0.1.1"
853+source = "registry+https://github.com/rust-lang/crates.io-index"
854+checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
855+dependencies = [
856+ "proc-macro2",
857+ "quote",
858+ "syn 2.0.119",
859+]
860+
861+[[package]]
862+name = "darling"
863+version = "0.24.1"
864+source = "registry+https://github.com/rust-lang/crates.io-index"
865+checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec"
866+dependencies = [
867+ "darling_core",
868+ "darling_macro",
869+]
870+
871+[[package]]
872+name = "darling_core"
873+version = "0.24.1"
874+source = "registry+https://github.com/rust-lang/crates.io-index"
875+checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff"
876+dependencies = [
877+ "ident_case",
878+ "proc-macro2",
879+ "quote",
880+ "strsim",
881+ "syn 3.0.6",
882+]
883+
884+[[package]]
885+name = "darling_macro"
886+version = "0.24.1"
887+source = "registry+https://github.com/rust-lang/crates.io-index"
888+checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785"
889+dependencies = [
890+ "darling_core",
891+ "quote",
892+ "syn 3.0.6",
893+]
894+
895+[[package]]
896+name = "data-encoding"
897+version = "2.11.1"
898+source = "registry+https://github.com/rust-lang/crates.io-index"
899+checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
900+
901+[[package]]
902+name = "defmt"
903+version = "1.1.1"
904+source = "registry+https://github.com/rust-lang/crates.io-index"
905+checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
906+dependencies = [
907+ "bitflags 1.3.2",
908+ "defmt-macros",
909+]
910+
911+[[package]]
912+name = "defmt-macros"
913+version = "1.1.1"
914+source = "registry+https://github.com/rust-lang/crates.io-index"
915+checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
916+dependencies = [
917+ "defmt-parser",
918+ "proc-macro2",
919+ "quote",
920+ "syn 2.0.119",
921+]
922+
923+[[package]]
924+name = "defmt-parser"
925+version = "1.0.0"
926+source = "registry+https://github.com/rust-lang/crates.io-index"
927+checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
928+dependencies = [
929+ "thiserror",
930+]
931+
932+[[package]]
933+name = "delegate"
934+version = "0.13.5"
935+source = "registry+https://github.com/rust-lang/crates.io-index"
936+checksum = "780eb241654bf097afb00fc5f054a09b687dad862e485fdcf8399bb056565370"
937+dependencies = [
938+ "proc-macro2",
939+ "quote",
940+ "syn 2.0.119",
941+]
942+
943+[[package]]
944+name = "der"
945+version = "0.8.2"
946+source = "registry+https://github.com/rust-lang/crates.io-index"
947+checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a"
948+dependencies = [
949+ "const-oid",
950+ "pem-rfc7468",
951+ "zeroize",
952+]
953+
954+[[package]]
955+name = "deranged"
956+version = "0.5.8"
957+source = "registry+https://github.com/rust-lang/crates.io-index"
958+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
959+
960+[[package]]
961+name = "des"
962+version = "0.9.0"
963+source = "registry+https://github.com/rust-lang/crates.io-index"
964+checksum = "916a94e407b54f9034d71dd748234cd1e516ced6284009906ae246f177eafe5a"
965+dependencies = [
966+ "cipher",
967+]
968+
969+[[package]]
970+name = "digest"
971+version = "0.10.7"
972+source = "registry+https://github.com/rust-lang/crates.io-index"
973+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
974+dependencies = [
975+ "block-buffer 0.10.4",
976+ "crypto-common 0.1.7",
977+]
978+
979+[[package]]
980+name = "digest"
981+version = "0.11.3"
982+source = "registry+https://github.com/rust-lang/crates.io-index"
983+checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
984+dependencies = [
985+ "block-buffer 0.12.1",
986+ "const-oid",
987+ "crypto-common 0.2.2",
988+ "ctutils",
989+]
990+
991+[[package]]
992+name = "dirs"
993+version = "7.0.0"
994+source = "registry+https://github.com/rust-lang/crates.io-index"
995+checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f"
996+dependencies = [
997+ "dirs-sys",
998+]
999+
1000+[[package]]
1001+name = "dirs-sys"
1002+version = "0.5.0"
1003+source = "registry+https://github.com/rust-lang/crates.io-index"
1004+checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
1005+dependencies = [
1006+ "libc",
1007+ "option-ext",
1008+ "redox_users",
1009+ "windows-sys 0.61.2",
1010+]
1011+
1012+[[package]]
1013+name = "displaydoc"
1014+version = "0.2.7"
1015+source = "registry+https://github.com/rust-lang/crates.io-index"
1016+checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
1017+dependencies = [
1018+ "proc-macro2",
1019+ "quote",
1020+ "syn 3.0.6",
1021+]
1022+
1023+[[package]]
1024+name = "dotenvy"
1025+version = "0.15.7"
1026+source = "registry+https://github.com/rust-lang/crates.io-index"
1027+checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
1028+
1029+[[package]]
1030+name = "dunce"
1031+version = "1.0.5"
1032+source = "registry+https://github.com/rust-lang/crates.io-index"
1033+checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
1034+
1035+[[package]]
1036+name = "ecdsa"
1037+version = "0.17.0"
1038+source = "registry+https://github.com/rust-lang/crates.io-index"
1039+checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0"
1040+dependencies = [
1041+ "der",
1042+ "digest 0.11.3",
1043+ "elliptic-curve",
1044+ "rfc6979",
1045+ "signature",
1046+ "spki",
1047+ "zeroize",
1048+]
1049+
1050+[[package]]
1051+name = "ed25519"
1052+version = "3.0.0"
1053+source = "registry+https://github.com/rust-lang/crates.io-index"
1054+checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a"
1055+dependencies = [
1056+ "pkcs8",
1057+ "signature",
1058+]
1059+
1060+[[package]]
1061+name = "ed25519-dalek"
1062+version = "3.0.0"
1063+source = "registry+https://github.com/rust-lang/crates.io-index"
1064+checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de"
1065+dependencies = [
1066+ "curve25519-dalek",
1067+ "ed25519",
1068+ "rand_core 0.10.1",
1069+ "serde",
1070+ "sha2 0.11.0",
1071+ "signature",
1072+ "subtle",
1073+ "zeroize",
1074+]
1075+
1076+[[package]]
1077+name = "either"
1078+version = "1.19.0"
1079+source = "registry+https://github.com/rust-lang/crates.io-index"
1080+checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
1081+dependencies = [
1082+ "serde",
1083+]
1084+
1085+[[package]]
1086+name = "elliptic-curve"
1087+version = "0.14.1"
1088+source = "registry+https://github.com/rust-lang/crates.io-index"
1089+checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65"
1090+dependencies = [
1091+ "base16ct",
1092+ "crypto-bigint",
1093+ "crypto-common 0.2.2",
1094+ "digest 0.11.3",
1095+ "ff",
1096+ "group",
1097+ "hkdf",
1098+ "hybrid-array",
1099+ "pem-rfc7468",
1100+ "pkcs8",
1101+ "rand_core 0.10.1",
1102+ "sec1",
1103+ "subtle",
1104+ "zeroize",
1105+]
1106+
1107+[[package]]
1108+name = "email-encoding"
1109+version = "0.4.2"
1110+source = "registry+https://github.com/rust-lang/crates.io-index"
1111+checksum = "420b9da095f052ea597503e39073b5b3c522f7db933fbac202d91d24492693fd"
1112+dependencies = [
1113+ "base64 0.23.1",
1114+ "memchr",
1115+]
1116+
1117+[[package]]
1118+name = "email_address"
1119+version = "0.2.9"
1120+source = "registry+https://github.com/rust-lang/crates.io-index"
1121+checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
1122+
1123+[[package]]
1124+name = "emojis"
1125+version = "0.9.0"
1126+source = "registry+https://github.com/rust-lang/crates.io-index"
1127+checksum = "0b1514ced566c94991ed9563258ecf61e311fd773bf0a3f20606830386bf66e3"
1128+dependencies = [
1129+ "phf 0.13.1",
1130+]
1131+
1132+[[package]]
1133+name = "encoding_rs"
1134+version = "0.8.42"
1135+source = "registry+https://github.com/rust-lang/crates.io-index"
1136+checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679"
1137+dependencies = [
1138+ "cfg-if",
1139+ "core_detect",
1140+ "multiversion_no_op",
1141+ "rustversion",
1142+ "scopeguard",
1143+ "simdutf8",
1144+]
1145+
1146+[[package]]
1147+name = "entities"
1148+version = "1.0.1"
1149+source = "registry+https://github.com/rust-lang/crates.io-index"
1150+checksum = "b5320ae4c3782150d900b79807611a59a99fc9a1d61d686faafc24b93fc8d7ca"
1151+
1152+[[package]]
1153+name = "enum_dispatch"
1154+version = "0.3.13"
1155+source = "registry+https://github.com/rust-lang/crates.io-index"
1156+checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd"
1157+dependencies = [
1158+ "once_cell",
1159+ "proc-macro2",
1160+ "quote",
1161+ "syn 2.0.119",
1162+]
1163+
1164+[[package]]
1165+name = "equivalent"
1166+version = "1.0.2"
1167+source = "registry+https://github.com/rust-lang/crates.io-index"
1168+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
1169+
1170+[[package]]
1171+name = "errno"
1172+version = "0.3.14"
1173+source = "registry+https://github.com/rust-lang/crates.io-index"
1174+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
1175+dependencies = [
1176+ "libc",
1177+ "windows-sys 0.61.2",
1178+]
1179+
1180+[[package]]
1181+name = "etcetera"
1182+version = "0.11.0"
1183+source = "registry+https://github.com/rust-lang/crates.io-index"
1184+checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96"
1185+dependencies = [
1186+ "cfg-if",
1187+ "windows-sys 0.61.2",
1188+]
1189+
1190+[[package]]
1191+name = "event-listener"
1192+version = "5.4.2"
1193+source = "registry+https://github.com/rust-lang/crates.io-index"
1194+checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
1195+dependencies = [
1196+ "parking",
1197+ "pin-project-lite",
1198+]
1199+
1200+[[package]]
1201+name = "fancy-regex"
1202+version = "0.16.2"
1203+source = "registry+https://github.com/rust-lang/crates.io-index"
1204+checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f"
1205+dependencies = [
1206+ "bit-set",
1207+ "regex-automata",
1208+ "regex-syntax",
1209+]
1210+
1211+[[package]]
1212+name = "fastrand"
1213+version = "2.5.0"
1214+source = "registry+https://github.com/rust-lang/crates.io-index"
1215+checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
1216+
1217+[[package]]
1218+name = "fearless_simd"
1219+version = "1.1.0"
1220+source = "registry+https://github.com/rust-lang/crates.io-index"
1221+checksum = "3296eb97d869113eaf148a6bdf01884e90b3828ad7ae2340b0f93362fc158611"
1222+
1223+[[package]]
1224+name = "fearless_simd_macros"
1225+version = "0.1.0"
1226+source = "registry+https://github.com/rust-lang/crates.io-index"
1227+checksum = "3e91d94c6885390b81782d7ee60fd6583b33907333a8b340de25ccf468353315"
1228+dependencies = [
1229+ "proc-macro2",
1230+ "quote",
1231+ "syn 3.0.6",
1232+]
1233+
1234+[[package]]
1235+name = "ff"
1236+version = "0.14.0"
1237+source = "registry+https://github.com/rust-lang/crates.io-index"
1238+checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f"
1239+dependencies = [
1240+ "rand_core 0.10.1",
1241+ "subtle",
1242+]
1243+
1244+[[package]]
1245+name = "fiat-crypto"
1246+version = "0.3.0"
1247+source = "registry+https://github.com/rust-lang/crates.io-index"
1248+checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24"
1249+
1250+[[package]]
1251+name = "find-msvc-tools"
1252+version = "0.1.14"
1253+source = "registry+https://github.com/rust-lang/crates.io-index"
1254+checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
1255+
1256+[[package]]
1257+name = "finl_unicode"
1258+version = "1.5.0"
1259+source = "registry+https://github.com/rust-lang/crates.io-index"
1260+checksum = "80bb028c8b4148c9ee0cca68fcd9add6044e81d3619f48577ddf13a263d047a2"
1261+
1262+[[package]]
1263+name = "flate2"
1264+version = "1.1.10"
1265+source = "registry+https://github.com/rust-lang/crates.io-index"
1266+checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
1267+dependencies = [
1268+ "crc32fast",
1269+ "miniz_oxide",
1270+ "zlib-rs",
1271+]
1272+
1273+[[package]]
1274+name = "flume"
1275+version = "0.12.0"
1276+source = "registry+https://github.com/rust-lang/crates.io-index"
1277+checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be"
1278+dependencies = [
1279+ "futures-core",
1280+ "futures-sink",
1281+ "spin",
1282+]
1283+
1284+[[package]]
1285+name = "fmt2io"
1286+version = "1.0.0"
1287+source = "registry+https://github.com/rust-lang/crates.io-index"
1288+checksum = "6b6129284da9f7e5296cc22183a63f24300e945e297705dcc0672f7df01d62c8"
1289+
1290+[[package]]
1291+name = "fnv"
1292+version = "1.0.7"
1293+source = "registry+https://github.com/rust-lang/crates.io-index"
1294+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
1295+
1296+[[package]]
1297+name = "foldhash"
1298+version = "0.2.0"
1299+source = "registry+https://github.com/rust-lang/crates.io-index"
1300+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
1301+
1302+[[package]]
1303+name = "form_urlencoded"
1304+version = "1.2.2"
1305+source = "registry+https://github.com/rust-lang/crates.io-index"
1306+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
1307+dependencies = [
1308+ "percent-encoding",
1309+]
1310+
1311+[[package]]
1312+name = "fs_extra"
1313+version = "1.3.0"
1314+source = "registry+https://github.com/rust-lang/crates.io-index"
1315+checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
1316+
1317+[[package]]
1318+name = "fsevent-sys"
1319+version = "4.1.0"
1320+source = "registry+https://github.com/rust-lang/crates.io-index"
1321+checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
1322+dependencies = [
1323+ "libc",
1324+]
1325+
1326+[[package]]
1327+name = "futures"
1328+version = "0.3.34"
1329+source = "registry+https://github.com/rust-lang/crates.io-index"
1330+checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3"
1331+dependencies = [
1332+ "futures-channel",
1333+ "futures-core",
1334+ "futures-executor",
1335+ "futures-io",
1336+ "futures-sink",
1337+ "futures-task",
1338+ "futures-util",
1339+]
1340+
1341+[[package]]
1342+name = "futures-channel"
1343+version = "0.3.34"
1344+source = "registry+https://github.com/rust-lang/crates.io-index"
1345+checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
1346+dependencies = [
1347+ "futures-core",
1348+ "futures-sink",
1349+]
1350+
1351+[[package]]
1352+name = "futures-core"
1353+version = "0.3.34"
1354+source = "registry+https://github.com/rust-lang/crates.io-index"
1355+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
1356+
1357+[[package]]
1358+name = "futures-executor"
1359+version = "0.3.34"
1360+source = "registry+https://github.com/rust-lang/crates.io-index"
1361+checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"
1362+dependencies = [
1363+ "futures-core",
1364+ "futures-task",
1365+ "futures-util",
1366+]
1367+
1368+[[package]]
1369+name = "futures-intrusive"
1370+version = "0.5.0"
1371+source = "registry+https://github.com/rust-lang/crates.io-index"
1372+checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f"
1373+dependencies = [
1374+ "futures-core",
1375+ "lock_api",
1376+ "parking_lot",
1377+]
1378+
1379+[[package]]
1380+name = "futures-io"
1381+version = "0.3.34"
1382+source = "registry+https://github.com/rust-lang/crates.io-index"
1383+checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
1384+
1385+[[package]]
1386+name = "futures-macro"
1387+version = "0.3.34"
1388+source = "registry+https://github.com/rust-lang/crates.io-index"
1389+checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
1390+dependencies = [
1391+ "proc-macro2",
1392+ "quote",
1393+ "syn 3.0.6",
1394+]
1395+
1396+[[package]]
1397+name = "futures-sink"
1398+version = "0.3.34"
1399+source = "registry+https://github.com/rust-lang/crates.io-index"
1400+checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
1401+
1402+[[package]]
1403+name = "futures-task"
1404+version = "0.3.34"
1405+source = "registry+https://github.com/rust-lang/crates.io-index"
1406+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
1407+
1408+[[package]]
1409+name = "futures-util"
1410+version = "0.3.34"
1411+source = "registry+https://github.com/rust-lang/crates.io-index"
1412+checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
1413+dependencies = [
1414+ "futures-channel",
1415+ "futures-core",
1416+ "futures-io",
1417+ "futures-macro",
1418+ "futures-sink",
1419+ "futures-task",
1420+ "memchr",
1421+ "pin-project-lite",
1422+ "slab",
1423+]
1424+
1425+[[package]]
1426+name = "generic-array"
1427+version = "0.14.7"
1428+source = "registry+https://github.com/rust-lang/crates.io-index"
1429+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
1430+dependencies = [
1431+ "typenum",
1432+ "version_check",
1433+]
1434+
1435+[[package]]
1436+name = "generic-array"
1437+version = "1.4.5"
1438+source = "registry+https://github.com/rust-lang/crates.io-index"
1439+checksum = "337d46834ee672ab3e48caca2cb0c78cc174fb12b3a68d0d88f99a0519a5e36e"
1440+dependencies = [
1441+ "generic-array 0.14.7",
1442+ "rustversion",
1443+ "typenum",
1444+]
1445+
1446+[[package]]
1447+name = "getrandom"
1448+version = "0.2.17"
1449+source = "registry+https://github.com/rust-lang/crates.io-index"
1450+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
1451+dependencies = [
1452+ "cfg-if",
1453+ "js-sys",
1454+ "libc",
1455+ "wasi",
1456+ "wasm-bindgen",
1457+]
1458+
1459+[[package]]
1460+name = "getrandom"
1461+version = "0.3.4"
1462+source = "registry+https://github.com/rust-lang/crates.io-index"
1463+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
1464+dependencies = [
1465+ "cfg-if",
1466+ "libc",
1467+ "r-efi 5.3.0",
1468+ "wasip2",
1469+]
1470+
1471+[[package]]
1472+name = "getrandom"
1473+version = "0.4.3"
1474+source = "registry+https://github.com/rust-lang/crates.io-index"
1475+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
1476+dependencies = [
1477+ "cfg-if",
1478+ "js-sys",
1479+ "libc",
1480+ "r-efi 6.0.0",
1481+ "rand_core 0.10.1",
1482+ "wasm-bindgen",
1483+]
1484+
1485+[[package]]
1486+name = "ghash"
1487+version = "0.6.0"
1488+source = "registry+https://github.com/rust-lang/crates.io-index"
1489+checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5"
1490+dependencies = [
1491+ "polyval",
1492+ "zeroize",
1493+]
1494+
1495+[[package]]
1496+name = "group"
1497+version = "0.14.0"
1498+source = "registry+https://github.com/rust-lang/crates.io-index"
1499+checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4"
1500+dependencies = [
1501+ "ff",
1502+ "rand_core 0.10.1",
1503+ "subtle",
1504+]
1505+
1506+[[package]]
1507+name = "h2"
1508+version = "0.4.20"
1509+source = "registry+https://github.com/rust-lang/crates.io-index"
1510+checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118"
1511+dependencies = [
1512+ "atomic-waker",
1513+ "bytes",
1514+ "fnv",
1515+ "futures-core",
1516+ "futures-sink",
1517+ "http",
1518+ "indexmap",
1519+ "slab",
1520+ "tokio",
1521+ "tokio-util",
1522+ "tracing",
1523+]
1524+
1525+[[package]]
1526+name = "hashbrown"
1527+version = "0.16.1"
1528+source = "registry+https://github.com/rust-lang/crates.io-index"
1529+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
1530+dependencies = [
1531+ "allocator-api2",
1532+ "equivalent",
1533+ "foldhash",
1534+]
1535+
1536+[[package]]
1537+name = "hashbrown"
1538+version = "0.17.1"
1539+source = "registry+https://github.com/rust-lang/crates.io-index"
1540+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
1541+
1542+[[package]]
1543+name = "hashlink"
1544+version = "0.11.1"
1545+source = "registry+https://github.com/rust-lang/crates.io-index"
1546+checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f"
1547+dependencies = [
1548+ "hashbrown 0.16.1",
1549+]
1550+
1551+[[package]]
1552+name = "headers"
1553+version = "0.4.2"
1554+source = "registry+https://github.com/rust-lang/crates.io-index"
1555+checksum = "fc2700e3fe555c3310aa7286cac6167449f2c87e0eb58769c9208a1c58a1d106"
1556+dependencies = [
1557+ "base64 0.22.1",
1558+ "bytes",
1559+ "headers-core",
1560+ "http",
1561+ "httpdate",
1562+ "mime",
1563+ "sha1 0.10.7",
1564+]
1565+
1566+[[package]]
1567+name = "headers-core"
1568+version = "0.3.0"
1569+source = "registry+https://github.com/rust-lang/crates.io-index"
1570+checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4"
1571+dependencies = [
1572+ "http",
1573+]
1574+
1575+[[package]]
1576+name = "heck"
1577+version = "0.5.0"
1578+source = "registry+https://github.com/rust-lang/crates.io-index"
1579+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
1580+
1581+[[package]]
1582+name = "hex"
1583+version = "0.4.3"
1584+source = "registry+https://github.com/rust-lang/crates.io-index"
1585+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
1586+
1587+[[package]]
1588+name = "hex-literal"
1589+version = "1.1.0"
1590+source = "registry+https://github.com/rust-lang/crates.io-index"
1591+checksum = "e712f64ec3850b98572bffac52e2c6f282b29fe6c5fa6d42334b30be438d95c1"
1592+
1593+[[package]]
1594+name = "hkdf"
1595+version = "0.13.0"
1596+source = "registry+https://github.com/rust-lang/crates.io-index"
1597+checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018"
1598+dependencies = [
1599+ "hmac",
1600+]
1601+
1602+[[package]]
1603+name = "hmac"
1604+version = "0.13.0"
1605+source = "registry+https://github.com/rust-lang/crates.io-index"
1606+checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
1607+dependencies = [
1608+ "digest 0.11.3",
1609+]
1610+
1611+[[package]]
1612+name = "hostname"
1613+version = "0.4.2"
1614+source = "registry+https://github.com/rust-lang/crates.io-index"
1615+checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd"
1616+dependencies = [
1617+ "cfg-if",
1618+ "libc",
1619+ "windows-link",
1620+]
1621+
1622+[[package]]
1623+name = "http"
1624+version = "1.5.0"
1625+source = "registry+https://github.com/rust-lang/crates.io-index"
1626+checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
1627+dependencies = [
1628+ "bytes",
1629+ "itoa",
1630+]
1631+
1632+[[package]]
1633+name = "http-body"
1634+version = "1.1.0"
1635+source = "registry+https://github.com/rust-lang/crates.io-index"
1636+checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
1637+dependencies = [
1638+ "bytes",
1639+ "http",
1640+]
1641+
1642+[[package]]
1643+name = "http-body-util"
1644+version = "0.1.5"
1645+source = "registry+https://github.com/rust-lang/crates.io-index"
1646+checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"
1647+dependencies = [
1648+ "bytes",
1649+ "futures-core",
1650+ "http",
1651+ "http-body",
1652+ "pin-project-lite",
1653+]
1654+
1655+[[package]]
1656+name = "httparse"
1657+version = "1.10.1"
1658+source = "registry+https://github.com/rust-lang/crates.io-index"
1659+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
1660+
1661+[[package]]
1662+name = "httpdate"
1663+version = "1.0.3"
1664+source = "registry+https://github.com/rust-lang/crates.io-index"
1665+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
1666+
1667+[[package]]
1668+name = "hybrid-array"
1669+version = "0.4.15"
1670+source = "registry+https://github.com/rust-lang/crates.io-index"
1671+checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
1672+dependencies = [
1673+ "ctutils",
1674+ "subtle",
1675+ "typenum",
1676+ "zeroize",
1677+]
1678+
1679+[[package]]
1680+name = "hyper"
1681+version = "1.12.0"
1682+source = "registry+https://github.com/rust-lang/crates.io-index"
1683+checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c"
1684+dependencies = [
1685+ "atomic-waker",
1686+ "bytes",
1687+ "futures-channel",
1688+ "futures-core",
1689+ "h2",
1690+ "http",
1691+ "http-body",
1692+ "httparse",
1693+ "httpdate",
1694+ "itoa",
1695+ "pin-project-lite",
1696+ "smallvec",
1697+ "tokio",
1698+ "want",
1699+]
1700+
1701+[[package]]
1702+name = "hyper-rustls"
1703+version = "0.27.10"
1704+source = "registry+https://github.com/rust-lang/crates.io-index"
1705+checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53"
1706+dependencies = [
1707+ "http",
1708+ "hyper",
1709+ "hyper-util",
1710+ "rustls",
1711+ "tokio",
1712+ "tokio-rustls",
1713+ "tower-service",
1714+]
1715+
1716+[[package]]
1717+name = "hyper-util"
1718+version = "0.1.21"
1719+source = "registry+https://github.com/rust-lang/crates.io-index"
1720+checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff"
1721+dependencies = [
1722+ "base64 0.23.1",
1723+ "bytes",
1724+ "futures-channel",
1725+ "futures-util",
1726+ "http",
1727+ "http-body",
1728+ "httparse",
1729+ "hyper",
1730+ "ipnet",
1731+ "libc",
1732+ "percent-encoding",
1733+ "pin-project-lite",
1734+ "socket2",
1735+ "tokio",
1736+ "tower-service",
1737+ "tracing",
1738+]
1739+
1740+[[package]]
1741+name = "iana-time-zone"
1742+version = "0.1.65"
1743+source = "registry+https://github.com/rust-lang/crates.io-index"
1744+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
1745+dependencies = [
1746+ "android_system_properties",
1747+ "core-foundation-sys",
1748+ "iana-time-zone-haiku",
1749+ "js-sys",
1750+ "log",
1751+ "wasm-bindgen",
1752+ "windows-core",
1753+]
1754+
1755+[[package]]
1756+name = "iana-time-zone-haiku"
1757+version = "0.1.2"
1758+source = "registry+https://github.com/rust-lang/crates.io-index"
1759+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
1760+dependencies = [
1761+ "cc",
1762+]
1763+
1764+[[package]]
1765+name = "icu_collections"
1766+version = "2.3.0"
1767+source = "registry+https://github.com/rust-lang/crates.io-index"
1768+checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
1769+dependencies = [
1770+ "displaydoc",
1771+ "potential_utf",
1772+ "utf8_iter",
1773+ "yoke",
1774+ "zerofrom",
1775+ "zerovec",
1776+]
1777+
1778+[[package]]
1779+name = "icu_locale_core"
1780+version = "2.3.0"
1781+source = "registry+https://github.com/rust-lang/crates.io-index"
1782+checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
1783+dependencies = [
1784+ "displaydoc",
1785+ "litemap",
1786+ "tinystr",
1787+ "writeable",
1788+ "zerovec",
1789+]
1790+
1791+[[package]]
1792+name = "icu_normalizer"
1793+version = "2.3.0"
1794+source = "registry+https://github.com/rust-lang/crates.io-index"
1795+checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
1796+dependencies = [
1797+ "icu_collections",
1798+ "icu_normalizer_data",
1799+ "icu_properties",
1800+ "icu_provider",
1801+ "smallvec",
1802+ "zerovec",
1803+]
1804+
1805+[[package]]
1806+name = "icu_normalizer_data"
1807+version = "2.3.0"
1808+source = "registry+https://github.com/rust-lang/crates.io-index"
1809+checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
1810+
1811+[[package]]
1812+name = "icu_properties"
1813+version = "2.3.0"
1814+source = "registry+https://github.com/rust-lang/crates.io-index"
1815+checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
1816+dependencies = [
1817+ "displaydoc",
1818+ "icu_collections",
1819+ "icu_locale_core",
1820+ "icu_properties_data",
1821+ "icu_provider",
1822+ "zerotrie",
1823+ "zerovec",
1824+]
1825+
1826+[[package]]
1827+name = "icu_properties_data"
1828+version = "2.3.0"
1829+source = "registry+https://github.com/rust-lang/crates.io-index"
1830+checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
1831+
1832+[[package]]
1833+name = "icu_provider"
1834+version = "2.3.1"
1835+source = "registry+https://github.com/rust-lang/crates.io-index"
1836+checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
1837+dependencies = [
1838+ "displaydoc",
1839+ "icu_locale_core",
1840+ "writeable",
1841+ "yoke",
1842+ "zerofrom",
1843+ "zerotrie",
1844+ "zerovec",
1845+]
1846+
1847+[[package]]
1848+name = "ident_case"
1849+version = "1.0.1"
1850+source = "registry+https://github.com/rust-lang/crates.io-index"
1851+checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
1852+
1853+[[package]]
1854+name = "idna"
1855+version = "1.1.0"
1856+source = "registry+https://github.com/rust-lang/crates.io-index"
1857+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
1858+dependencies = [
1859+ "idna_adapter",
1860+ "smallvec",
1861+ "utf8_iter",
1862+]
1863+
1864+[[package]]
1865+name = "idna_adapter"
1866+version = "1.2.2"
1867+source = "registry+https://github.com/rust-lang/crates.io-index"
1868+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
1869+dependencies = [
1870+ "icu_normalizer",
1871+ "icu_properties",
1872+]
1873+
1874+[[package]]
1875+name = "ig"
1876+version = "0.1.0"
1877+dependencies = [
1878+ "anyhow",
1879+ "clap",
1880+ "dirs",
1881+ "hex",
1882+ "irongit-shared",
1883+ "reqwest",
1884+ "serde",
1885+ "serde_json",
1886+ "sha2 0.11.0",
1887+ "toml",
1888+]
1889+
1890+[[package]]
1891+name = "indexmap"
1892+version = "2.14.2"
1893+source = "registry+https://github.com/rust-lang/crates.io-index"
1894+checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
1895+dependencies = [
1896+ "equivalent",
1897+ "hashbrown 0.17.1",
1898+]
1899+
1900+[[package]]
1901+name = "inotify"
1902+version = "0.11.5"
1903+source = "registry+https://github.com/rust-lang/crates.io-index"
1904+checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592"
1905+dependencies = [
1906+ "bitflags 2.13.2",
1907+ "inotify-sys",
1908+ "libc",
1909+]
1910+
1911+[[package]]
1912+name = "inotify-sys"
1913+version = "0.1.8"
1914+source = "registry+https://github.com/rust-lang/crates.io-index"
1915+checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d"
1916+dependencies = [
1917+ "libc",
1918+]
1919+
1920+[[package]]
1921+name = "inout"
1922+version = "0.2.2"
1923+source = "registry+https://github.com/rust-lang/crates.io-index"
1924+checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
1925+dependencies = [
1926+ "block-padding",
1927+ "hybrid-array",
1928+]
1929+
1930+[[package]]
1931+name = "instant-xml"
1932+version = "0.7.6"
1933+source = "registry+https://github.com/rust-lang/crates.io-index"
1934+checksum = "0a2cad967c3b727c000ebfdcd14974539e8c6f59e1d044c8034179df2c6fe250"
1935+dependencies = [
1936+ "instant-xml-macros",
1937+ "thiserror",
1938+ "xmlparser",
1939+]
1940+
1941+[[package]]
1942+name = "instant-xml-macros"
1943+version = "0.7.2"
1944+source = "registry+https://github.com/rust-lang/crates.io-index"
1945+checksum = "ac3c2e6cbd6e0579ee53a8290fc0015a438cfff0139c8359d5891cae130815d7"
1946+dependencies = [
1947+ "heck",
1948+ "proc-macro2",
1949+ "quote",
1950+ "syn 3.0.6",
1951+]
1952+
1953+[[package]]
1954+name = "ipnet"
1955+version = "2.12.2"
1956+source = "registry+https://github.com/rust-lang/crates.io-index"
1957+checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
1958+
1959+[[package]]
1960+name = "irongit"
1961+version = "0.1.0"
1962+dependencies = [
1963+ "anyhow",
1964+ "argon2",
1965+ "async-compression",
1966+ "axum",
1967+ "axum-extra",
1968+ "base64 0.23.1",
1969+ "bytes",
1970+ "chrono",
1971+ "clap",
1972+ "comrak",
1973+ "dotenvy",
1974+ "flate2",
1975+ "futures",
1976+ "hex",
1977+ "hmac",
1978+ "irongit-shared",
1979+ "lettre",
1980+ "maud",
1981+ "mime_guess",
1982+ "notify",
1983+ "once_cell",
1984+ "rand 0.10.3",
1985+ "regex",
1986+ "reqwest",
1987+ "russh",
1988+ "rust-embed",
1989+ "rusty-s3",
1990+ "serde",
1991+ "serde_json",
1992+ "sha2 0.11.0",
1993+ "sqlx",
1994+ "syntect",
1995+ "time",
1996+ "tokio",
1997+ "tokio-util",
1998+ "tower-http 0.7.1",
1999+ "tracing",
2000+ "tracing-appender",
2001+ "tracing-subscriber",
2002+ "url",
2003+ "uuid",
2004+]
2005+
2006+[[package]]
2007+name = "irongit-shared"
2008+version = "0.1.0"
2009+dependencies = [
2010+ "chrono",
2011+ "serde",
2012+ "serde_json",
2013+]
2014+
2015+[[package]]
2016+name = "is_terminal_polyfill"
2017+version = "1.70.2"
2018+source = "registry+https://github.com/rust-lang/crates.io-index"
2019+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
2020+
2021+[[package]]
2022+name = "itoa"
2023+version = "1.0.18"
2024+source = "registry+https://github.com/rust-lang/crates.io-index"
2025+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
2026+
2027+[[package]]
2028+name = "jiff"
2029+version = "0.2.38"
2030+source = "registry+https://github.com/rust-lang/crates.io-index"
2031+checksum = "b2b005715dcbeb0089a3c0dab99f2ff1cc3b2525323552703d648585d342a383"
2032+dependencies = [
2033+ "defmt",
2034+ "jiff-core",
2035+ "jiff-static",
2036+ "log",
2037+ "portable-atomic",
2038+ "portable-atomic-util",
2039+ "serde_core",
2040+]
2041+
2042+[[package]]
2043+name = "jiff-core"
2044+version = "0.1.1"
2045+source = "registry+https://github.com/rust-lang/crates.io-index"
2046+checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c"
2047+dependencies = [
2048+ "defmt",
2049+ "log",
2050+]
2051+
2052+[[package]]
2053+name = "jiff-static"
2054+version = "0.2.38"
2055+source = "registry+https://github.com/rust-lang/crates.io-index"
2056+checksum = "2cc9817253cf7c7ee4684451bd327e88d6f3658014e54a29198625590650695c"
2057+dependencies = [
2058+ "jiff-core",
2059+ "proc-macro2",
2060+ "quote",
2061+ "syn 2.0.119",
2062+]
2063+
2064+[[package]]
2065+name = "jni"
2066+version = "0.22.4"
2067+source = "registry+https://github.com/rust-lang/crates.io-index"
2068+checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
2069+dependencies = [
2070+ "cfg-if",
2071+ "combine",
2072+ "jni-macros",
2073+ "jni-sys",
2074+ "log",
2075+ "simd_cesu8",
2076+ "thiserror",
2077+ "walkdir",
2078+ "windows-link",
2079+]
2080+
2081+[[package]]
2082+name = "jni-macros"
2083+version = "0.22.4"
2084+source = "registry+https://github.com/rust-lang/crates.io-index"
2085+checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
2086+dependencies = [
2087+ "proc-macro2",
2088+ "quote",
2089+ "rustc_version",
2090+ "simd_cesu8",
2091+ "syn 2.0.119",
2092+]
2093+
2094+[[package]]
2095+name = "jni-sys"
2096+version = "0.4.1"
2097+source = "registry+https://github.com/rust-lang/crates.io-index"
2098+checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
2099+dependencies = [
2100+ "jni-sys-macros",
2101+]
2102+
2103+[[package]]
2104+name = "jni-sys-macros"
2105+version = "0.4.1"
2106+source = "registry+https://github.com/rust-lang/crates.io-index"
2107+checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
2108+dependencies = [
2109+ "quote",
2110+ "syn 2.0.119",
2111+]
2112+
2113+[[package]]
2114+name = "jobserver"
2115+version = "0.1.35"
2116+source = "registry+https://github.com/rust-lang/crates.io-index"
2117+checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
2118+dependencies = [
2119+ "getrandom 0.4.3",
2120+ "libc",
2121+]
2122+
2123+[[package]]
2124+name = "js-sys"
2125+version = "0.3.106"
2126+source = "registry+https://github.com/rust-lang/crates.io-index"
2127+checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
2128+dependencies = [
2129+ "cfg-if",
2130+ "futures-util",
2131+ "wasm-bindgen",
2132+]
2133+
2134+[[package]]
2135+name = "keccak"
2136+version = "0.2.2"
2137+source = "registry+https://github.com/rust-lang/crates.io-index"
2138+checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598"
2139+dependencies = [
2140+ "cfg-if",
2141+ "cpufeatures 0.3.1",
2142+]
2143+
2144+[[package]]
2145+name = "kem"
2146+version = "0.3.0"
2147+source = "registry+https://github.com/rust-lang/crates.io-index"
2148+checksum = "01737161ba802849cfd486b5bd209d38ba4943494c249a8126005170c7621edd"
2149+dependencies = [
2150+ "crypto-common 0.2.2",
2151+ "rand_core 0.10.1",
2152+]
2153+
2154+[[package]]
2155+name = "kqueue"
2156+version = "1.2.1"
2157+source = "registry+https://github.com/rust-lang/crates.io-index"
2158+checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea"
2159+dependencies = [
2160+ "kqueue-sys",
2161+ "libc",
2162+]
2163+
2164+[[package]]
2165+name = "kqueue-sys"
2166+version = "1.1.2"
2167+source = "registry+https://github.com/rust-lang/crates.io-index"
2168+checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087"
2169+dependencies = [
2170+ "bitflags 2.13.2",
2171+ "libc",
2172+]
2173+
2174+[[package]]
2175+name = "lazy_static"
2176+version = "1.5.1"
2177+source = "registry+https://github.com/rust-lang/crates.io-index"
2178+checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
2179+
2180+[[package]]
2181+name = "lettre"
2182+version = "0.11.23"
2183+source = "registry+https://github.com/rust-lang/crates.io-index"
2184+checksum = "f2c646bd5cc763b1087b15493e29a64be6147ba8f19342004fa52048ee596eae"
2185+dependencies = [
2186+ "async-trait",
2187+ "base64 0.23.1",
2188+ "email-encoding",
2189+ "email_address",
2190+ "fastrand",
2191+ "futures-io",
2192+ "futures-util",
2193+ "hostname",
2194+ "httpdate",
2195+ "idna",
2196+ "mime",
2197+ "nom",
2198+ "percent-encoding",
2199+ "quoted_printable",
2200+ "rustls",
2201+ "socket2",
2202+ "tokio",
2203+ "tokio-rustls",
2204+ "url",
2205+ "webpki-roots",
2206+]
2207+
2208+[[package]]
2209+name = "libc"
2210+version = "0.2.190"
2211+source = "registry+https://github.com/rust-lang/crates.io-index"
2212+checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
2213+
2214+[[package]]
2215+name = "libredox"
2216+version = "0.1.25"
2217+source = "registry+https://github.com/rust-lang/crates.io-index"
2218+checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d"
2219+dependencies = [
2220+ "libc",
2221+]
2222+
2223+[[package]]
2224+name = "libsqlite3-sys"
2225+version = "0.37.0"
2226+source = "registry+https://github.com/rust-lang/crates.io-index"
2227+checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1"
2228+dependencies = [
2229+ "pkg-config",
2230+ "vcpkg",
2231+]
2232+
2233+[[package]]
2234+name = "linked-hash-map"
2235+version = "0.5.6"
2236+source = "registry+https://github.com/rust-lang/crates.io-index"
2237+checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
2238+
2239+[[package]]
2240+name = "linux-raw-sys"
2241+version = "0.12.1"
2242+source = "registry+https://github.com/rust-lang/crates.io-index"
2243+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
2244+
2245+[[package]]
2246+name = "litemap"
2247+version = "0.8.3"
2248+source = "registry+https://github.com/rust-lang/crates.io-index"
2249+checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
2250+
2251+[[package]]
2252+name = "lock_api"
2253+version = "0.4.14"
2254+source = "registry+https://github.com/rust-lang/crates.io-index"
2255+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
2256+dependencies = [
2257+ "scopeguard",
2258+]
2259+
2260+[[package]]
2261+name = "log"
2262+version = "0.4.34"
2263+source = "registry+https://github.com/rust-lang/crates.io-index"
2264+checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
2265+
2266+[[package]]
2267+name = "lru-slab"
2268+version = "0.1.3"
2269+source = "registry+https://github.com/rust-lang/crates.io-index"
2270+checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
2271+
2272+[[package]]
2273+name = "matchers"
2274+version = "0.2.0"
2275+source = "registry+https://github.com/rust-lang/crates.io-index"
2276+checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
2277+dependencies = [
2278+ "regex-automata",
2279+]
2280+
2281+[[package]]
2282+name = "matchit"
2283+version = "0.8.4"
2284+source = "registry+https://github.com/rust-lang/crates.io-index"
2285+checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
2286+
2287+[[package]]
2288+name = "maud"
2289+version = "0.27.0"
2290+source = "registry+https://github.com/rust-lang/crates.io-index"
2291+checksum = "8156733e27020ea5c684db5beac5d1d611e1272ab17901a49466294b84fc217e"
2292+dependencies = [
2293+ "axum-core",
2294+ "http",
2295+ "itoa",
2296+ "maud_macros",
2297+]
2298+
2299+[[package]]
2300+name = "maud_macros"
2301+version = "0.27.0"
2302+source = "registry+https://github.com/rust-lang/crates.io-index"
2303+checksum = "7261b00f3952f617899bc012e3dbd56e4f0110a038175929fa5d18e5a19913ca"
2304+dependencies = [
2305+ "proc-macro2",
2306+ "proc-macro2-diagnostics",
2307+ "quote",
2308+ "syn 2.0.119",
2309+]
2310+
2311+[[package]]
2312+name = "md-5"
2313+version = "0.11.0"
2314+source = "registry+https://github.com/rust-lang/crates.io-index"
2315+checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98"
2316+dependencies = [
2317+ "cfg-if",
2318+ "digest 0.11.3",
2319+]
2320+
2321+[[package]]
2322+name = "md5"
2323+version = "0.8.1"
2324+source = "registry+https://github.com/rust-lang/crates.io-index"
2325+checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c"
2326+
2327+[[package]]
2328+name = "memchr"
2329+version = "2.8.3"
2330+source = "registry+https://github.com/rust-lang/crates.io-index"
2331+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
2332+
2333+[[package]]
2334+name = "memchr-n"
2335+version = "0.1.9"
2336+source = "registry+https://github.com/rust-lang/crates.io-index"
2337+checksum = "1805571b8e7b1284ad43f27c88477840c1c0e5807c150021ac83d2228806540e"
2338+dependencies = [
2339+ "fearless_simd",
2340+ "fearless_simd_macros",
2341+]
2342+
2343+[[package]]
2344+name = "mime"
2345+version = "0.3.17"
2346+source = "registry+https://github.com/rust-lang/crates.io-index"
2347+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
2348+
2349+[[package]]
2350+name = "mime_guess"
2351+version = "2.0.5"
2352+source = "registry+https://github.com/rust-lang/crates.io-index"
2353+checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
2354+dependencies = [
2355+ "mime",
2356+ "unicase",
2357+]
2358+
2359+[[package]]
2360+name = "miniz_oxide"
2361+version = "0.9.1"
2362+source = "registry+https://github.com/rust-lang/crates.io-index"
2363+checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
2364+dependencies = [
2365+ "adler2",
2366+ "simd-adler32",
2367+]
2368+
2369+[[package]]
2370+name = "mio"
2371+version = "1.2.4"
2372+source = "registry+https://github.com/rust-lang/crates.io-index"
2373+checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
2374+dependencies = [
2375+ "libc",
2376+ "log",
2377+ "wasi",
2378+ "windows-sys 0.61.2",
2379+]
2380+
2381+[[package]]
2382+name = "ml-kem"
2383+version = "0.3.2"
2384+source = "registry+https://github.com/rust-lang/crates.io-index"
2385+checksum = "5e15f3e5b957493873e396a66914e83e616b6afe335cdef7efe5c6e1216aba66"
2386+dependencies = [
2387+ "hybrid-array",
2388+ "kem",
2389+ "module-lattice",
2390+ "pkcs8",
2391+ "rand_core 0.10.1",
2392+ "sha3 0.11.0",
2393+]
2394+
2395+[[package]]
2396+name = "module-lattice"
2397+version = "0.2.3"
2398+source = "registry+https://github.com/rust-lang/crates.io-index"
2399+checksum = "0c61b87c9683ab7cb1c6871d261ad5479b6b10ceb52c4352aaca3b5d35a8febe"
2400+dependencies = [
2401+ "ctutils",
2402+ "hybrid-array",
2403+ "num-traits",
2404+]
2405+
2406+[[package]]
2407+name = "multer"
2408+version = "3.1.0"
2409+source = "registry+https://github.com/rust-lang/crates.io-index"
2410+checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
2411+dependencies = [
2412+ "bytes",
2413+ "encoding_rs",
2414+ "futures-util",
2415+ "http",
2416+ "httparse",
2417+ "memchr",
2418+ "mime",
2419+ "spin",
2420+ "version_check",
2421+]
2422+
2423+[[package]]
2424+name = "multiversion_no_op"
2425+version = "1.0.0"
2426+source = "registry+https://github.com/rust-lang/crates.io-index"
2427+checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
2428+
2429+[[package]]
2430+name = "nix"
2431+version = "0.31.3"
2432+source = "registry+https://github.com/rust-lang/crates.io-index"
2433+checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
2434+dependencies = [
2435+ "bitflags 2.13.2",
2436+ "cfg-if",
2437+ "cfg_aliases",
2438+ "libc",
2439+]
2440+
2441+[[package]]
2442+name = "nom"
2443+version = "8.0.0"
2444+source = "registry+https://github.com/rust-lang/crates.io-index"
2445+checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
2446+dependencies = [
2447+ "memchr",
2448+]
2449+
2450+[[package]]
2451+name = "notify"
2452+version = "8.2.0"
2453+source = "registry+https://github.com/rust-lang/crates.io-index"
2454+checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3"
2455+dependencies = [
2456+ "bitflags 2.13.2",
2457+ "fsevent-sys",
2458+ "inotify",
2459+ "kqueue",
2460+ "libc",
2461+ "log",
2462+ "mio",
2463+ "notify-types",
2464+ "walkdir",
2465+ "windows-sys 0.60.2",
2466+]
2467+
2468+[[package]]
2469+name = "notify-types"
2470+version = "2.1.0"
2471+source = "registry+https://github.com/rust-lang/crates.io-index"
2472+checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a"
2473+dependencies = [
2474+ "bitflags 2.13.2",
2475+]
2476+
2477+[[package]]
2478+name = "nu-ansi-term"
2479+version = "0.50.3"
2480+source = "registry+https://github.com/rust-lang/crates.io-index"
2481+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
2482+dependencies = [
2483+ "windows-sys 0.61.2",
2484+]
2485+
2486+[[package]]
2487+name = "num-bigint"
2488+version = "0.5.1"
2489+source = "registry+https://github.com/rust-lang/crates.io-index"
2490+checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0"
2491+dependencies = [
2492+ "num-integer",
2493+ "num-traits",
2494+ "rand 0.10.3",
2495+ "rand_core 0.10.1",
2496+]
2497+
2498+[[package]]
2499+name = "num-conv"
2500+version = "0.2.2"
2501+source = "registry+https://github.com/rust-lang/crates.io-index"
2502+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
2503+
2504+[[package]]
2505+name = "num-integer"
2506+version = "0.1.47"
2507+source = "registry+https://github.com/rust-lang/crates.io-index"
2508+checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
2509+dependencies = [
2510+ "num-traits",
2511+]
2512+
2513+[[package]]
2514+name = "num-traits"
2515+version = "0.2.19"
2516+source = "registry+https://github.com/rust-lang/crates.io-index"
2517+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
2518+dependencies = [
2519+ "autocfg",
2520+]
2521+
2522+[[package]]
2523+name = "once_cell"
2524+version = "1.21.4"
2525+source = "registry+https://github.com/rust-lang/crates.io-index"
2526+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
2527+
2528+[[package]]
2529+name = "once_cell_polyfill"
2530+version = "1.70.2"
2531+source = "registry+https://github.com/rust-lang/crates.io-index"
2532+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
2533+
2534+[[package]]
2535+name = "onig"
2536+version = "6.5.3"
2537+source = "registry+https://github.com/rust-lang/crates.io-index"
2538+checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2"
2539+dependencies = [
2540+ "bitflags 2.13.2",
2541+ "libc",
2542+ "once_cell",
2543+ "onig_sys",
2544+]
2545+
2546+[[package]]
2547+name = "onig_sys"
2548+version = "69.9.3"
2549+source = "registry+https://github.com/rust-lang/crates.io-index"
2550+checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7"
2551+dependencies = [
2552+ "cc",
2553+ "pkg-config",
2554+]
2555+
2556+[[package]]
2557+name = "openssl-probe"
2558+version = "0.2.1"
2559+source = "registry+https://github.com/rust-lang/crates.io-index"
2560+checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
2561+
2562+[[package]]
2563+name = "option-ext"
2564+version = "0.2.0"
2565+source = "registry+https://github.com/rust-lang/crates.io-index"
2566+checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
2567+
2568+[[package]]
2569+name = "p256"
2570+version = "0.14.0"
2571+source = "registry+https://github.com/rust-lang/crates.io-index"
2572+checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a"
2573+dependencies = [
2574+ "ecdsa",
2575+ "elliptic-curve",
2576+ "primefield",
2577+ "primeorder",
2578+ "sha2 0.11.0",
2579+]
2580+
2581+[[package]]
2582+name = "p384"
2583+version = "0.14.0"
2584+source = "registry+https://github.com/rust-lang/crates.io-index"
2585+checksum = "d17b851e6b3e378ab4ecb07fa2ed23f4d15f075735f8fec9fa1e7bdce5f8301f"
2586+dependencies = [
2587+ "ecdsa",
2588+ "elliptic-curve",
2589+ "fiat-crypto",
2590+ "primefield",
2591+ "primeorder",
2592+ "sha2 0.11.0",
2593+]
2594+
2595+[[package]]
2596+name = "p521"
2597+version = "0.14.0"
2598+source = "registry+https://github.com/rust-lang/crates.io-index"
2599+checksum = "4ad64cc32c2dc466317c12ee5853e61f159f9eab1fe7efade0395dc2e7b43449"
2600+dependencies = [
2601+ "base16ct",
2602+ "ecdsa",
2603+ "elliptic-curve",
2604+ "primefield",
2605+ "primeorder",
2606+ "sha2 0.11.0",
2607+]
2608+
2609+[[package]]
2610+name = "pageant"
2611+version = "0.2.4"
2612+source = "registry+https://github.com/rust-lang/crates.io-index"
2613+checksum = "f0786409b5aaa884655c9b4065681db0835db0cc185a6fb19fb17a5d38656866"
2614+dependencies = [
2615+ "base16ct",
2616+ "byteorder",
2617+ "bytes",
2618+ "delegate",
2619+ "futures",
2620+ "log",
2621+ "rand 0.10.3",
2622+ "sha2 0.11.0",
2623+ "thiserror",
2624+ "tokio",
2625+ "windows",
2626+ "windows-strings",
2627+]
2628+
2629+[[package]]
2630+name = "parking"
2631+version = "2.2.1"
2632+source = "registry+https://github.com/rust-lang/crates.io-index"
2633+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
2634+
2635+[[package]]
2636+name = "parking_lot"
2637+version = "0.12.5"
2638+source = "registry+https://github.com/rust-lang/crates.io-index"
2639+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
2640+dependencies = [
2641+ "lock_api",
2642+ "parking_lot_core",
2643+]
2644+
2645+[[package]]
2646+name = "parking_lot_core"
2647+version = "0.9.12"
2648+source = "registry+https://github.com/rust-lang/crates.io-index"
2649+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
2650+dependencies = [
2651+ "cfg-if",
2652+ "libc",
2653+ "redox_syscall",
2654+ "smallvec",
2655+ "windows-link",
2656+]
2657+
2658+[[package]]
2659+name = "password-hash"
2660+version = "0.6.1"
2661+source = "registry+https://github.com/rust-lang/crates.io-index"
2662+checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b"
2663+dependencies = [
2664+ "getrandom 0.4.3",
2665+ "phc",
2666+]
2667+
2668+[[package]]
2669+name = "pbkdf2"
2670+version = "0.13.0"
2671+source = "registry+https://github.com/rust-lang/crates.io-index"
2672+checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629"
2673+dependencies = [
2674+ "digest 0.11.3",
2675+ "hmac",
2676+]
2677+
2678+[[package]]
2679+name = "pem-rfc7468"
2680+version = "1.0.0"
2681+source = "registry+https://github.com/rust-lang/crates.io-index"
2682+checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9"
2683+dependencies = [
2684+ "base64ct",
2685+]
2686+
2687+[[package]]
2688+name = "percent-encoding"
2689+version = "2.3.2"
2690+source = "registry+https://github.com/rust-lang/crates.io-index"
2691+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
2692+
2693+[[package]]
2694+name = "phc"
2695+version = "0.6.1"
2696+source = "registry+https://github.com/rust-lang/crates.io-index"
2697+checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892"
2698+dependencies = [
2699+ "base64ct",
2700+ "ctutils",
2701+ "getrandom 0.4.3",
2702+]
2703+
2704+[[package]]
2705+name = "phf"
2706+version = "0.13.1"
2707+source = "registry+https://github.com/rust-lang/crates.io-index"
2708+checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
2709+dependencies = [
2710+ "phf_shared 0.13.1",
2711+]
2712+
2713+[[package]]
2714+name = "phf"
2715+version = "0.14.0"
2716+source = "registry+https://github.com/rust-lang/crates.io-index"
2717+checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6"
2718+dependencies = [
2719+ "phf_shared 0.14.0",
2720+ "serde",
2721+]
2722+
2723+[[package]]
2724+name = "phf_codegen"
2725+version = "0.14.0"
2726+source = "registry+https://github.com/rust-lang/crates.io-index"
2727+checksum = "41b585a510fb76fdebead6897982ef2a03a21d8e6cbcca904999742a4afc6ffe"
2728+dependencies = [
2729+ "phf_generator",
2730+ "phf_shared 0.14.0",
2731+]
2732+
2733+[[package]]
2734+name = "phf_generator"
2735+version = "0.14.0"
2736+source = "registry+https://github.com/rust-lang/crates.io-index"
2737+checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100"
2738+dependencies = [
2739+ "fastrand",
2740+ "phf_shared 0.14.0",
2741+]
2742+
2743+[[package]]
2744+name = "phf_shared"
2745+version = "0.13.1"
2746+source = "registry+https://github.com/rust-lang/crates.io-index"
2747+checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
2748+dependencies = [
2749+ "siphasher",
2750+]
2751+
2752+[[package]]
2753+name = "phf_shared"
2754+version = "0.14.0"
2755+source = "registry+https://github.com/rust-lang/crates.io-index"
2756+checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89"
2757+dependencies = [
2758+ "siphasher",
2759+]
2760+
2761+[[package]]
2762+name = "pin-project-lite"
2763+version = "0.2.17"
2764+source = "registry+https://github.com/rust-lang/crates.io-index"
2765+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
2766+
2767+[[package]]
2768+name = "pkcs1"
2769+version = "0.8.0-rc.4"
2770+source = "registry+https://github.com/rust-lang/crates.io-index"
2771+checksum = "986d2e952779af96ea048f160fd9194e1751b4faea78bcf3ceb456efe008088e"
2772+dependencies = [
2773+ "der",
2774+ "spki",
2775+]
2776+
2777+[[package]]
2778+name = "pkcs5"
2779+version = "0.8.1"
2780+source = "registry+https://github.com/rust-lang/crates.io-index"
2781+checksum = "63d440a804ec8d6fafbb6b84471e013286658d373248927692ab3366686220ca"
2782+dependencies = [
2783+ "aes",
2784+ "aes-gcm",
2785+ "cbc",
2786+ "der",
2787+ "pbkdf2",
2788+ "rand_core 0.10.1",
2789+ "scrypt",
2790+ "sha2 0.11.0",
2791+ "spki",
2792+]
2793+
2794+[[package]]
2795+name = "pkcs8"
2796+version = "0.11.0"
2797+source = "registry+https://github.com/rust-lang/crates.io-index"
2798+checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7"
2799+dependencies = [
2800+ "der",
2801+ "pkcs5",
2802+ "rand_core 0.10.1",
2803+ "spki",
2804+]
2805+
2806+[[package]]
2807+name = "pkg-config"
2808+version = "0.3.34"
2809+source = "registry+https://github.com/rust-lang/crates.io-index"
2810+checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
2811+
2812+[[package]]
2813+name = "plist"
2814+version = "1.10.1"
2815+source = "registry+https://github.com/rust-lang/crates.io-index"
2816+checksum = "2896bade328c13f7042a297ea5ac5b0951f6cf989dea5f32c2fd98da398195cb"
2817+dependencies = [
2818+ "base64 0.23.1",
2819+ "indexmap",
2820+ "quick-xml",
2821+ "serde",
2822+ "time",
2823+]
2824+
2825+[[package]]
2826+name = "poly1305"
2827+version = "0.9.1"
2828+source = "registry+https://github.com/rust-lang/crates.io-index"
2829+checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c"
2830+dependencies = [
2831+ "cpufeatures 0.3.1",
2832+ "universal-hash",
2833+ "zeroize",
2834+]
2835+
2836+[[package]]
2837+name = "polyval"
2838+version = "0.7.3"
2839+source = "registry+https://github.com/rust-lang/crates.io-index"
2840+checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd"
2841+dependencies = [
2842+ "cpubits",
2843+ "cpufeatures 0.3.1",
2844+ "universal-hash",
2845+ "zeroize",
2846+]
2847+
2848+[[package]]
2849+name = "portable-atomic"
2850+version = "1.15.0"
2851+source = "registry+https://github.com/rust-lang/crates.io-index"
2852+checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
2853+
2854+[[package]]
2855+name = "portable-atomic-util"
2856+version = "0.2.8"
2857+source = "registry+https://github.com/rust-lang/crates.io-index"
2858+checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715"
2859+dependencies = [
2860+ "portable-atomic",
2861+]
2862+
2863+[[package]]
2864+name = "potential_utf"
2865+version = "0.1.6"
2866+source = "registry+https://github.com/rust-lang/crates.io-index"
2867+checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
2868+dependencies = [
2869+ "zerovec",
2870+]
2871+
2872+[[package]]
2873+name = "powerfmt"
2874+version = "0.2.1"
2875+source = "registry+https://github.com/rust-lang/crates.io-index"
2876+checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
2877+
2878+[[package]]
2879+name = "ppv-lite86"
2880+version = "0.2.21"
2881+source = "registry+https://github.com/rust-lang/crates.io-index"
2882+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
2883+dependencies = [
2884+ "zerocopy",
2885+]
2886+
2887+[[package]]
2888+name = "prettyplease"
2889+version = "0.3.0"
2890+source = "registry+https://github.com/rust-lang/crates.io-index"
2891+checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
2892+dependencies = [
2893+ "proc-macro2",
2894+ "syn 3.0.6",
2895+]
2896+
2897+[[package]]
2898+name = "primefield"
2899+version = "0.14.0"
2900+source = "registry+https://github.com/rust-lang/crates.io-index"
2901+checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4"
2902+dependencies = [
2903+ "crypto-bigint",
2904+ "crypto-common 0.2.2",
2905+ "ff",
2906+ "rand_core 0.10.1",
2907+ "subtle",
2908+ "zeroize",
2909+]
2910+
2911+[[package]]
2912+name = "primeorder"
2913+version = "0.14.0"
2914+source = "registry+https://github.com/rust-lang/crates.io-index"
2915+checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06"
2916+dependencies = [
2917+ "elliptic-curve",
2918+ "once_cell",
2919+ "primefield",
2920+ "serdect",
2921+ "wnaf",
2922+]
2923+
2924+[[package]]
2925+name = "proc-macro2"
2926+version = "1.0.107"
2927+source = "registry+https://github.com/rust-lang/crates.io-index"
2928+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
2929+dependencies = [
2930+ "unicode-ident",
2931+]
2932+
2933+[[package]]
2934+name = "proc-macro2-diagnostics"
2935+version = "0.10.1"
2936+source = "registry+https://github.com/rust-lang/crates.io-index"
2937+checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8"
2938+dependencies = [
2939+ "proc-macro2",
2940+ "quote",
2941+ "syn 2.0.119",
2942+ "version_check",
2943+]
2944+
2945+[[package]]
2946+name = "quick-xml"
2947+version = "0.42.0"
2948+source = "registry+https://github.com/rust-lang/crates.io-index"
2949+checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b"
2950+dependencies = [
2951+ "memchr",
2952+]
2953+
2954+[[package]]
2955+name = "quinn"
2956+version = "0.11.12"
2957+source = "registry+https://github.com/rust-lang/crates.io-index"
2958+checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11"
2959+dependencies = [
2960+ "bytes",
2961+ "cfg_aliases",
2962+ "pin-project-lite",
2963+ "quinn-proto",
2964+ "quinn-udp",
2965+ "rustc-hash",
2966+ "rustls",
2967+ "socket2",
2968+ "thiserror",
2969+ "tokio",
2970+ "tracing",
2971+ "web-time",
2972+]
2973+
2974+[[package]]
2975+name = "quinn-proto"
2976+version = "0.11.19"
2977+source = "registry+https://github.com/rust-lang/crates.io-index"
2978+checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe"
2979+dependencies = [
2980+ "aws-lc-rs",
2981+ "bytes",
2982+ "getrandom 0.4.3",
2983+ "lru-slab",
2984+ "rand 0.10.3",
2985+ "rand_pcg",
2986+ "ring",
2987+ "rustc-hash",
2988+ "rustls",
2989+ "rustls-pki-types",
2990+ "slab",
2991+ "thiserror",
2992+ "tinyvec",
2993+ "tracing",
2994+ "web-time",
2995+]
2996+
2997+[[package]]
2998+name = "quinn-udp"
2999+version = "0.5.16"
3000+source = "registry+https://github.com/rust-lang/crates.io-index"
3001+checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016"
3002+dependencies = [
3003+ "cfg_aliases",
3004+ "libc",
3005+ "once_cell",
3006+ "socket2",
3007+ "tracing",
3008+ "windows-sys 0.61.2",
3009+]
3010+
3011+[[package]]
3012+name = "quote"
3013+version = "1.0.47"
3014+source = "registry+https://github.com/rust-lang/crates.io-index"
3015+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
3016+dependencies = [
3017+ "proc-macro2",
3018+]
3019+
3020+[[package]]
3021+name = "quoted_printable"
3022+version = "0.5.2"
3023+source = "registry+https://github.com/rust-lang/crates.io-index"
3024+checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972"
3025+
3026+[[package]]
3027+name = "r-efi"
3028+version = "5.3.0"
3029+source = "registry+https://github.com/rust-lang/crates.io-index"
3030+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
3031+
3032+[[package]]
3033+name = "r-efi"
3034+version = "6.0.0"
3035+source = "registry+https://github.com/rust-lang/crates.io-index"
3036+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
3037+
3038+[[package]]
3039+name = "rand"
3040+version = "0.9.5"
3041+source = "registry+https://github.com/rust-lang/crates.io-index"
3042+checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
3043+dependencies = [
3044+ "rand_chacha",
3045+ "rand_core 0.9.5",
3046+]
3047+
3048+[[package]]
3049+name = "rand"
3050+version = "0.10.3"
3051+source = "registry+https://github.com/rust-lang/crates.io-index"
3052+checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
3053+dependencies = [
3054+ "chacha20",
3055+ "getrandom 0.4.3",
3056+ "rand_core 0.10.1",
3057+]
3058+
3059+[[package]]
3060+name = "rand_chacha"
3061+version = "0.9.0"
3062+source = "registry+https://github.com/rust-lang/crates.io-index"
3063+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
3064+dependencies = [
3065+ "ppv-lite86",
3066+ "rand_core 0.9.5",
3067+]
3068+
3069+[[package]]
3070+name = "rand_core"
3071+version = "0.9.5"
3072+source = "registry+https://github.com/rust-lang/crates.io-index"
3073+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
3074+dependencies = [
3075+ "getrandom 0.3.4",
3076+]
3077+
3078+[[package]]
3079+name = "rand_core"
3080+version = "0.10.1"
3081+source = "registry+https://github.com/rust-lang/crates.io-index"
3082+checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
3083+
3084+[[package]]
3085+name = "rand_pcg"
3086+version = "0.10.2"
3087+source = "registry+https://github.com/rust-lang/crates.io-index"
3088+checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
3089+dependencies = [
3090+ "rand_core 0.10.1",
3091+]
3092+
3093+[[package]]
3094+name = "redox_syscall"
3095+version = "0.5.18"
3096+source = "registry+https://github.com/rust-lang/crates.io-index"
3097+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
3098+dependencies = [
3099+ "bitflags 2.13.2",
3100+]
3101+
3102+[[package]]
3103+name = "redox_users"
3104+version = "0.5.3"
3105+source = "registry+https://github.com/rust-lang/crates.io-index"
3106+checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0"
3107+dependencies = [
3108+ "libredox",
3109+ "thiserror",
3110+]
3111+
3112+[[package]]
3113+name = "regex"
3114+version = "1.13.1"
3115+source = "registry+https://github.com/rust-lang/crates.io-index"
3116+checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
3117+dependencies = [
3118+ "aho-corasick",
3119+ "memchr",
3120+ "regex-automata",
3121+ "regex-syntax",
3122+]
3123+
3124+[[package]]
3125+name = "regex-automata"
3126+version = "0.4.18"
3127+source = "registry+https://github.com/rust-lang/crates.io-index"
3128+checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
3129+dependencies = [
3130+ "aho-corasick",
3131+ "memchr",
3132+ "regex-syntax",
3133+]
3134+
3135+[[package]]
3136+name = "regex-syntax"
3137+version = "0.8.11"
3138+source = "registry+https://github.com/rust-lang/crates.io-index"
3139+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
3140+
3141+[[package]]
3142+name = "reqwest"
3143+version = "0.13.5"
3144+source = "registry+https://github.com/rust-lang/crates.io-index"
3145+checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029"
3146+dependencies = [
3147+ "base64 0.23.1",
3148+ "bytes",
3149+ "futures-channel",
3150+ "futures-core",
3151+ "futures-util",
3152+ "h2",
3153+ "http",
3154+ "http-body",
3155+ "http-body-util",
3156+ "hyper",
3157+ "hyper-rustls",
3158+ "hyper-util",
3159+ "js-sys",
3160+ "log",
3161+ "percent-encoding",
3162+ "pin-project-lite",
3163+ "quinn",
3164+ "rustls",
3165+ "rustls-pki-types",
3166+ "rustls-platform-verifier",
3167+ "serde",
3168+ "serde_json",
3169+ "sync_wrapper",
3170+ "tokio",
3171+ "tokio-rustls",
3172+ "tokio-util",
3173+ "tower",
3174+ "tower-http 0.6.11",
3175+ "tower-service",
3176+ "url",
3177+ "wasm-bindgen",
3178+ "wasm-bindgen-futures",
3179+ "wasm-streams",
3180+ "web-sys",
3181+]
3182+
3183+[[package]]
3184+name = "rfc6979"
3185+version = "0.6.0"
3186+source = "registry+https://github.com/rust-lang/crates.io-index"
3187+checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b"
3188+dependencies = [
3189+ "crypto-bigint",
3190+ "hmac",
3191+]
3192+
3193+[[package]]
3194+name = "ring"
3195+version = "0.17.14"
3196+source = "registry+https://github.com/rust-lang/crates.io-index"
3197+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
3198+dependencies = [
3199+ "cc",
3200+ "cfg-if",
3201+ "getrandom 0.2.17",
3202+ "libc",
3203+ "untrusted 0.9.0",
3204+ "windows-sys 0.52.0",
3205+]
3206+
3207+[[package]]
3208+name = "rsa"
3209+version = "0.10.0-rc.18"
3210+source = "registry+https://github.com/rust-lang/crates.io-index"
3211+checksum = "30b2aa4ba0d89f73d1e332df05be0eeab8840351c36ca5654341dfdb57bb3caf"
3212+dependencies = [
3213+ "const-oid",
3214+ "crypto-bigint",
3215+ "crypto-primes",
3216+ "digest 0.11.3",
3217+ "pkcs1",
3218+ "pkcs8",
3219+ "rand_core 0.10.1",
3220+ "sha2 0.11.0",
3221+ "signature",
3222+ "spki",
3223+ "zeroize",
3224+]
3225+
3226+[[package]]
3227+name = "russh"
3228+version = "0.64.1"
3229+source = "registry+https://github.com/rust-lang/crates.io-index"
3230+checksum = "ba61e87b9ec9a39a59a6bbed4c0b8ff7fb07073405b24a767a1f2c8560ac8f58"
3231+dependencies = [
3232+ "aes",
3233+ "aws-lc-rs",
3234+ "bitflags 2.13.2",
3235+ "block-padding",
3236+ "byteorder",
3237+ "bytes",
3238+ "cbc",
3239+ "cipher",
3240+ "crypto-bigint",
3241+ "ctr",
3242+ "curve25519-dalek",
3243+ "data-encoding",
3244+ "delegate",
3245+ "der",
3246+ "digest 0.11.3",
3247+ "ecdsa",
3248+ "ed25519-dalek",
3249+ "elliptic-curve",
3250+ "enum_dispatch",
3251+ "flate2",
3252+ "futures",
3253+ "generic-array 1.4.5",
3254+ "getrandom 0.4.3",
3255+ "ghash",
3256+ "hex-literal",
3257+ "hmac",
3258+ "inout",
3259+ "keccak",
3260+ "log",
3261+ "md5",
3262+ "ml-kem",
3263+ "module-lattice",
3264+ "num-bigint",
3265+ "p256",
3266+ "p384",
3267+ "p521",
3268+ "pageant",
3269+ "pbkdf2",
3270+ "pkcs1",
3271+ "pkcs5",
3272+ "pkcs8",
3273+ "polyval",
3274+ "rand 0.10.3",
3275+ "rand_core 0.10.1",
3276+ "rsa",
3277+ "russh-cryptovec",
3278+ "russh-util",
3279+ "salsa20",
3280+ "scrypt",
3281+ "sec1",
3282+ "sha1 0.11.0",
3283+ "sha2 0.11.0",
3284+ "sha3 0.12.0",
3285+ "signature",
3286+ "spki",
3287+ "ssh-encoding",
3288+ "ssh-key",
3289+ "subtle",
3290+ "thiserror",
3291+ "tokio",
3292+ "typenum",
3293+ "universal-hash",
3294+ "zeroize",
3295+]
3296+
3297+[[package]]
3298+name = "russh-cryptovec"
3299+version = "0.62.0"
3300+source = "registry+https://github.com/rust-lang/crates.io-index"
3301+checksum = "3aec6cb630dbe85d72ffd7bcd95f07e1bd69f9f270ee8adfa1afe443a6331438"
3302+dependencies = [
3303+ "log",
3304+ "nix",
3305+ "ssh-encoding",
3306+ "windows-sys 0.61.2",
3307+]
3308+
3309+[[package]]
3310+name = "russh-util"
3311+version = "0.52.0"
3312+source = "registry+https://github.com/rust-lang/crates.io-index"
3313+checksum = "668424a5dde0bcb45b55ba7de8476b93831b4aa2fa6947e145f3b053e22c60b6"
3314+dependencies = [
3315+ "chrono",
3316+ "tokio",
3317+ "wasm-bindgen",
3318+ "wasm-bindgen-futures",
3319+]
3320+
3321+[[package]]
3322+name = "rust-embed"
3323+version = "8.13.0"
3324+source = "registry+https://github.com/rust-lang/crates.io-index"
3325+checksum = "19afa5b4b6a611de00bd1bdae6ae6f39084c9399f0679c3f52d8469cf335cc23"
3326+dependencies = [
3327+ "rust-embed-impl",
3328+ "rust-embed-utils",
3329+ "walkdir",
3330+]
3331+
3332+[[package]]
3333+name = "rust-embed-impl"
3334+version = "8.13.0"
3335+source = "registry+https://github.com/rust-lang/crates.io-index"
3336+checksum = "e0d8afda6374eac59e066abee06d265247ebbaf3006cf878e2879e8356e34053"
3337+dependencies = [
3338+ "mime_guess",
3339+ "proc-macro2",
3340+ "quote",
3341+ "rust-embed-utils",
3342+ "syn 2.0.119",
3343+ "walkdir",
3344+]
3345+
3346+[[package]]
3347+name = "rust-embed-utils"
3348+version = "8.13.0"
3349+source = "registry+https://github.com/rust-lang/crates.io-index"
3350+checksum = "0d84e8ba78bd384263e5922f084cbe1b081c3b7e69add59c8fb097b879ba968a"
3351+dependencies = [
3352+ "sha2 0.11.0",
3353+ "walkdir",
3354+]
3355+
3356+[[package]]
3357+name = "rustc-hash"
3358+version = "2.1.3"
3359+source = "registry+https://github.com/rust-lang/crates.io-index"
3360+checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
3361+
3362+[[package]]
3363+name = "rustc_version"
3364+version = "0.4.1"
3365+source = "registry+https://github.com/rust-lang/crates.io-index"
3366+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
3367+dependencies = [
3368+ "semver",
3369+]
3370+
3371+[[package]]
3372+name = "rustix"
3373+version = "1.1.5"
3374+source = "registry+https://github.com/rust-lang/crates.io-index"
3375+checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
3376+dependencies = [
3377+ "bitflags 2.13.2",
3378+ "errno",
3379+ "libc",
3380+ "linux-raw-sys",
3381+ "windows-sys 0.61.2",
3382+]
3383+
3384+[[package]]
3385+name = "rustls"
3386+version = "0.23.45"
3387+source = "registry+https://github.com/rust-lang/crates.io-index"
3388+checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
3389+dependencies = [
3390+ "aws-lc-rs",
3391+ "log",
3392+ "once_cell",
3393+ "ring",
3394+ "rustls-pki-types",
3395+ "rustls-webpki",
3396+ "subtle",
3397+ "zeroize",
3398+]
3399+
3400+[[package]]
3401+name = "rustls-native-certs"
3402+version = "0.8.4"
3403+source = "registry+https://github.com/rust-lang/crates.io-index"
3404+checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
3405+dependencies = [
3406+ "openssl-probe",
3407+ "rustls-pki-types",
3408+ "schannel",
3409+ "security-framework",
3410+]
3411+
3412+[[package]]
3413+name = "rustls-pki-types"
3414+version = "1.15.1"
3415+source = "registry+https://github.com/rust-lang/crates.io-index"
3416+checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
3417+dependencies = [
3418+ "web-time",
3419+ "zeroize",
3420+]
3421+
3422+[[package]]
3423+name = "rustls-platform-verifier"
3424+version = "0.7.1"
3425+source = "registry+https://github.com/rust-lang/crates.io-index"
3426+checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98"
3427+dependencies = [
3428+ "core-foundation",
3429+ "core-foundation-sys",
3430+ "jni",
3431+ "log",
3432+ "once_cell",
3433+ "rustls",
3434+ "rustls-native-certs",
3435+ "rustls-platform-verifier-android",
3436+ "rustls-webpki",
3437+ "security-framework",
3438+ "security-framework-sys",
3439+ "webpki-root-certs",
3440+ "windows-sys 0.61.2",
3441+]
3442+
3443+[[package]]
3444+name = "rustls-platform-verifier-android"
3445+version = "0.2.0"
3446+source = "registry+https://github.com/rust-lang/crates.io-index"
3447+checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
3448+
3449+[[package]]
3450+name = "rustls-webpki"
3451+version = "0.103.15"
3452+source = "registry+https://github.com/rust-lang/crates.io-index"
3453+checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
3454+dependencies = [
3455+ "aws-lc-rs",
3456+ "ring",
3457+ "rustls-pki-types",
3458+ "untrusted 0.9.0",
3459+]
3460+
3461+[[package]]
3462+name = "rustversion"
3463+version = "1.0.23"
3464+source = "registry+https://github.com/rust-lang/crates.io-index"
3465+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
3466+
3467+[[package]]
3468+name = "rusty-s3"
3469+version = "0.10.2"
3470+source = "registry+https://github.com/rust-lang/crates.io-index"
3471+checksum = "2412ec048709e6a8ee4ee1505313bce003f6b8b27fc41a97e30615223c7ed592"
3472+dependencies = [
3473+ "base64 0.23.1",
3474+ "hmac",
3475+ "instant-xml",
3476+ "jiff",
3477+ "md-5",
3478+ "percent-encoding",
3479+ "serde",
3480+ "serde_json",
3481+ "sha2 0.11.0",
3482+ "url",
3483+ "zeroize",
3484+]
3485+
3486+[[package]]
3487+name = "ryu"
3488+version = "1.0.23"
3489+source = "registry+https://github.com/rust-lang/crates.io-index"
3490+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
3491+
3492+[[package]]
3493+name = "salsa20"
3494+version = "0.11.0"
3495+source = "registry+https://github.com/rust-lang/crates.io-index"
3496+checksum = "2f874456e72520ff1375a06c588eaf074b0f01f9e9e1aada45bd9b7954a6e42c"
3497+dependencies = [
3498+ "cfg-if",
3499+ "cipher",
3500+]
3501+
3502+[[package]]
3503+name = "same-file"
3504+version = "1.0.6"
3505+source = "registry+https://github.com/rust-lang/crates.io-index"
3506+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
3507+dependencies = [
3508+ "winapi-util",
3509+]
3510+
3511+[[package]]
3512+name = "schannel"
3513+version = "0.1.29"
3514+source = "registry+https://github.com/rust-lang/crates.io-index"
3515+checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
3516+dependencies = [
3517+ "windows-sys 0.61.2",
3518+]
3519+
3520+[[package]]
3521+name = "scopeguard"
3522+version = "1.2.0"
3523+source = "registry+https://github.com/rust-lang/crates.io-index"
3524+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
3525+
3526+[[package]]
3527+name = "scrypt"
3528+version = "0.12.0"
3529+source = "registry+https://github.com/rust-lang/crates.io-index"
3530+checksum = "d87af57419b594aa23fa95f09f0e06d80d84ba01c26148c43844cad6ff4485f0"
3531+dependencies = [
3532+ "cfg-if",
3533+ "pbkdf2",
3534+ "salsa20",
3535+ "sha2 0.11.0",
3536+]
3537+
3538+[[package]]
3539+name = "sec1"
3540+version = "0.8.1"
3541+source = "registry+https://github.com/rust-lang/crates.io-index"
3542+checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d"
3543+dependencies = [
3544+ "base16ct",
3545+ "ctutils",
3546+ "der",
3547+ "hybrid-array",
3548+ "subtle",
3549+ "zeroize",
3550+]
3551+
3552+[[package]]
3553+name = "security-framework"
3554+version = "3.7.0"
3555+source = "registry+https://github.com/rust-lang/crates.io-index"
3556+checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
3557+dependencies = [
3558+ "bitflags 2.13.2",
3559+ "core-foundation",
3560+ "core-foundation-sys",
3561+ "libc",
3562+ "security-framework-sys",
3563+]
3564+
3565+[[package]]
3566+name = "security-framework-sys"
3567+version = "2.17.0"
3568+source = "registry+https://github.com/rust-lang/crates.io-index"
3569+checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
3570+dependencies = [
3571+ "core-foundation-sys",
3572+ "libc",
3573+]
3574+
3575+[[package]]
3576+name = "semver"
3577+version = "1.0.28"
3578+source = "registry+https://github.com/rust-lang/crates.io-index"
3579+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
3580+
3581+[[package]]
3582+name = "serde"
3583+version = "1.0.229"
3584+source = "registry+https://github.com/rust-lang/crates.io-index"
3585+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
3586+dependencies = [
3587+ "serde_core",
3588+ "serde_derive",
3589+]
3590+
3591+[[package]]
3592+name = "serde_core"
3593+version = "1.0.229"
3594+source = "registry+https://github.com/rust-lang/crates.io-index"
3595+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
3596+dependencies = [
3597+ "serde_derive",
3598+]
3599+
3600+[[package]]
3601+name = "serde_derive"
3602+version = "1.0.229"
3603+source = "registry+https://github.com/rust-lang/crates.io-index"
3604+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
3605+dependencies = [
3606+ "proc-macro2",
3607+ "quote",
3608+ "syn 3.0.6",
3609+]
3610+
3611+[[package]]
3612+name = "serde_json"
3613+version = "1.0.151"
3614+source = "registry+https://github.com/rust-lang/crates.io-index"
3615+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
3616+dependencies = [
3617+ "itoa",
3618+ "memchr",
3619+ "serde",
3620+ "serde_core",
3621+ "zmij",
3622+]
3623+
3624+[[package]]
3625+name = "serde_path_to_error"
3626+version = "0.1.20"
3627+source = "registry+https://github.com/rust-lang/crates.io-index"
3628+checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
3629+dependencies = [
3630+ "itoa",
3631+ "serde",
3632+ "serde_core",
3633+]
3634+
3635+[[package]]
3636+name = "serde_spanned"
3637+version = "1.1.2"
3638+source = "registry+https://github.com/rust-lang/crates.io-index"
3639+checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c"
3640+dependencies = [
3641+ "serde_core",
3642+]
3643+
3644+[[package]]
3645+name = "serde_urlencoded"
3646+version = "0.7.1"
3647+source = "registry+https://github.com/rust-lang/crates.io-index"
3648+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
3649+dependencies = [
3650+ "form_urlencoded",
3651+ "itoa",
3652+ "ryu",
3653+ "serde",
3654+]
3655+
3656+[[package]]
3657+name = "serdect"
3658+version = "0.4.3"
3659+source = "registry+https://github.com/rust-lang/crates.io-index"
3660+checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e"
3661+dependencies = [
3662+ "base16ct",
3663+ "serde",
3664+]
3665+
3666+[[package]]
3667+name = "sha1"
3668+version = "0.10.7"
3669+source = "registry+https://github.com/rust-lang/crates.io-index"
3670+checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
3671+dependencies = [
3672+ "cfg-if",
3673+ "cpufeatures 0.2.17",
3674+ "digest 0.10.7",
3675+]
3676+
3677+[[package]]
3678+name = "sha1"
3679+version = "0.11.0"
3680+source = "registry+https://github.com/rust-lang/crates.io-index"
3681+checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
3682+dependencies = [
3683+ "cfg-if",
3684+ "cpufeatures 0.3.1",
3685+ "digest 0.11.3",
3686+]
3687+
3688+[[package]]
3689+name = "sha2"
3690+version = "0.10.9"
3691+source = "registry+https://github.com/rust-lang/crates.io-index"
3692+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
3693+dependencies = [
3694+ "cfg-if",
3695+ "cpufeatures 0.2.17",
3696+ "digest 0.10.7",
3697+]
3698+
3699+[[package]]
3700+name = "sha2"
3701+version = "0.11.0"
3702+source = "registry+https://github.com/rust-lang/crates.io-index"
3703+checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
3704+dependencies = [
3705+ "cfg-if",
3706+ "cpufeatures 0.3.1",
3707+ "digest 0.11.3",
3708+]
3709+
3710+[[package]]
3711+name = "sha3"
3712+version = "0.11.0"
3713+source = "registry+https://github.com/rust-lang/crates.io-index"
3714+checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1"
3715+dependencies = [
3716+ "digest 0.11.3",
3717+ "keccak",
3718+]
3719+
3720+[[package]]
3721+name = "sha3"
3722+version = "0.12.0"
3723+source = "registry+https://github.com/rust-lang/crates.io-index"
3724+checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759"
3725+dependencies = [
3726+ "digest 0.11.3",
3727+ "keccak",
3728+ "sponge-cursor",
3729+]
3730+
3731+[[package]]
3732+name = "sharded-slab"
3733+version = "0.1.7"
3734+source = "registry+https://github.com/rust-lang/crates.io-index"
3735+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
3736+dependencies = [
3737+ "lazy_static",
3738+]
3739+
3740+[[package]]
3741+name = "shell-words"
3742+version = "1.1.1"
3743+source = "registry+https://github.com/rust-lang/crates.io-index"
3744+checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
3745+
3746+[[package]]
3747+name = "shlex"
3748+version = "2.0.1"
3749+source = "registry+https://github.com/rust-lang/crates.io-index"
3750+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
3751+
3752+[[package]]
3753+name = "signal-hook-registry"
3754+version = "1.4.8"
3755+source = "registry+https://github.com/rust-lang/crates.io-index"
3756+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
3757+dependencies = [
3758+ "errno",
3759+ "libc",
3760+]
3761+
3762+[[package]]
3763+name = "signature"
3764+version = "3.0.0"
3765+source = "registry+https://github.com/rust-lang/crates.io-index"
3766+checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5"
3767+dependencies = [
3768+ "digest 0.11.3",
3769+ "rand_core 0.10.1",
3770+]
3771+
3772+[[package]]
3773+name = "simd-adler32"
3774+version = "0.3.10"
3775+source = "registry+https://github.com/rust-lang/crates.io-index"
3776+checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
3777+
3778+[[package]]
3779+name = "simd_cesu8"
3780+version = "1.2.0"
3781+source = "registry+https://github.com/rust-lang/crates.io-index"
3782+checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
3783+dependencies = [
3784+ "rustc_version",
3785+ "simdutf8",
3786+]
3787+
3788+[[package]]
3789+name = "simdutf8"
3790+version = "0.1.5"
3791+source = "registry+https://github.com/rust-lang/crates.io-index"
3792+checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
3793+
3794+[[package]]
3795+name = "siphasher"
3796+version = "1.0.4"
3797+source = "registry+https://github.com/rust-lang/crates.io-index"
3798+checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256"
3799+
3800+[[package]]
3801+name = "slab"
3802+version = "0.4.12"
3803+source = "registry+https://github.com/rust-lang/crates.io-index"
3804+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
3805+
3806+[[package]]
3807+name = "smallvec"
3808+version = "1.16.2"
3809+source = "registry+https://github.com/rust-lang/crates.io-index"
3810+checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
3811+dependencies = [
3812+ "serde",
3813+]
3814+
3815+[[package]]
3816+name = "socket2"
3817+version = "0.6.5"
3818+source = "registry+https://github.com/rust-lang/crates.io-index"
3819+checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
3820+dependencies = [
3821+ "libc",
3822+ "windows-sys 0.61.2",
3823+]
3824+
3825+[[package]]
3826+name = "spin"
3827+version = "0.9.9"
3828+source = "registry+https://github.com/rust-lang/crates.io-index"
3829+checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
3830+dependencies = [
3831+ "lock_api",
3832+]
3833+
3834+[[package]]
3835+name = "spki"
3836+version = "0.8.1"
3837+source = "registry+https://github.com/rust-lang/crates.io-index"
3838+checksum = "7ef958a98b9d5da290cfc78946e9f3e61e1e62a18db0d92cac0b83cc161491a9"
3839+dependencies = [
3840+ "base64ct",
3841+ "der",
3842+]
3843+
3844+[[package]]
3845+name = "sponge-cursor"
3846+version = "0.1.0"
3847+source = "registry+https://github.com/rust-lang/crates.io-index"
3848+checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620"
3849+
3850+[[package]]
3851+name = "sqlx"
3852+version = "0.9.0"
3853+source = "registry+https://github.com/rust-lang/crates.io-index"
3854+checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71"
3855+dependencies = [
3856+ "sqlx-core",
3857+ "sqlx-macros",
3858+ "sqlx-mysql",
3859+ "sqlx-postgres",
3860+ "sqlx-sqlite",
3861+]
3862+
3863+[[package]]
3864+name = "sqlx-core"
3865+version = "0.9.0"
3866+source = "registry+https://github.com/rust-lang/crates.io-index"
3867+checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb"
3868+dependencies = [
3869+ "base64 0.22.1",
3870+ "bytes",
3871+ "cfg-if",
3872+ "chrono",
3873+ "crc",
3874+ "crossbeam-queue",
3875+ "either",
3876+ "event-listener",
3877+ "futures-core",
3878+ "futures-intrusive",
3879+ "futures-io",
3880+ "futures-util",
3881+ "hashbrown 0.16.1",
3882+ "hashlink",
3883+ "indexmap",
3884+ "log",
3885+ "memchr",
3886+ "percent-encoding",
3887+ "rustls",
3888+ "serde",
3889+ "serde_json",
3890+ "sha2 0.10.9",
3891+ "smallvec",
3892+ "thiserror",
3893+ "tokio",
3894+ "tokio-stream",
3895+ "tracing",
3896+ "url",
3897+ "uuid",
3898+ "webpki-roots",
3899+]
3900+
3901+[[package]]
3902+name = "sqlx-macros"
3903+version = "0.9.0"
3904+source = "registry+https://github.com/rust-lang/crates.io-index"
3905+checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf"
3906+dependencies = [
3907+ "proc-macro2",
3908+ "quote",
3909+ "sqlx-core",
3910+ "sqlx-macros-core",
3911+ "syn 2.0.119",
3912+]
3913+
3914+[[package]]
3915+name = "sqlx-macros-core"
3916+version = "0.9.0"
3917+source = "registry+https://github.com/rust-lang/crates.io-index"
3918+checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3"
3919+dependencies = [
3920+ "cfg-if",
3921+ "dotenvy",
3922+ "either",
3923+ "heck",
3924+ "hex",
3925+ "proc-macro2",
3926+ "quote",
3927+ "serde",
3928+ "serde_json",
3929+ "sha2 0.10.9",
3930+ "sqlx-core",
3931+ "sqlx-mysql",
3932+ "sqlx-postgres",
3933+ "sqlx-sqlite",
3934+ "syn 2.0.119",
3935+ "thiserror",
3936+ "tokio",
3937+ "url",
3938+]
3939+
3940+[[package]]
3941+name = "sqlx-mysql"
3942+version = "0.9.0"
3943+source = "registry+https://github.com/rust-lang/crates.io-index"
3944+checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27"
3945+dependencies = [
3946+ "bitflags 2.13.2",
3947+ "byteorder",
3948+ "bytes",
3949+ "chrono",
3950+ "crc",
3951+ "digest 0.11.3",
3952+ "dotenvy",
3953+ "either",
3954+ "futures-core",
3955+ "futures-util",
3956+ "generic-array 0.14.7",
3957+ "log",
3958+ "percent-encoding",
3959+ "serde",
3960+ "sha1 0.11.0",
3961+ "sha2 0.11.0",
3962+ "sqlx-core",
3963+ "thiserror",
3964+ "tracing",
3965+ "uuid",
3966+]
3967+
3968+[[package]]
3969+name = "sqlx-postgres"
3970+version = "0.9.0"
3971+source = "registry+https://github.com/rust-lang/crates.io-index"
3972+checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e"
3973+dependencies = [
3974+ "atoi",
3975+ "base64 0.22.1",
3976+ "bitflags 2.13.2",
3977+ "byteorder",
3978+ "chrono",
3979+ "crc",
3980+ "dotenvy",
3981+ "etcetera",
3982+ "futures-channel",
3983+ "futures-core",
3984+ "futures-util",
3985+ "hex",
3986+ "hkdf",
3987+ "hmac",
3988+ "itoa",
3989+ "log",
3990+ "md-5",
3991+ "memchr",
3992+ "rand 0.10.3",
3993+ "serde",
3994+ "serde_json",
3995+ "sha2 0.11.0",
3996+ "smallvec",
3997+ "sqlx-core",
3998+ "stringprep",
3999+ "thiserror",
4000+ "tracing",
4001+ "uuid",
4002+ "whoami",
4003+]
4004+
4005+[[package]]
4006+name = "sqlx-sqlite"
4007+version = "0.9.0"
4008+source = "registry+https://github.com/rust-lang/crates.io-index"
4009+checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c"
4010+dependencies = [
4011+ "atoi",
4012+ "chrono",
4013+ "flume",
4014+ "form_urlencoded",
4015+ "futures-channel",
4016+ "futures-core",
4017+ "futures-executor",
4018+ "futures-intrusive",
4019+ "futures-util",
4020+ "libsqlite3-sys",
4021+ "log",
4022+ "percent-encoding",
4023+ "serde",
4024+ "sqlx-core",
4025+ "thiserror",
4026+ "tracing",
4027+ "url",
4028+ "uuid",
4029+]
4030+
4031+[[package]]
4032+name = "ssh-cipher"
4033+version = "0.3.0"
4034+source = "registry+https://github.com/rust-lang/crates.io-index"
4035+checksum = "d801accda99469cde6d73da741422610fdf6508a72d9a69d1b55cb241c720597"
4036+dependencies = [
4037+ "aead",
4038+ "aes",
4039+ "aes-gcm",
4040+ "chacha20",
4041+ "cipher",
4042+ "ctutils",
4043+ "des",
4044+ "poly1305",
4045+ "ssh-encoding",
4046+ "zeroize",
4047+]
4048+
4049+[[package]]
4050+name = "ssh-encoding"
4051+version = "0.3.0"
4052+source = "registry+https://github.com/rust-lang/crates.io-index"
4053+checksum = "7b54d0ed0498daf3f78d82e00e28c8eec9d75a067c4cfbcc7a0f7d0f4077749e"
4054+dependencies = [
4055+ "base64ct",
4056+ "bytes",
4057+ "crypto-bigint",
4058+ "ctutils",
4059+ "digest 0.11.3",
4060+ "pem-rfc7468",
4061+ "zeroize",
4062+]
4063+
4064+[[package]]
4065+name = "ssh-key"
4066+version = "0.7.0-rc.11"
4067+source = "registry+https://github.com/rust-lang/crates.io-index"
4068+checksum = "f9a32fae177b74a22aa9c5b01bf7e68b33545be32d9e381e248058d2adc15ce3"
4069+dependencies = [
4070+ "argon2",
4071+ "bcrypt-pbkdf",
4072+ "ctutils",
4073+ "ed25519-dalek",
4074+ "hex",
4075+ "hmac",
4076+ "p256",
4077+ "p384",
4078+ "p521",
4079+ "rand_core 0.10.1",
4080+ "rsa",
4081+ "sec1",
4082+ "sha1 0.11.0",
4083+ "sha2 0.11.0",
4084+ "signature",
4085+ "ssh-cipher",
4086+ "ssh-encoding",
4087+ "zeroize",
4088+]
4089+
4090+[[package]]
4091+name = "stable_deref_trait"
4092+version = "1.2.1"
4093+source = "registry+https://github.com/rust-lang/crates.io-index"
4094+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
4095+
4096+[[package]]
4097+name = "stringprep"
4098+version = "0.1.5"
4099+source = "registry+https://github.com/rust-lang/crates.io-index"
4100+checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
4101+dependencies = [
4102+ "unicode-bidi",
4103+ "unicode-normalization",
4104+ "unicode-properties",
4105+]
4106+
4107+[[package]]
4108+name = "strsim"
4109+version = "0.11.1"
4110+source = "registry+https://github.com/rust-lang/crates.io-index"
4111+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
4112+
4113+[[package]]
4114+name = "subtle"
4115+version = "2.6.1"
4116+source = "registry+https://github.com/rust-lang/crates.io-index"
4117+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
4118+
4119+[[package]]
4120+name = "symlink"
4121+version = "0.1.0"
4122+source = "registry+https://github.com/rust-lang/crates.io-index"
4123+checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a"
4124+
4125+[[package]]
4126+name = "syn"
4127+version = "2.0.119"
4128+source = "registry+https://github.com/rust-lang/crates.io-index"
4129+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
4130+dependencies = [
4131+ "proc-macro2",
4132+ "quote",
4133+ "unicode-ident",
4134+]
4135+
4136+[[package]]
4137+name = "syn"
4138+version = "3.0.6"
4139+source = "registry+https://github.com/rust-lang/crates.io-index"
4140+checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
4141+dependencies = [
4142+ "proc-macro2",
4143+ "quote",
4144+ "unicode-ident",
4145+]
4146+
4147+[[package]]
4148+name = "sync_wrapper"
4149+version = "1.0.2"
4150+source = "registry+https://github.com/rust-lang/crates.io-index"
4151+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
4152+dependencies = [
4153+ "futures-core",
4154+]
4155+
4156+[[package]]
4157+name = "synstructure"
4158+version = "0.14.0"
4159+source = "registry+https://github.com/rust-lang/crates.io-index"
4160+checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02"
4161+dependencies = [
4162+ "proc-macro2",
4163+ "quote",
4164+ "syn 3.0.6",
4165+]
4166+
4167+[[package]]
4168+name = "syntect"
4169+version = "5.3.0"
4170+source = "registry+https://github.com/rust-lang/crates.io-index"
4171+checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925"
4172+dependencies = [
4173+ "bincode",
4174+ "fancy-regex",
4175+ "flate2",
4176+ "fnv",
4177+ "once_cell",
4178+ "onig",
4179+ "plist",
4180+ "regex-syntax",
4181+ "serde",
4182+ "serde_derive",
4183+ "serde_json",
4184+ "thiserror",
4185+ "walkdir",
4186+ "yaml-rust",
4187+]
4188+
4189+[[package]]
4190+name = "terminal_size"
4191+version = "0.4.4"
4192+source = "registry+https://github.com/rust-lang/crates.io-index"
4193+checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"
4194+dependencies = [
4195+ "rustix",
4196+ "windows-sys 0.61.2",
4197+]
4198+
4199+[[package]]
4200+name = "thiserror"
4201+version = "2.0.21"
4202+source = "registry+https://github.com/rust-lang/crates.io-index"
4203+checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
4204+dependencies = [
4205+ "thiserror-impl",
4206+]
4207+
4208+[[package]]
4209+name = "thiserror-impl"
4210+version = "2.0.21"
4211+source = "registry+https://github.com/rust-lang/crates.io-index"
4212+checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
4213+dependencies = [
4214+ "proc-macro2",
4215+ "quote",
4216+ "syn 3.0.6",
4217+]
4218+
4219+[[package]]
4220+name = "thread_local"
4221+version = "1.1.10"
4222+source = "registry+https://github.com/rust-lang/crates.io-index"
4223+checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
4224+dependencies = [
4225+ "cfg-if",
4226+]
4227+
4228+[[package]]
4229+name = "time"
4230+version = "0.3.55"
4231+source = "registry+https://github.com/rust-lang/crates.io-index"
4232+checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
4233+dependencies = [
4234+ "deranged",
4235+ "num-conv",
4236+ "powerfmt",
4237+ "serde_core",
4238+ "time-core",
4239+ "time-macros",
4240+]
4241+
4242+[[package]]
4243+name = "time-core"
4244+version = "0.1.9"
4245+source = "registry+https://github.com/rust-lang/crates.io-index"
4246+checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
4247+
4248+[[package]]
4249+name = "time-macros"
4250+version = "0.2.32"
4251+source = "registry+https://github.com/rust-lang/crates.io-index"
4252+checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
4253+dependencies = [
4254+ "num-conv",
4255+ "time-core",
4256+]
4257+
4258+[[package]]
4259+name = "tinystr"
4260+version = "0.8.4"
4261+source = "registry+https://github.com/rust-lang/crates.io-index"
4262+checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
4263+dependencies = [
4264+ "displaydoc",
4265+ "zerovec",
4266+]
4267+
4268+[[package]]
4269+name = "tinyvec"
4270+version = "1.13.3"
4271+source = "registry+https://github.com/rust-lang/crates.io-index"
4272+checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
4273+
4274+[[package]]
4275+name = "tokio"
4276+version = "1.53.2"
4277+source = "registry+https://github.com/rust-lang/crates.io-index"
4278+checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044"
4279+dependencies = [
4280+ "bytes",
4281+ "libc",
4282+ "mio",
4283+ "parking_lot",
4284+ "pin-project-lite",
4285+ "signal-hook-registry",
4286+ "socket2",
4287+ "tokio-macros",
4288+ "windows-sys 0.61.2",
4289+]
4290+
4291+[[package]]
4292+name = "tokio-macros"
4293+version = "2.7.2"
4294+source = "registry+https://github.com/rust-lang/crates.io-index"
4295+checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
4296+dependencies = [
4297+ "proc-macro2",
4298+ "quote",
4299+ "syn 3.0.6",
4300+]
4301+
4302+[[package]]
4303+name = "tokio-rustls"
4304+version = "0.26.6"
4305+source = "registry+https://github.com/rust-lang/crates.io-index"
4306+checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db"
4307+dependencies = [
4308+ "rustls",
4309+ "tokio",
4310+]
4311+
4312+[[package]]
4313+name = "tokio-stream"
4314+version = "0.1.19"
4315+source = "registry+https://github.com/rust-lang/crates.io-index"
4316+checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b"
4317+dependencies = [
4318+ "futures-core",
4319+ "pin-project-lite",
4320+ "tokio",
4321+]
4322+
4323+[[package]]
4324+name = "tokio-tungstenite"
4325+version = "0.29.0"
4326+source = "registry+https://github.com/rust-lang/crates.io-index"
4327+checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
4328+dependencies = [
4329+ "futures-util",
4330+ "log",
4331+ "tokio",
4332+ "tungstenite",
4333+]
4334+
4335+[[package]]
4336+name = "tokio-util"
4337+version = "0.7.19"
4338+source = "registry+https://github.com/rust-lang/crates.io-index"
4339+checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
4340+dependencies = [
4341+ "bytes",
4342+ "futures-core",
4343+ "futures-sink",
4344+ "libc",
4345+ "pin-project-lite",
4346+ "tokio",
4347+]
4348+
4349+[[package]]
4350+name = "toml"
4351+version = "1.1.7+spec-1.1.0"
4352+source = "registry+https://github.com/rust-lang/crates.io-index"
4353+checksum = "7874cd34dd99040fc24e30923aa64a60a81ff3ddfe2b8129ccc84ba24949deed"
4354+dependencies = [
4355+ "indexmap",
4356+ "serde_core",
4357+ "serde_spanned",
4358+ "toml_datetime",
4359+ "toml_parser",
4360+ "toml_writer",
4361+ "winnow",
4362+]
4363+
4364+[[package]]
4365+name = "toml_datetime"
4366+version = "1.1.2+spec-1.1.0"
4367+source = "registry+https://github.com/rust-lang/crates.io-index"
4368+checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053"
4369+dependencies = [
4370+ "serde_core",
4371+]
4372+
4373+[[package]]
4374+name = "toml_parser"
4375+version = "1.1.4+spec-1.1.0"
4376+source = "registry+https://github.com/rust-lang/crates.io-index"
4377+checksum = "bed810ee007d8e7f9cf20b4774e7198142fa27186e0f805871da7f3c39eaf333"
4378+dependencies = [
4379+ "winnow",
4380+]
4381+
4382+[[package]]
4383+name = "toml_writer"
4384+version = "1.1.3+spec-1.1.0"
4385+source = "registry+https://github.com/rust-lang/crates.io-index"
4386+checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770"
4387+
4388+[[package]]
4389+name = "tower"
4390+version = "0.5.3"
4391+source = "registry+https://github.com/rust-lang/crates.io-index"
4392+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
4393+dependencies = [
4394+ "futures-core",
4395+ "futures-util",
4396+ "pin-project-lite",
4397+ "sync_wrapper",
4398+ "tokio",
4399+ "tower-layer",
4400+ "tower-service",
4401+ "tracing",
4402+]
4403+
4404+[[package]]
4405+name = "tower-http"
4406+version = "0.6.11"
4407+source = "registry+https://github.com/rust-lang/crates.io-index"
4408+checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
4409+dependencies = [
4410+ "bitflags 2.13.2",
4411+ "bytes",
4412+ "futures-util",
4413+ "http",
4414+ "http-body",
4415+ "pin-project-lite",
4416+ "tower",
4417+ "tower-layer",
4418+ "tower-service",
4419+ "url",
4420+]
4421+
4422+[[package]]
4423+name = "tower-http"
4424+version = "0.7.1"
4425+source = "registry+https://github.com/rust-lang/crates.io-index"
4426+checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c"
4427+dependencies = [
4428+ "async-compression",
4429+ "bitflags 2.13.2",
4430+ "bytes",
4431+ "futures-core",
4432+ "http",
4433+ "http-body",
4434+ "http-body-util",
4435+ "percent-encoding",
4436+ "pin-project-lite",
4437+ "tokio",
4438+ "tokio-util",
4439+ "tower-layer",
4440+ "tower-service",
4441+ "tracing",
4442+]
4443+
4444+[[package]]
4445+name = "tower-layer"
4446+version = "0.3.3"
4447+source = "registry+https://github.com/rust-lang/crates.io-index"
4448+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
4449+
4450+[[package]]
4451+name = "tower-service"
4452+version = "0.3.3"
4453+source = "registry+https://github.com/rust-lang/crates.io-index"
4454+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
4455+
4456+[[package]]
4457+name = "tracing"
4458+version = "0.1.44"
4459+source = "registry+https://github.com/rust-lang/crates.io-index"
4460+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
4461+dependencies = [
4462+ "log",
4463+ "pin-project-lite",
4464+ "tracing-attributes",
4465+ "tracing-core",
4466+]
4467+
4468+[[package]]
4469+name = "tracing-appender"
4470+version = "0.2.5"
4471+source = "registry+https://github.com/rust-lang/crates.io-index"
4472+checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c"
4473+dependencies = [
4474+ "crossbeam-channel",
4475+ "symlink",
4476+ "thiserror",
4477+ "time",
4478+ "tracing-subscriber",
4479+]
4480+
4481+[[package]]
4482+name = "tracing-attributes"
4483+version = "0.1.31"
4484+source = "registry+https://github.com/rust-lang/crates.io-index"
4485+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
4486+dependencies = [
4487+ "proc-macro2",
4488+ "quote",
4489+ "syn 2.0.119",
4490+]
4491+
4492+[[package]]
4493+name = "tracing-core"
4494+version = "0.1.36"
4495+source = "registry+https://github.com/rust-lang/crates.io-index"
4496+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
4497+dependencies = [
4498+ "once_cell",
4499+ "valuable",
4500+]
4501+
4502+[[package]]
4503+name = "tracing-log"
4504+version = "0.2.0"
4505+source = "registry+https://github.com/rust-lang/crates.io-index"
4506+checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
4507+dependencies = [
4508+ "log",
4509+ "once_cell",
4510+ "tracing-core",
4511+]
4512+
4513+[[package]]
4514+name = "tracing-serde"
4515+version = "0.2.0"
4516+source = "registry+https://github.com/rust-lang/crates.io-index"
4517+checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1"
4518+dependencies = [
4519+ "serde",
4520+ "tracing-core",
4521+]
4522+
4523+[[package]]
4524+name = "tracing-subscriber"
4525+version = "0.3.23"
4526+source = "registry+https://github.com/rust-lang/crates.io-index"
4527+checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
4528+dependencies = [
4529+ "matchers",
4530+ "nu-ansi-term",
4531+ "once_cell",
4532+ "regex-automata",
4533+ "serde",
4534+ "serde_json",
4535+ "sharded-slab",
4536+ "smallvec",
4537+ "thread_local",
4538+ "tracing",
4539+ "tracing-core",
4540+ "tracing-log",
4541+ "tracing-serde",
4542+]
4543+
4544+[[package]]
4545+name = "try-lock"
4546+version = "0.2.5"
4547+source = "registry+https://github.com/rust-lang/crates.io-index"
4548+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
4549+
4550+[[package]]
4551+name = "tungstenite"
4552+version = "0.29.0"
4553+source = "registry+https://github.com/rust-lang/crates.io-index"
4554+checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
4555+dependencies = [
4556+ "bytes",
4557+ "data-encoding",
4558+ "http",
4559+ "httparse",
4560+ "log",
4561+ "rand 0.9.5",
4562+ "sha1 0.10.7",
4563+ "thiserror",
4564+]
4565+
4566+[[package]]
4567+name = "typed-arena"
4568+version = "2.0.2"
4569+source = "registry+https://github.com/rust-lang/crates.io-index"
4570+checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a"
4571+
4572+[[package]]
4573+name = "typenum"
4574+version = "1.20.1"
4575+source = "registry+https://github.com/rust-lang/crates.io-index"
4576+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
4577+
4578+[[package]]
4579+name = "unicase"
4580+version = "2.10.0"
4581+source = "registry+https://github.com/rust-lang/crates.io-index"
4582+checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f"
4583+
4584+[[package]]
4585+name = "unicode-bidi"
4586+version = "0.3.18"
4587+source = "registry+https://github.com/rust-lang/crates.io-index"
4588+checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
4589+
4590+[[package]]
4591+name = "unicode-ident"
4592+version = "1.0.26"
4593+source = "registry+https://github.com/rust-lang/crates.io-index"
4594+checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
4595+
4596+[[package]]
4597+name = "unicode-normalization"
4598+version = "0.1.25"
4599+source = "registry+https://github.com/rust-lang/crates.io-index"
4600+checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
4601+dependencies = [
4602+ "tinyvec",
4603+]
4604+
4605+[[package]]
4606+name = "unicode-properties"
4607+version = "0.1.4"
4608+source = "registry+https://github.com/rust-lang/crates.io-index"
4609+checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
4610+
4611+[[package]]
4612+name = "universal-hash"
4613+version = "0.6.1"
4614+source = "registry+https://github.com/rust-lang/crates.io-index"
4615+checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96"
4616+dependencies = [
4617+ "crypto-common 0.2.2",
4618+ "ctutils",
4619+]
4620+
4621+[[package]]
4622+name = "untrusted"
4623+version = "0.7.1"
4624+source = "registry+https://github.com/rust-lang/crates.io-index"
4625+checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
4626+
4627+[[package]]
4628+name = "untrusted"
4629+version = "0.9.0"
4630+source = "registry+https://github.com/rust-lang/crates.io-index"
4631+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
4632+
4633+[[package]]
4634+name = "url"
4635+version = "2.5.8"
4636+source = "registry+https://github.com/rust-lang/crates.io-index"
4637+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
4638+dependencies = [
4639+ "form_urlencoded",
4640+ "idna",
4641+ "percent-encoding",
4642+ "serde",
4643+]
4644+
4645+[[package]]
4646+name = "utf8_iter"
4647+version = "1.0.4"
4648+source = "registry+https://github.com/rust-lang/crates.io-index"
4649+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
4650+
4651+[[package]]
4652+name = "utf8parse"
4653+version = "0.2.2"
4654+source = "registry+https://github.com/rust-lang/crates.io-index"
4655+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
4656+
4657+[[package]]
4658+name = "uuid"
4659+version = "1.27.0"
4660+source = "registry+https://github.com/rust-lang/crates.io-index"
4661+checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a"
4662+dependencies = [
4663+ "getrandom 0.4.3",
4664+ "js-sys",
4665+ "serde_core",
4666+ "wasm-bindgen",
4667+]
4668+
4669+[[package]]
4670+name = "valuable"
4671+version = "0.1.1"
4672+source = "registry+https://github.com/rust-lang/crates.io-index"
4673+checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
4674+
4675+[[package]]
4676+name = "vcpkg"
4677+version = "0.2.15"
4678+source = "registry+https://github.com/rust-lang/crates.io-index"
4679+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
4680+
4681+[[package]]
4682+name = "version_check"
4683+version = "0.9.5"
4684+source = "registry+https://github.com/rust-lang/crates.io-index"
4685+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
4686+
4687+[[package]]
4688+name = "walkdir"
4689+version = "2.5.0"
4690+source = "registry+https://github.com/rust-lang/crates.io-index"
4691+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
4692+dependencies = [
4693+ "same-file",
4694+ "winapi-util",
4695+]
4696+
4697+[[package]]
4698+name = "want"
4699+version = "0.3.2"
4700+source = "registry+https://github.com/rust-lang/crates.io-index"
4701+checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b"
4702+dependencies = [
4703+ "try-lock",
4704+]
4705+
4706+[[package]]
4707+name = "wasi"
4708+version = "0.11.1+wasi-snapshot-preview1"
4709+source = "registry+https://github.com/rust-lang/crates.io-index"
4710+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
4711+
4712+[[package]]
4713+name = "wasip2"
4714+version = "1.0.4+wasi-0.2.12"
4715+source = "registry+https://github.com/rust-lang/crates.io-index"
4716+checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
4717+dependencies = [
4718+ "wit-bindgen",
4719+]
4720+
4721+[[package]]
4722+name = "wasm-bindgen"
4723+version = "0.2.129"
4724+source = "registry+https://github.com/rust-lang/crates.io-index"
4725+checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
4726+dependencies = [
4727+ "cfg-if",
4728+ "once_cell",
4729+ "rustversion",
4730+ "wasm-bindgen-macro",
4731+ "wasm-bindgen-shared",
4732+]
4733+
4734+[[package]]
4735+name = "wasm-bindgen-futures"
4736+version = "0.4.79"
4737+source = "registry+https://github.com/rust-lang/crates.io-index"
4738+checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e"
4739+dependencies = [
4740+ "js-sys",
4741+ "tokio",
4742+ "wasm-bindgen",
4743+]
4744+
4745+[[package]]
4746+name = "wasm-bindgen-macro"
4747+version = "0.2.129"
4748+source = "registry+https://github.com/rust-lang/crates.io-index"
4749+checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
4750+dependencies = [
4751+ "quote",
4752+ "wasm-bindgen-macro-support",
4753+]
4754+
4755+[[package]]
4756+name = "wasm-bindgen-macro-support"
4757+version = "0.2.129"
4758+source = "registry+https://github.com/rust-lang/crates.io-index"
4759+checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
4760+dependencies = [
4761+ "bumpalo",
4762+ "proc-macro2",
4763+ "quote",
4764+ "syn 3.0.6",
4765+ "wasm-bindgen-shared",
4766+]
4767+
4768+[[package]]
4769+name = "wasm-bindgen-shared"
4770+version = "0.2.129"
4771+source = "registry+https://github.com/rust-lang/crates.io-index"
4772+checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
4773+dependencies = [
4774+ "unicode-ident",
4775+]
4776+
4777+[[package]]
4778+name = "wasm-streams"
4779+version = "0.5.0"
4780+source = "registry+https://github.com/rust-lang/crates.io-index"
4781+checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb"
4782+dependencies = [
4783+ "futures-util",
4784+ "js-sys",
4785+ "wasm-bindgen",
4786+ "wasm-bindgen-futures",
4787+ "web-sys",
4788+]
4789+
4790+[[package]]
4791+name = "web-sys"
4792+version = "0.3.106"
4793+source = "registry+https://github.com/rust-lang/crates.io-index"
4794+checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4"
4795+dependencies = [
4796+ "js-sys",
4797+ "wasm-bindgen",
4798+]
4799+
4800+[[package]]
4801+name = "web-time"
4802+version = "1.1.0"
4803+source = "registry+https://github.com/rust-lang/crates.io-index"
4804+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
4805+dependencies = [
4806+ "js-sys",
4807+ "wasm-bindgen",
4808+]
4809+
4810+[[package]]
4811+name = "webpki-root-certs"
4812+version = "1.0.9"
4813+source = "registry+https://github.com/rust-lang/crates.io-index"
4814+checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
4815+dependencies = [
4816+ "rustls-pki-types",
4817+]
4818+
4819+[[package]]
4820+name = "webpki-roots"
4821+version = "1.0.9"
4822+source = "registry+https://github.com/rust-lang/crates.io-index"
4823+checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
4824+dependencies = [
4825+ "rustls-pki-types",
4826+]
4827+
4828+[[package]]
4829+name = "whoami"
4830+version = "2.1.3"
4831+source = "registry+https://github.com/rust-lang/crates.io-index"
4832+checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c"
4833+
4834+[[package]]
4835+name = "winapi-util"
4836+version = "0.1.11"
4837+source = "registry+https://github.com/rust-lang/crates.io-index"
4838+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
4839+dependencies = [
4840+ "windows-sys 0.61.2",
4841+]
4842+
4843+[[package]]
4844+name = "windows"
4845+version = "0.62.2"
4846+source = "registry+https://github.com/rust-lang/crates.io-index"
4847+checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
4848+dependencies = [
4849+ "windows-collections",
4850+ "windows-core",
4851+ "windows-future",
4852+ "windows-numerics",
4853+]
4854+
4855+[[package]]
4856+name = "windows-collections"
4857+version = "0.3.2"
4858+source = "registry+https://github.com/rust-lang/crates.io-index"
4859+checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
4860+dependencies = [
4861+ "windows-core",
4862+]
4863+
4864+[[package]]
4865+name = "windows-core"
4866+version = "0.62.2"
4867+source = "registry+https://github.com/rust-lang/crates.io-index"
4868+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
4869+dependencies = [
4870+ "windows-implement",
4871+ "windows-interface",
4872+ "windows-link",
4873+ "windows-result",
4874+ "windows-strings",
4875+]
4876+
4877+[[package]]
4878+name = "windows-future"
4879+version = "0.3.2"
4880+source = "registry+https://github.com/rust-lang/crates.io-index"
4881+checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
4882+dependencies = [
4883+ "windows-core",
4884+ "windows-link",
4885+ "windows-threading",
4886+]
4887+
4888+[[package]]
4889+name = "windows-implement"
4890+version = "0.60.2"
4891+source = "registry+https://github.com/rust-lang/crates.io-index"
4892+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
4893+dependencies = [
4894+ "proc-macro2",
4895+ "quote",
4896+ "syn 2.0.119",
4897+]
4898+
4899+[[package]]
4900+name = "windows-interface"
4901+version = "0.59.3"
4902+source = "registry+https://github.com/rust-lang/crates.io-index"
4903+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
4904+dependencies = [
4905+ "proc-macro2",
4906+ "quote",
4907+ "syn 2.0.119",
4908+]
4909+
4910+[[package]]
4911+name = "windows-link"
4912+version = "0.2.1"
4913+source = "registry+https://github.com/rust-lang/crates.io-index"
4914+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
4915+
4916+[[package]]
4917+name = "windows-numerics"
4918+version = "0.3.1"
4919+source = "registry+https://github.com/rust-lang/crates.io-index"
4920+checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
4921+dependencies = [
4922+ "windows-core",
4923+ "windows-link",
4924+]
4925+
4926+[[package]]
4927+name = "windows-result"
4928+version = "0.4.1"
4929+source = "registry+https://github.com/rust-lang/crates.io-index"
4930+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
4931+dependencies = [
4932+ "windows-link",
4933+]
4934+
4935+[[package]]
4936+name = "windows-strings"
4937+version = "0.5.1"
4938+source = "registry+https://github.com/rust-lang/crates.io-index"
4939+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
4940+dependencies = [
4941+ "windows-link",
4942+]
4943+
4944+[[package]]
4945+name = "windows-sys"
4946+version = "0.52.0"
4947+source = "registry+https://github.com/rust-lang/crates.io-index"
4948+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
4949+dependencies = [
4950+ "windows-targets 0.52.6",
4951+]
4952+
4953+[[package]]
4954+name = "windows-sys"
4955+version = "0.60.2"
4956+source = "registry+https://github.com/rust-lang/crates.io-index"
4957+checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
4958+dependencies = [
4959+ "windows-targets 0.53.5",
4960+]
4961+
4962+[[package]]
4963+name = "windows-sys"
4964+version = "0.61.2"
4965+source = "registry+https://github.com/rust-lang/crates.io-index"
4966+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
4967+dependencies = [
4968+ "windows-link",
4969+]
4970+
4971+[[package]]
4972+name = "windows-targets"
4973+version = "0.52.6"
4974+source = "registry+https://github.com/rust-lang/crates.io-index"
4975+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
4976+dependencies = [
4977+ "windows_aarch64_gnullvm 0.52.6",
4978+ "windows_aarch64_msvc 0.52.6",
4979+ "windows_i686_gnu 0.52.6",
4980+ "windows_i686_gnullvm 0.52.6",
4981+ "windows_i686_msvc 0.52.6",
4982+ "windows_x86_64_gnu 0.52.6",
4983+ "windows_x86_64_gnullvm 0.52.6",
4984+ "windows_x86_64_msvc 0.52.6",
4985+]
4986+
4987+[[package]]
4988+name = "windows-targets"
4989+version = "0.53.5"
4990+source = "registry+https://github.com/rust-lang/crates.io-index"
4991+checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
4992+dependencies = [
4993+ "windows-link",
4994+ "windows_aarch64_gnullvm 0.53.1",
4995+ "windows_aarch64_msvc 0.53.1",
4996+ "windows_i686_gnu 0.53.1",
4997+ "windows_i686_gnullvm 0.53.1",
4998+ "windows_i686_msvc 0.53.1",
4999+ "windows_x86_64_gnu 0.53.1",
5000+ "windows_x86_64_gnullvm 0.53.1",
5001+ "windows_x86_64_msvc 0.53.1",
5002+]
5003+
5004+[[package]]
5005+name = "windows-threading"
5006+version = "0.2.1"
5007+source = "registry+https://github.com/rust-lang/crates.io-index"
5008+checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
5009+dependencies = [
5010+ "windows-link",
5011+]
5012+
5013+[[package]]
5014+name = "windows_aarch64_gnullvm"
5015+version = "0.52.6"
5016+source = "registry+https://github.com/rust-lang/crates.io-index"
5017+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
5018+
5019+[[package]]
5020+name = "windows_aarch64_gnullvm"
5021+version = "0.53.1"
5022+source = "registry+https://github.com/rust-lang/crates.io-index"
5023+checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
5024+
5025+[[package]]
5026+name = "windows_aarch64_msvc"
5027+version = "0.52.6"
5028+source = "registry+https://github.com/rust-lang/crates.io-index"
5029+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
5030+
5031+[[package]]
5032+name = "windows_aarch64_msvc"
5033+version = "0.53.1"
5034+source = "registry+https://github.com/rust-lang/crates.io-index"
5035+checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
5036+
5037+[[package]]
5038+name = "windows_i686_gnu"
5039+version = "0.52.6"
5040+source = "registry+https://github.com/rust-lang/crates.io-index"
5041+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
5042+
5043+[[package]]
5044+name = "windows_i686_gnu"
5045+version = "0.53.1"
5046+source = "registry+https://github.com/rust-lang/crates.io-index"
5047+checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
5048+
5049+[[package]]
5050+name = "windows_i686_gnullvm"
5051+version = "0.52.6"
5052+source = "registry+https://github.com/rust-lang/crates.io-index"
5053+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
5054+
5055+[[package]]
5056+name = "windows_i686_gnullvm"
5057+version = "0.53.1"
5058+source = "registry+https://github.com/rust-lang/crates.io-index"
5059+checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
5060+
5061+[[package]]
5062+name = "windows_i686_msvc"
5063+version = "0.52.6"
5064+source = "registry+https://github.com/rust-lang/crates.io-index"
5065+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
5066+
5067+[[package]]
5068+name = "windows_i686_msvc"
5069+version = "0.53.1"
5070+source = "registry+https://github.com/rust-lang/crates.io-index"
5071+checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
5072+
5073+[[package]]
5074+name = "windows_x86_64_gnu"
5075+version = "0.52.6"
5076+source = "registry+https://github.com/rust-lang/crates.io-index"
5077+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
5078+
5079+[[package]]
5080+name = "windows_x86_64_gnu"
5081+version = "0.53.1"
5082+source = "registry+https://github.com/rust-lang/crates.io-index"
5083+checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
5084+
5085+[[package]]
5086+name = "windows_x86_64_gnullvm"
5087+version = "0.52.6"
5088+source = "registry+https://github.com/rust-lang/crates.io-index"
5089+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
5090+
5091+[[package]]
5092+name = "windows_x86_64_gnullvm"
5093+version = "0.53.1"
5094+source = "registry+https://github.com/rust-lang/crates.io-index"
5095+checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
5096+
5097+[[package]]
5098+name = "windows_x86_64_msvc"
5099+version = "0.52.6"
5100+source = "registry+https://github.com/rust-lang/crates.io-index"
5101+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
5102+
5103+[[package]]
5104+name = "windows_x86_64_msvc"
5105+version = "0.53.1"
5106+source = "registry+https://github.com/rust-lang/crates.io-index"
5107+checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
5108+
5109+[[package]]
5110+name = "winnow"
5111+version = "1.0.4"
5112+source = "registry+https://github.com/rust-lang/crates.io-index"
5113+checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
5114+
5115+[[package]]
5116+name = "wit-bindgen"
5117+version = "0.57.1"
5118+source = "registry+https://github.com/rust-lang/crates.io-index"
5119+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
5120+
5121+[[package]]
5122+name = "wnaf"
5123+version = "0.14.1"
5124+source = "registry+https://github.com/rust-lang/crates.io-index"
5125+checksum = "795ca18b3fdb5e62bf982199278341ddcf7ebf7d32e25e212ad05d496e95f6fa"
5126+dependencies = [
5127+ "ff",
5128+ "group",
5129+ "hybrid-array",
5130+ "primefield",
5131+]
5132+
5133+[[package]]
5134+name = "writeable"
5135+version = "0.6.4"
5136+source = "registry+https://github.com/rust-lang/crates.io-index"
5137+checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
5138+
5139+[[package]]
5140+name = "xdg"
5141+version = "3.0.0"
5142+source = "registry+https://github.com/rust-lang/crates.io-index"
5143+checksum = "2fb433233f2df9344722454bc7e96465c9d03bff9d77c248f9e7523fe79585b5"
5144+
5145+[[package]]
5146+name = "xmlparser"
5147+version = "0.13.6"
5148+source = "registry+https://github.com/rust-lang/crates.io-index"
5149+checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4"
5150+
5151+[[package]]
5152+name = "yaml-rust"
5153+version = "0.4.5"
5154+source = "registry+https://github.com/rust-lang/crates.io-index"
5155+checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85"
5156+dependencies = [
5157+ "linked-hash-map",
5158+]
5159+
5160+[[package]]
5161+name = "yoke"
5162+version = "0.8.3"
5163+source = "registry+https://github.com/rust-lang/crates.io-index"
5164+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
5165+dependencies = [
5166+ "stable_deref_trait",
5167+ "yoke-derive",
5168+ "zerofrom",
5169+]
5170+
5171+[[package]]
5172+name = "yoke-derive"
5173+version = "0.8.4"
5174+source = "registry+https://github.com/rust-lang/crates.io-index"
5175+checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71"
5176+dependencies = [
5177+ "proc-macro2",
5178+ "quote",
5179+ "syn 3.0.6",
5180+ "synstructure",
5181+]
5182+
5183+[[package]]
5184+name = "zerocopy"
5185+version = "0.8.62"
5186+source = "registry+https://github.com/rust-lang/crates.io-index"
5187+checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d"
5188+dependencies = [
5189+ "zerocopy-derive",
5190+]
5191+
5192+[[package]]
5193+name = "zerocopy-derive"
5194+version = "0.8.62"
5195+source = "registry+https://github.com/rust-lang/crates.io-index"
5196+checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a"
5197+dependencies = [
5198+ "proc-macro2",
5199+ "quote",
5200+ "syn 2.0.119",
5201+]
5202+
5203+[[package]]
5204+name = "zerofrom"
5205+version = "0.1.8"
5206+source = "registry+https://github.com/rust-lang/crates.io-index"
5207+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
5208+dependencies = [
5209+ "zerofrom-derive",
5210+]
5211+
5212+[[package]]
5213+name = "zerofrom-derive"
5214+version = "0.1.8"
5215+source = "registry+https://github.com/rust-lang/crates.io-index"
5216+checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a"
5217+dependencies = [
5218+ "proc-macro2",
5219+ "quote",
5220+ "syn 3.0.6",
5221+ "synstructure",
5222+]
5223+
5224+[[package]]
5225+name = "zeroize"
5226+version = "1.9.1"
5227+source = "registry+https://github.com/rust-lang/crates.io-index"
5228+checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
5229+
5230+[[package]]
5231+name = "zerotrie"
5232+version = "0.2.5"
5233+source = "registry+https://github.com/rust-lang/crates.io-index"
5234+checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
5235+dependencies = [
5236+ "displaydoc",
5237+ "yoke",
5238+ "zerofrom",
5239+]
5240+
5241+[[package]]
5242+name = "zerovec"
5243+version = "0.11.8"
5244+source = "registry+https://github.com/rust-lang/crates.io-index"
5245+checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
5246+dependencies = [
5247+ "yoke",
5248+ "zerofrom",
5249+ "zerovec-derive",
5250+]
5251+
5252+[[package]]
5253+name = "zerovec-derive"
5254+version = "0.11.6"
5255+source = "registry+https://github.com/rust-lang/crates.io-index"
5256+checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
5257+dependencies = [
5258+ "proc-macro2",
5259+ "quote",
5260+ "syn 3.0.6",
5261+]
5262+
5263+[[package]]
5264+name = "zlib-rs"
5265+version = "0.6.8"
5266+source = "registry+https://github.com/rust-lang/crates.io-index"
5267+checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
5268+
5269+[[package]]
5270+name = "zmij"
5271+version = "1.0.23"
5272+source = "registry+https://github.com/rust-lang/crates.io-index"
5273+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+13-0Cargo.toml
@@ -0,0 +1,13 @@
1+[workspace]
2+resolver = "3"
3+members = ["backend", "cli", "shared"]
4+
5+[profile.release]
6+lto = "thin"
7+codegen-units = 4
8+strip = true
9+
10+# Dependencies compile with optimizations even in debug builds: argon2,
11+# sha256 over image layers and syntax highlighting are unusably slow otherwise.
12+[profile.dev.package."*"]
13+opt-level = 2
+53-0backend/Cargo.toml
@@ -0,0 +1,53 @@
1+[package]
2+name = "irongit"
3+version = "0.1.0"
4+edition = "2024"
5+
6+[features]
7+default = ["hot-reload"]
8+# src/dev.rs: the bun rebuild, the file watcher and /dev-ws. Build a
9+# production binary with --no-default-features to leave all of it out.
10+hot-reload = ["dep:notify"]
11+
12+[dependencies]
13+notify = { version = "8", optional = true }
14+irongit-shared = { path = "../shared" }
15+anyhow = "1.0.104"
16+axum = { version = "0.8.9", features = ["ws", "multipart", "macros"] }
17+tokio = { version = "1.53.2", features = ["full"] }
18+tower-http = { version = "0.7.1", features = ["trace", "compression-gzip", "limit", "set-header", "timeout"] }
19+sqlx = { version = "0.9.0", default-features = false, features = ["runtime-tokio", "tls-rustls-ring-webpki", "postgres", "macros", "migrate", "uuid", "chrono", "json"] }
20+uuid = { version = "1.27.0", features = ["v4", "v7", "serde"] }
21+chrono = { version = "0.4.45", features = ["serde"] }
22+serde = { version = "1.0.229", features = ["derive"] }
23+serde_json = "1.0.151"
24+mime_guess = "2.0.5"
25+rust-embed = "8.13.0"
26+tracing = "0.1.44"
27+tracing-appender = "0.2.5"
28+dotenvy = "0.15.7"
29+argon2 = "0.6.0"
30+rand = "0.10.3"
31+sha2 = "0.11.0"
32+hex = "0.4.3"
33+hmac = "0.13.0"
34+base64 = "0.23.1"
35+bytes = "1.12.1"
36+futures = "0.3.34"
37+tokio-util = { version = "0.7.19", features = ["io", "io-util"] }
38+url = "2.5.8"
39+maud = { version = "0.27.0", features = ["axum"] }
40+regex = "1.13.1"
41+once_cell = "1.21.4"
42+time = "0.3.55"
43+tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] }
44+rusty-s3 = "0.10.2"
45+reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "stream", "json", "http2"] }
46+russh = "0.64.1"
47+comrak = "0.56.0"
48+syntect = "5.3.0"
49+flate2 = "1.1.10"
50+clap = { version = "4.6.7", features = ["derive", "env"] }
51+lettre = { version = "0.11.23", default-features = false, features = ["tokio1-rustls-tls", "smtp-transport", "builder", "hostname"] }
52+axum-extra = { version = "0.12.6", features = ["cookie", "typed-header"] }
53+async-compression = { version = "0.4.50", features = ["tokio", "gzip"] }
+7-0backend/build.rs
@@ -0,0 +1,7 @@
1+// rust-embed reads ../frontend/dist while the Embed derive expands, and
2+// cargo has no idea those files exist. Without this, a release build happily
3+// embeds a stale frontend (including the dev reload script) because nothing
4+// in src/ changed.
5+fn main() {
6+ println!("cargo:rerun-if-changed=../frontend/dist");
7+}
+288-0backend/migrations/0001_init.sql
@@ -0,0 +1,288 @@
1+-- irongit schema. Postgres holds identity, permissions and metadata; git
2+-- objects live on the server's disk and large blobs (LFS, image layers, CLI
3+-- releases, avatars, backups) live in R2.
4+
5+create extension if not exists citext;
6+create extension if not exists pg_trgm;
7+
8+-- Users and organizations share one namespace, so /{name} is unambiguous.
9+create table accounts (
10+ id bigserial primary key,
11+ kind text not null check (kind in ('user', 'org')),
12+ name citext not null unique,
13+ display_name text not null default '',
14+ bio text not null default '',
15+ location text not null default '',
16+ website text not null default '',
17+ -- R2 key of an uploaded avatar; null means the generated identicon.
18+ avatar_key text,
19+ -- Per-account override of the default storage quota, in bytes.
20+ quota_bytes bigint,
21+ created_at timestamptz not null default now(),
22+ updated_at timestamptz not null default now()
23+);
24+create index accounts_name_trgm on accounts using gin (name gin_trgm_ops);
25+
26+create table users (
27+ account_id bigint primary key references accounts (id) on delete cascade,
28+ -- Null for accounts that only sign in with Google.
29+ password_hash text,
30+ is_admin boolean not null default false,
31+ suspended_at timestamptz,
32+ -- Whether other people see counts from private repos on the heatmap.
33+ show_private_contributions boolean not null default false,
34+ last_login_at timestamptz
35+);
36+
37+create table emails (
38+ id bigserial primary key,
39+ user_id bigint not null references users (account_id) on delete cascade,
40+ email citext not null unique,
41+ is_primary boolean not null default false,
42+ verified_at timestamptz,
43+ verify_token_hash text,
44+ verify_sent_at timestamptz,
45+ created_at timestamptz not null default now()
46+);
47+create unique index emails_one_primary on emails (user_id) where is_primary;
48+create index emails_user on emails (user_id);
49+
50+-- External sign-in (Google). `subject` is the provider's stable user id.
51+create table oauth_identities (
52+ provider text not null check (provider in ('google')),
53+ subject text not null,
54+ user_id bigint not null references users (account_id) on delete cascade,
55+ email citext,
56+ created_at timestamptz not null default now(),
57+ last_used_at timestamptz,
58+ primary key (provider, subject),
59+ unique (provider, user_id)
60+);
61+
62+create table org_members (
63+ org_id bigint not null references accounts (id) on delete cascade,
64+ user_id bigint not null references users (account_id) on delete cascade,
65+ role text not null check (role in ('owner', 'member')),
66+ created_at timestamptz not null default now(),
67+ primary key (org_id, user_id)
68+);
69+create index org_members_user on org_members (user_id);
70+
71+create table sessions (
72+ id_hash text primary key,
73+ user_id bigint not null references users (account_id) on delete cascade,
74+ ip text,
75+ user_agent text,
76+ created_at timestamptz not null default now(),
77+ last_seen_at timestamptz not null default now(),
78+ expires_at timestamptz not null
79+);
80+create index sessions_user on sessions (user_id);
81+
82+-- Personal access tokens: the only credential git, docker and the CLI accept.
83+create table access_tokens (
84+ id bigserial primary key,
85+ user_id bigint not null references users (account_id) on delete cascade,
86+ name text not null,
87+ token_hash text not null unique,
88+ token_prefix text not null,
89+ scopes text[] not null default '{repo,packages,user}',
90+ last_used_at timestamptz,
91+ expires_at timestamptz,
92+ created_at timestamptz not null default now()
93+);
94+create index access_tokens_user on access_tokens (user_id);
95+
96+create table ssh_keys (
97+ id bigserial primary key,
98+ user_id bigint not null references users (account_id) on delete cascade,
99+ title text not null,
100+ public_key text not null,
101+ fingerprint text not null unique,
102+ last_used_at timestamptz,
103+ created_at timestamptz not null default now()
104+);
105+create index ssh_keys_user on ssh_keys (user_id);
106+
107+-- `ig login`: the CLI holds device_code, the person types user_code in a browser.
108+create table device_codes (
109+ device_code_hash text primary key,
110+ user_code text not null unique,
111+ client_name text not null default '',
112+ user_id bigint references users (account_id) on delete cascade,
113+ approved_at timestamptz,
114+ denied_at timestamptz,
115+ consumed_at timestamptz,
116+ expires_at timestamptz not null,
117+ created_at timestamptz not null default now()
118+);
119+
120+create table repos (
121+ id bigserial primary key,
122+ owner_id bigint not null references accounts (id) on delete cascade,
123+ name citext not null,
124+ description text not null default '',
125+ visibility text not null check (visibility in ('public', 'private')),
126+ default_branch text not null default 'main',
127+ size_bytes bigint not null default 0,
128+ is_empty boolean not null default true,
129+ archived boolean not null default false,
130+ created_at timestamptz not null default now(),
131+ updated_at timestamptz not null default now(),
132+ pushed_at timestamptz,
133+ unique (owner_id, name)
134+);
135+create index repos_name_trgm on repos using gin (name gin_trgm_ops);
136+create index repos_public_pushed on repos (pushed_at desc nulls last) where visibility = 'public';
137+
138+create table repo_collaborators (
139+ repo_id bigint not null references repos (id) on delete cascade,
140+ user_id bigint not null references users (account_id) on delete cascade,
141+ permission text not null check (permission in ('read', 'write', 'admin')),
142+ created_at timestamptz not null default now(),
143+ primary key (repo_id, user_id)
144+);
145+create index repo_collaborators_user on repo_collaborators (user_id);
146+
147+create table push_events (
148+ id bigserial primary key,
149+ repo_id bigint not null references repos (id) on delete cascade,
150+ pusher_id bigint references users (account_id) on delete set null,
151+ ref_name text not null,
152+ before_sha text not null,
153+ after_sha text not null,
154+ commit_count integer not null,
155+ head_message text not null default '',
156+ via text not null check (via in ('http', 'ssh', 'web')),
157+ created_at timestamptz not null default now()
158+);
159+create index push_events_pusher on push_events (pusher_id, created_at desc);
160+create index push_events_repo on push_events (repo_id, created_at desc);
161+
162+-- One row per commit that landed on a repo's default branch and whose author
163+-- email is verified by a user. Idempotent, so force pushes never double count.
164+create table contribution_commits (
165+ repo_id bigint not null references repos (id) on delete cascade,
166+ sha text not null,
167+ user_id bigint not null references users (account_id) on delete cascade,
168+ day date not null,
169+ primary key (repo_id, sha)
170+);
171+create index contribution_commits_user_day on contribution_commits (user_id, day);
172+
173+-- Git LFS. Objects are stored once in R2 by oid; repo links gate access.
174+create table lfs_objects (
175+ oid text primary key,
176+ size bigint not null,
177+ created_at timestamptz not null default now()
178+);
179+
180+create table repo_lfs_objects (
181+ repo_id bigint not null references repos (id) on delete cascade,
182+ oid text not null references lfs_objects (oid) on delete cascade,
183+ created_at timestamptz not null default now(),
184+ primary key (repo_id, oid)
185+);
186+create index repo_lfs_objects_oid on repo_lfs_objects (oid);
187+
188+-- Container images. Visibility is independent of any linked repo.
189+create table packages (
190+ id bigserial primary key,
191+ owner_id bigint not null references accounts (id) on delete cascade,
192+ -- Image path below the owner, lowercase: "api" or "tools/builder".
193+ name text not null,
194+ visibility text not null default 'private' check (visibility in ('public', 'private')),
195+ repo_id bigint references repos (id) on delete set null,
196+ description text not null default '',
197+ pull_count bigint not null default 0,
198+ created_at timestamptz not null default now(),
199+ updated_at timestamptz not null default now(),
200+ unique (owner_id, name)
201+);
202+
203+-- Layers and configs, stored once in R2 under their digest.
204+create table blobs (
205+ digest text primary key,
206+ size bigint not null,
207+ created_at timestamptz not null default now()
208+);
209+
210+-- Which packages may see a blob. Every HEAD, GET and mount checks this, so a
211+-- digest from someone's private image is useless without access to it.
212+create table package_blobs (
213+ package_id bigint not null references packages (id) on delete cascade,
214+ digest text not null references blobs (digest) on delete cascade,
215+ created_at timestamptz not null default now(),
216+ primary key (package_id, digest)
217+);
218+create index package_blobs_digest on package_blobs (digest);
219+
220+create table manifests (
221+ id bigserial primary key,
222+ package_id bigint not null references packages (id) on delete cascade,
223+ digest text not null,
224+ media_type text not null,
225+ content bytea not null,
226+ -- Sum of the config and layer sizes, for display.
227+ total_size bigint not null default 0,
228+ created_at timestamptz not null default now(),
229+ unique (package_id, digest)
230+);
231+
232+create table manifest_references (
233+ manifest_id bigint not null references manifests (id) on delete cascade,
234+ digest text not null,
235+ kind text not null check (kind in ('blob', 'manifest')),
236+ primary key (manifest_id, digest)
237+);
238+create index manifest_references_digest on manifest_references (digest);
239+
240+create table tags (
241+ package_id bigint not null references packages (id) on delete cascade,
242+ name text not null,
243+ manifest_id bigint not null references manifests (id) on delete cascade,
244+ updated_at timestamptz not null default now(),
245+ primary key (package_id, name)
246+);
247+create index tags_manifest on tags (manifest_id);
248+
249+-- In-progress pushes, staged on local disk under DATA_DIR/uploads/{id}.
250+create table blob_uploads (
251+ id uuid primary key,
252+ package_id bigint not null references packages (id) on delete cascade,
253+ user_id bigint references users (account_id) on delete set null,
254+ size bigint not null default 0,
255+ created_at timestamptz not null default now(),
256+ updated_at timestamptz not null default now()
257+);
258+
259+create table cli_releases (
260+ id bigserial primary key,
261+ version text not null unique,
262+ target text not null default 'x86_64-unknown-linux-musl',
263+ r2_key text not null,
264+ sha256 text not null,
265+ size bigint not null,
266+ created_at timestamptz not null default now()
267+);
268+
269+create table backup_runs (
270+ id bigserial primary key,
271+ started_at timestamptz not null default now(),
272+ finished_at timestamptz,
273+ status text not null default 'running' check (status in ('running', 'ok', 'failed')),
274+ repo_count integer not null default 0,
275+ bytes bigint not null default 0,
276+ error text
277+);
278+
279+create table audit_log (
280+ id bigserial primary key,
281+ actor_id bigint references users (account_id) on delete set null,
282+ action text not null,
283+ target text not null default '',
284+ meta jsonb not null default '{}',
285+ ip text,
286+ created_at timestamptz not null default now()
287+);
288+create index audit_log_created on audit_log (created_at desc);
+39-0backend/src/analytics.rs
@@ -0,0 +1,39 @@
1+//! Server-side events for the self-hosted Rybbit at a.hygo.ai. Browser
2+//! events are sent by the page script; these cover what has no browser:
3+//! git pushes, image pushes and pulls, CLI logins. Fire and forget.
4+
5+use serde_json::{Value, json};
6+
7+use crate::state::AppState;
8+
9+pub fn track(state: &AppState, event: &str, user: Option<&str>, path: &str, properties: Value) {
10+ let Some(rybbit) = state.config.rybbit.clone() else {
11+ tracing::debug!(event, %properties, "analytics disabled");
12+ return;
13+ };
14+ let http = state.http.clone();
15+ let hostname = state.config.public_url.host_str().unwrap_or("localhost").to_string();
16+ let body = json!({
17+ "type": "custom_event",
18+ "site_id": rybbit.site_id,
19+ "event_name": event,
20+ "properties": properties.to_string(),
21+ "hostname": hostname,
22+ "pathname": path,
23+ "user_id": user,
24+ "user_agent": "irongit-server",
25+ });
26+ let event = event.to_string();
27+ tokio::spawn(async move {
28+ let mut url = format!("{}/api/track", rybbit.host.trim_end_matches('/'));
29+ if let Some(key) = &rybbit.api_key {
30+ url.push_str("?api_key=");
31+ url.push_str(key);
32+ }
33+ match http.post(url).json(&body).send().await {
34+ Ok(r) if r.status().is_success() => tracing::debug!(event, "analytics event sent"),
35+ Ok(r) => tracing::warn!(event, status = %r.status(), "analytics event rejected"),
36+ Err(error) => tracing::warn!(event, %error, "analytics event failed"),
37+ }
38+ });
39+}
+11-0backend/src/api/mod.rs
@@ -0,0 +1,11 @@
1+//! JSON API for the `ig` CLI at /api/v1. Stub; filled in by the API work.
2+
3+pub mod release;
4+
5+use axum::Router;
6+
7+use crate::state::AppState;
8+
9+pub fn router() -> Router<AppState> {
10+ Router::new()
11+}
+9-0backend/src/api/release.rs
@@ -0,0 +1,9 @@
1+//! CLI releases stored in R2. Stub.
2+
3+use std::path::Path;
4+
5+use crate::storage::Storage;
6+
7+pub async fn publish(_db: &sqlx::PgPool, _storage: &Storage, _path: &Path, _version: &str, _target: &str) -> anyhow::Result<()> {
8+ anyhow::bail!("not implemented yet")
9+}
+463-0backend/src/auth.rs
@@ -0,0 +1,463 @@
1+//! Who is asking. Browsers carry a session cookie; git, docker, LFS and the
2+//! CLI carry a personal access token (Bearer, or Basic with any username).
3+//! Passwords are only accepted by the sign-in form.
4+
5+use argon2::{
6+ Argon2,
7+ password_hash::{PasswordHasher, PasswordVerifier, phc::PasswordHash},
8+};
9+use axum::{
10+ extract::{FromRequestParts, OptionalFromRequestParts},
11+ http::{HeaderMap, StatusCode, header, request::Parts},
12+ response::{IntoResponse, Redirect, Response},
13+};
14+use axum_extra::extract::cookie::{Cookie, SameSite};
15+use base64::Engine;
16+use chrono::{Duration, Utc};
17+use sha2::{Digest, Sha256};
18+use sqlx::{FromRow, PgPool};
19+
20+use crate::{
21+ error::{ApiError, AppError},
22+ state::AppState,
23+};
24+
25+pub const SESSION_COOKIE: &str = "ig_session";
26+const SESSION_DAYS: i64 = 30;
27+pub const TOKEN_PREFIX: &str = "igp_";
28+
29+/// What a credential may do. Sessions get everything; tokens get what they
30+/// were created with.
31+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32+pub struct Scopes {
33+ pub repo: bool,
34+ pub packages: bool,
35+ pub user: bool,
36+ pub admin: bool,
37+}
38+
39+impl Scopes {
40+ pub const ALL: Scopes = Scopes { repo: true, packages: true, user: true, admin: true };
41+
42+ pub fn from_list(list: &[String]) -> Self {
43+ let has = |s: &str| list.iter().any(|x| x == s);
44+ Scopes { repo: has("repo"), packages: has("packages"), user: has("user"), admin: has("admin") }
45+ }
46+
47+ pub const NAMES: &'static [(&'static str, &'static str)] = &[
48+ ("repo", "Clone, push and manage repositories"),
49+ ("packages", "Push, pull and manage container images"),
50+ ("user", "Manage your profile, SSH keys and tokens"),
51+ ("admin", "Site administration (admins only)"),
52+ ];
53+}
54+
55+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56+pub enum AuthVia {
57+ Session,
58+ Token(i64),
59+}
60+
61+/// The signed-in person making a request.
62+#[derive(Debug, Clone)]
63+pub struct Viewer {
64+ pub id: i64,
65+ pub name: String,
66+ pub is_admin: bool,
67+ pub avatar_key: Option<String>,
68+ pub scopes: Scopes,
69+ pub via: AuthVia,
70+}
71+
72+impl Viewer {
73+ /// Site admin powers, only when the credential carries them.
74+ pub fn site_admin(&self) -> bool {
75+ self.is_admin && self.scopes.admin
76+ }
77+}
78+
79+#[derive(FromRow)]
80+struct ViewerRow {
81+ id: i64,
82+ name: String,
83+ is_admin: bool,
84+ avatar_key: Option<String>,
85+}
86+
87+// ---------------------------------------------------------------------------
88+// Secrets and hashing
89+
90+pub fn sha256_hex(data: impl AsRef<[u8]>) -> String {
91+ hex::encode(Sha256::digest(data.as_ref()))
92+}
93+
94+/// URL-safe random string with `bytes` bytes of entropy.
95+pub fn random_token(bytes: usize) -> String {
96+ let mut buf = vec![0u8; bytes];
97+ rand::fill(&mut buf[..]);
98+ base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(buf)
99+}
100+
101+/// Base62, for tokens people copy around (no '-' or '_' to trip selection).
102+pub fn random_base62(len: usize) -> String {
103+ const ALPHABET: &[u8] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
104+ (0..len).map(|_| ALPHABET[rand::random_range(0..ALPHABET.len())] as char).collect()
105+}
106+
107+pub fn hash_password(password: &str) -> anyhow::Result<String> {
108+ Argon2::default()
109+ .hash_password(password.as_bytes())
110+ .map(|h| h.to_string())
111+ .map_err(|e| anyhow::anyhow!("hashing password: {e}"))
112+}
113+
114+pub fn verify_password(password: &str, hash: &str) -> bool {
115+ match PasswordHash::new(hash) {
116+ Ok(parsed) => Argon2::default().verify_password(password.as_bytes(), &parsed).is_ok(),
117+ Err(_) => false,
118+ }
119+}
120+
121+/// Password checks run on a blocking thread: argon2 takes tens of ms.
122+pub async fn verify_password_async(password: String, hash: String) -> bool {
123+ tokio::task::spawn_blocking(move || verify_password(&password, &hash)).await.unwrap_or(false)
124+}
125+
126+pub async fn hash_password_async(password: String) -> anyhow::Result<String> {
127+ tokio::task::spawn_blocking(move || hash_password(&password)).await?
128+}
129+
130+pub fn validate_password(password: &str) -> Result<(), &'static str> {
131+ if password.chars().count() < 10 {
132+ return Err("Passwords must be at least 10 characters.");
133+ }
134+ if password.len() > 512 {
135+ return Err("That password is too long.");
136+ }
137+ Ok(())
138+}
139+
140+// ---------------------------------------------------------------------------
141+// Sessions
142+
143+pub async fn create_session(db: &PgPool, user_id: i64, ip: Option<&str>, user_agent: Option<&str>) -> anyhow::Result<String> {
144+ let token = random_token(32);
145+ sqlx::query(
146+ "insert into sessions (id_hash, user_id, ip, user_agent, expires_at) values ($1, $2, $3, $4, $5)",
147+ )
148+ .bind(sha256_hex(&token))
149+ .bind(user_id)
150+ .bind(ip)
151+ .bind(user_agent.map(|ua| ua.chars().take(300).collect::<String>()))
152+ .bind(Utc::now() + Duration::days(SESSION_DAYS))
153+ .execute(db)
154+ .await?;
155+ sqlx::query("update users set last_login_at = now() where account_id = $1").bind(user_id).execute(db).await?;
156+ Ok(token)
157+}
158+
159+pub fn session_cookie(token: String, secure: bool) -> Cookie<'static> {
160+ Cookie::build((SESSION_COOKIE, token))
161+ .path("/")
162+ .http_only(true)
163+ .secure(secure)
164+ .same_site(SameSite::Lax)
165+ .max_age(time::Duration::days(SESSION_DAYS))
166+ .build()
167+}
168+
169+pub fn clear_session_cookie() -> Cookie<'static> {
170+ Cookie::build((SESSION_COOKIE, "")).path("/").max_age(time::Duration::ZERO).build()
171+}
172+
173+pub async fn delete_session(db: &PgPool, token: &str) -> anyhow::Result<()> {
174+ sqlx::query("delete from sessions where id_hash = $1").bind(sha256_hex(token)).execute(db).await?;
175+ Ok(())
176+}
177+
178+fn cookie_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
179+ headers
180+ .get_all(header::COOKIE)
181+ .iter()
182+ .filter_map(|v| v.to_str().ok())
183+ .flat_map(|v| v.split(';'))
184+ .filter_map(|pair| pair.trim().split_once('='))
185+ .find(|(k, _)| *k == name)
186+ .map(|(_, v)| v)
187+}
188+
189+pub fn session_token(headers: &HeaderMap) -> Option<&str> {
190+ cookie_value(headers, SESSION_COOKIE).filter(|v| !v.is_empty())
191+}
192+
193+async fn viewer_from_session(db: &PgPool, token: &str) -> anyhow::Result<Option<Viewer>> {
194+ let row: Option<(ViewerRow, chrono::DateTime<Utc>)> = sqlx::query_as::<_, (i64, String, bool, Option<String>, chrono::DateTime<Utc>)>(
195+ "select a.id, a.name::text, u.is_admin, a.avatar_key, s.last_seen_at
196+ from sessions s join users u on u.account_id = s.user_id join accounts a on a.id = u.account_id
197+ where s.id_hash = $1 and s.expires_at > now() and u.suspended_at is null",
198+ )
199+ .bind(sha256_hex(token))
200+ .fetch_optional(db)
201+ .await?
202+ .map(|(id, name, is_admin, avatar_key, seen)| (ViewerRow { id, name, is_admin, avatar_key }, seen));
203+
204+ let Some((row, last_seen)) = row else { return Ok(None) };
205+ // Sliding expiry, written at most every ten minutes.
206+ if Utc::now() - last_seen > Duration::minutes(10) {
207+ sqlx::query("update sessions set last_seen_at = now(), expires_at = $2 where id_hash = $1")
208+ .bind(sha256_hex(token))
209+ .bind(Utc::now() + Duration::days(SESSION_DAYS))
210+ .execute(db)
211+ .await?;
212+ }
213+ Ok(Some(Viewer {
214+ id: row.id,
215+ name: row.name,
216+ is_admin: row.is_admin,
217+ avatar_key: row.avatar_key,
218+ scopes: Scopes::ALL,
219+ via: AuthVia::Session,
220+ }))
221+}
222+
223+// ---------------------------------------------------------------------------
224+// Personal access tokens
225+
226+pub struct NewToken {
227+ pub id: i64,
228+ pub secret: String,
229+}
230+
231+pub async fn create_token(db: &PgPool, user_id: i64, name: &str, scopes: &[&str], expires_at: Option<chrono::DateTime<Utc>>) -> anyhow::Result<NewToken> {
232+ let secret = format!("{TOKEN_PREFIX}{}", random_base62(40));
233+ let id: i64 = sqlx::query_scalar(
234+ "insert into access_tokens (user_id, name, token_hash, token_prefix, scopes, expires_at)
235+ values ($1, $2, $3, $4, $5, $6) returning id",
236+ )
237+ .bind(user_id)
238+ .bind(name)
239+ .bind(sha256_hex(&secret))
240+ .bind(&secret[..TOKEN_PREFIX.len() + 6])
241+ .bind(scopes.iter().map(|s| s.to_string()).collect::<Vec<_>>())
242+ .bind(expires_at)
243+ .fetch_one(db)
244+ .await?;
245+ Ok(NewToken { id, secret })
246+}
247+
248+pub async fn viewer_from_token(db: &PgPool, secret: &str) -> anyhow::Result<Option<Viewer>> {
249+ if !secret.starts_with(TOKEN_PREFIX) {
250+ return Ok(None);
251+ }
252+ let row: Option<(i64, i64, String, bool, Option<String>, Vec<String>, Option<chrono::DateTime<Utc>>)> = sqlx::query_as(
253+ "select t.id, a.id, a.name::text, u.is_admin, a.avatar_key, t.scopes, t.last_used_at
254+ from access_tokens t join users u on u.account_id = t.user_id join accounts a on a.id = u.account_id
255+ where t.token_hash = $1 and (t.expires_at is null or t.expires_at > now()) and u.suspended_at is null",
256+ )
257+ .bind(sha256_hex(secret))
258+ .fetch_optional(db)
259+ .await?;
260+ let Some((token_id, id, name, is_admin, avatar_key, scopes, last_used)) = row else { return Ok(None) };
261+ if last_used.is_none_or(|t| Utc::now() - t > Duration::minutes(5)) {
262+ sqlx::query("update access_tokens set last_used_at = now() where id = $1").bind(token_id).execute(db).await?;
263+ }
264+ let mut scopes = Scopes::from_list(&scopes);
265+ scopes.admin &= is_admin;
266+ Ok(Some(Viewer { id, name, is_admin, avatar_key, scopes, via: AuthVia::Token(token_id) }))
267+}
268+
269+/// Result of reading an Authorization header.
270+pub enum HeaderAuth {
271+ /// No credentials were sent.
272+ Anonymous,
273+ /// Credentials were sent but are wrong, expired or revoked.
274+ Invalid,
275+ Viewer(Viewer),
276+}
277+
278+/// Accepts `Bearer igp_...`, `token igp_...` and `Basic base64(any:igp_...)`.
279+pub async fn authenticate_header(db: &PgPool, headers: &HeaderMap) -> anyhow::Result<HeaderAuth> {
280+ let Some(value) = headers.get(header::AUTHORIZATION).and_then(|v| v.to_str().ok()) else {
281+ return Ok(HeaderAuth::Anonymous);
282+ };
283+ let (scheme, rest) = value.split_once(' ').unwrap_or((value, ""));
284+ let secret = match scheme.to_ascii_lowercase().as_str() {
285+ "bearer" | "token" => rest.trim().to_string(),
286+ "basic" => match basic_credentials(rest.trim()) {
287+ Some((_, password)) => password,
288+ None => return Ok(HeaderAuth::Invalid),
289+ },
290+ _ => return Ok(HeaderAuth::Invalid),
291+ };
292+ Ok(match viewer_from_token(db, &secret).await? {
293+ Some(viewer) => HeaderAuth::Viewer(viewer),
294+ None => HeaderAuth::Invalid,
295+ })
296+}
297+
298+/// Decodes a Basic credential into (username, password).
299+pub fn basic_credentials(encoded: &str) -> Option<(String, String)> {
300+ let decoded = base64::engine::general_purpose::STANDARD.decode(encoded).ok()?;
301+ let text = String::from_utf8(decoded).ok()?;
302+ let (user, pass) = text.split_once(':')?;
303+ Some((user.to_string(), pass.to_string()))
304+}
305+
306+// ---------------------------------------------------------------------------
307+// Extractors
308+
309+/// The signed-in browser user, if any. Never rejects.
310+pub struct MaybeViewer(pub Option<Viewer>);
311+
312+impl FromRequestParts<AppState> for MaybeViewer {
313+ type Rejection = AppError;
314+
315+ async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
316+ if let Some(viewer) = parts.extensions.get::<Option<Viewer>>() {
317+ return Ok(MaybeViewer(viewer.clone()));
318+ }
319+ let viewer = match session_token(&parts.headers) {
320+ Some(token) => viewer_from_session(&state.db, token).await?,
321+ None => None,
322+ };
323+ parts.extensions.insert(viewer.clone());
324+ Ok(MaybeViewer(viewer))
325+ }
326+}
327+
328+/// A page that needs a signed-in user. Redirects to /login?next=... otherwise.
329+pub struct RequireViewer(pub Viewer);
330+
331+pub struct LoginRedirect(String);
332+
333+impl IntoResponse for LoginRedirect {
334+ fn into_response(self) -> Response {
335+ Redirect::to(&format!("/login?next={}", urlencode(&self.0))).into_response()
336+ }
337+}
338+
339+impl FromRequestParts<AppState> for RequireViewer {
340+ type Rejection = Response;
341+
342+ async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
343+ let MaybeViewer(viewer) = MaybeViewer::from_request_parts(parts, state).await.map_err(IntoResponse::into_response)?;
344+ match viewer {
345+ Some(viewer) => Ok(RequireViewer(viewer)),
346+ None => {
347+ let next = parts.uri.path_and_query().map(|p| p.as_str()).unwrap_or("/").to_string();
348+ Err(LoginRedirect(next).into_response())
349+ }
350+ }
351+ }
352+}
353+
354+/// API caller: a token in the Authorization header, or a browser session.
355+pub struct ApiViewer(pub Viewer);
356+
357+impl FromRequestParts<AppState> for ApiViewer {
358+ type Rejection = ApiError;
359+
360+ async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
361+ match <ApiViewer as OptionalFromRequestParts<AppState>>::from_request_parts(parts, state).await? {
362+ Some(viewer) => Ok(viewer),
363+ None => Err(ApiError(AppError::Unauthorized)),
364+ }
365+ }
366+}
367+
368+impl OptionalFromRequestParts<AppState> for ApiViewer {
369+ type Rejection = ApiError;
370+
371+ async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Option<Self>, Self::Rejection> {
372+ match authenticate_header(&state.db, &parts.headers).await? {
373+ HeaderAuth::Viewer(viewer) => Ok(Some(ApiViewer(viewer))),
374+ HeaderAuth::Invalid => Err(ApiError(AppError::Unauthorized)),
375+ HeaderAuth::Anonymous => {
376+ let MaybeViewer(viewer) = MaybeViewer::from_request_parts(parts, state).await?;
377+ Ok(viewer.map(ApiViewer))
378+ }
379+ }
380+ }
381+}
382+
383+/// The site admin panel. 404s for everyone else so it is not discoverable.
384+pub struct AdminViewer(pub Viewer);
385+
386+impl FromRequestParts<AppState> for AdminViewer {
387+ type Rejection = Response;
388+
389+ async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
390+ let RequireViewer(viewer) = RequireViewer::from_request_parts(parts, state).await?;
391+ if viewer.site_admin() {
392+ Ok(AdminViewer(viewer))
393+ } else {
394+ Err(AppError::NotFound.into_response())
395+ }
396+ }
397+}
398+
399+/// Client IP as seen through a reverse proxy, for logs and the audit trail.
400+pub fn client_ip(headers: &HeaderMap) -> Option<String> {
401+ for name in ["cf-connecting-ip", "x-real-ip"] {
402+ if let Some(v) = headers.get(name).and_then(|v| v.to_str().ok()) {
403+ return Some(v.trim().to_string());
404+ }
405+ }
406+ headers
407+ .get("x-forwarded-for")
408+ .and_then(|v| v.to_str().ok())
409+ .and_then(|v| v.split(',').next())
410+ .map(|v| v.trim().to_string())
411+}
412+
413+pub fn user_agent(headers: &HeaderMap) -> Option<&str> {
414+ headers.get(header::USER_AGENT).and_then(|v| v.to_str().ok())
415+}
416+
417+pub fn urlencode(s: &str) -> String {
418+ url::form_urlencoded::byte_serialize(s.as_bytes()).collect()
419+}
420+
421+/// Only same-site relative paths are allowed as post-login destinations.
422+pub fn safe_next(next: Option<&str>) -> String {
423+ match next {
424+ Some(n) if n.starts_with('/') && !n.starts_with("//") && !n.starts_with("/\\") => n.to_string(),
425+ _ => "/".to_string(),
426+ }
427+}
428+
429+/// A 401 that makes git and docker prompt for (or send) credentials.
430+pub fn basic_challenge(message: &str) -> Response {
431+ (
432+ StatusCode::UNAUTHORIZED,
433+ [(header::WWW_AUTHENTICATE, "Basic realm=\"irongit\"")],
434+ format!("{message}\n"),
435+ )
436+ .into_response()
437+}
438+
439+#[cfg(test)]
440+mod tests {
441+ use super::*;
442+
443+ #[test]
444+ fn password_roundtrip() {
445+ let hash = hash_password("correct horse battery").unwrap();
446+ assert!(verify_password("correct horse battery", &hash));
447+ assert!(!verify_password("wrong", &hash));
448+ }
449+
450+ #[test]
451+ fn basic_parsing() {
452+ let encoded = base64::engine::general_purpose::STANDARD.encode("alice:igp_abc");
453+ assert_eq!(basic_credentials(&encoded), Some(("alice".into(), "igp_abc".into())));
454+ }
455+
456+ #[test]
457+ fn next_is_local_only() {
458+ assert_eq!(safe_next(Some("/a/b?c")), "/a/b?c");
459+ assert_eq!(safe_next(Some("//evil.com")), "/");
460+ assert_eq!(safe_next(Some("https://evil.com")), "/");
461+ assert_eq!(safe_next(None), "/");
462+ }
463+}
+5-0backend/src/backup.rs
@@ -0,0 +1,5 @@
1+//! Nightly backups of repositories and Postgres to R2. Stub.
2+
3+use crate::state::AppState;
4+
5+pub fn spawn(_state: AppState) {}
+51-0backend/src/clientlog.rs
@@ -0,0 +1,51 @@
1+//! Browser logs. The page script batches console errors, uncaught
2+//! exceptions and failed fetches to POST /api/v1/client-logs; they are
3+//! written to LOG_DIR/frontend.<date>.log with the user and page.
4+
5+use axum::{
6+ Json, Router,
7+ extract::DefaultBodyLimit,
8+ http::{HeaderMap, StatusCode},
9+ routing::post,
10+};
11+use serde::Deserialize;
12+
13+use crate::{
14+ auth::{self, MaybeViewer},
15+ logging::FRONTEND_TARGET,
16+ state::AppState,
17+};
18+
19+pub fn router() -> Router<AppState> {
20+ Router::new().route("/api/v1/client-logs", post(ingest).layer(DefaultBodyLimit::max(64 * 1024)))
21+}
22+
23+#[derive(Deserialize)]
24+struct Batch {
25+ entries: Vec<Entry>,
26+}
27+
28+#[derive(Deserialize)]
29+struct Entry {
30+ level: String,
31+ message: String,
32+ url: String,
33+ at: Option<String>,
34+ stack: Option<String>,
35+}
36+
37+async fn ingest(MaybeViewer(viewer): MaybeViewer, headers: HeaderMap, Json(batch): Json<Batch>) -> StatusCode {
38+ let user = viewer.as_ref().map(|v| v.name.as_str()).unwrap_or("-");
39+ let agent = auth::user_agent(&headers).unwrap_or("-");
40+ for entry in batch.entries.into_iter().take(50) {
41+ let message: String = entry.message.chars().take(2000).collect();
42+ let stack: String = entry.stack.unwrap_or_default().chars().take(4000).collect();
43+ let at = entry.at.unwrap_or_default();
44+ match entry.level.as_str() {
45+ "error" => tracing::error!(target: FRONTEND_TARGET, user, url = %entry.url, at, agent, stack, "{message}"),
46+ "warn" => tracing::warn!(target: FRONTEND_TARGET, user, url = %entry.url, at, agent, "{message}"),
47+ _ => tracing::info!(target: FRONTEND_TARGET, user, url = %entry.url, at, agent, "{message}"),
48+ }
49+ }
50+ StatusCode::NO_CONTENT
51+}
+218-0backend/src/config.rs
@@ -0,0 +1,218 @@
1+//! Configuration from the environment (and `.env` in development).
2+
3+use std::path::PathBuf;
4+
5+use anyhow::Context;
6+use url::Url;
7+
8+#[derive(Clone, Debug)]
9+pub struct Config {
10+ pub host: String,
11+ pub port: u16,
12+ pub ssh_port: u16,
13+ /// How people reach the site, e.g. "https://git.example.com". Used for
14+ /// clone URLs, the registry token realm, LFS hrefs and cookie security.
15+ pub public_url: Url,
16+ /// Host (and port, if not 22) shown in SSH clone URLs.
17+ pub ssh_host: String,
18+ pub database_url: String,
19+ pub data_dir: PathBuf,
20+ pub log_dir: PathBuf,
21+ /// HMAC key for registry and LFS bearer tokens.
22+ pub secret_key: Vec<u8>,
23+ pub r2: R2Config,
24+ pub registration_open: bool,
25+ pub limits: Limits,
26+ pub smtp: Option<SmtpConfig>,
27+ pub rybbit: Option<RybbitConfig>,
28+ pub google: Option<GoogleConfig>,
29+ pub backup_interval_hours: u64,
30+}
31+
32+/// Google sign-in (OpenID Connect). Redirect URI: {PUBLIC_URL}/login/google/callback
33+#[derive(Clone, Debug)]
34+pub struct GoogleConfig {
35+ pub client_id: String,
36+ pub client_secret: String,
37+}
38+
39+#[derive(Clone, Debug)]
40+pub struct R2Config {
41+ pub endpoint: String,
42+ pub bucket: String,
43+ pub access_key_id: String,
44+ pub secret_access_key: String,
45+}
46+
47+#[derive(Clone, Debug)]
48+pub struct Limits {
49+ /// Largest single file a git push may contain. Bigger files belong in LFS.
50+ pub max_file_bytes: u64,
51+ /// Default storage per account across its repos (git on disk).
52+ pub account_quota_bytes: u64,
53+ pub max_lfs_object_bytes: u64,
54+ pub max_image_layer_bytes: u64,
55+ pub max_manifest_bytes: usize,
56+}
57+
58+#[derive(Clone, Debug)]
59+pub struct SmtpConfig {
60+ pub host: String,
61+ pub port: u16,
62+ pub username: String,
63+ pub password: String,
64+ pub from: String,
65+}
66+
67+#[derive(Clone, Debug)]
68+pub struct RybbitConfig {
69+ pub host: String,
70+ pub site_id: String,
71+ pub api_key: Option<String>,
72+}
73+
74+const MB: u64 = 1024 * 1024;
75+
76+impl Config {
77+ pub fn from_env() -> anyhow::Result<Self> {
78+ let port: u16 = var("PORT").map(|v| v.parse()).transpose()?.unwrap_or(7878);
79+ let public_url: Url = var("PUBLIC_URL")
80+ .unwrap_or_else(|| format!("http://localhost:{port}"))
81+ .parse()
82+ .context("PUBLIC_URL is not a valid URL")?;
83+ let ssh_port: u16 = var("SSH_PORT").map(|v| v.parse()).transpose()?.unwrap_or(2222);
84+ let ssh_host = var("SSH_HOST").unwrap_or_else(|| {
85+ let host = public_url.host_str().unwrap_or("localhost").to_string();
86+ if ssh_port == 22 { host } else { format!("{host}:{ssh_port}") }
87+ });
88+
89+ let data_dir = PathBuf::from(var("DATA_DIR").unwrap_or_else(|| "data".into()));
90+ std::fs::create_dir_all(&data_dir).with_context(|| format!("cannot create {}", data_dir.display()))?;
91+ let data_dir = data_dir.canonicalize()?;
92+ let log_dir = var("LOG_DIR").map(PathBuf::from).unwrap_or_else(|| data_dir.join("logs"));
93+
94+ let r2_account = var("R2_ACCOUNT_ID");
95+ let r2 = R2Config {
96+ endpoint: var("R2_ENDPOINT")
97+ .or_else(|| r2_account.map(|id| format!("https://{id}.r2.cloudflarestorage.com")))
98+ .context("set R2_ACCOUNT_ID (or R2_ENDPOINT)")?,
99+ bucket: var("R2_BUCKET").context("set R2_BUCKET")?,
100+ access_key_id: var("R2_ACCESS_KEY_ID").context("set R2_ACCESS_KEY_ID")?,
101+ secret_access_key: var("R2_SECRET_ACCESS_KEY").context("set R2_SECRET_ACCESS_KEY")?,
102+ };
103+
104+ let smtp = match (var("SMTP_HOST"), var("SMTP_USERNAME"), var("SMTP_PASSWORD")) {
105+ (Some(host), Some(username), Some(password)) => Some(SmtpConfig {
106+ port: var("SMTP_PORT").map(|v| v.parse()).transpose()?.unwrap_or(465),
107+ from: var("SMTP_FROM").unwrap_or_else(|| username.clone()),
108+ host,
109+ username,
110+ password,
111+ }),
112+ _ => None,
113+ };
114+
115+ let rybbit = var("RYBBIT_SITE_ID").map(|site_id| RybbitConfig {
116+ host: var("RYBBIT_HOST").unwrap_or_else(|| "https://a.hygo.ai".into()),
117+ site_id,
118+ api_key: var("RYBBIT_API_KEY"),
119+ });
120+
121+ let google = match (var("GOOGLE_CLIENT_ID"), var("GOOGLE_CLIENT_SECRET")) {
122+ (Some(client_id), Some(client_secret)) => Some(GoogleConfig { client_id, client_secret }),
123+ _ => None,
124+ };
125+
126+ Ok(Self {
127+ host: var("HOST").unwrap_or_else(|| "127.0.0.1".into()),
128+ port,
129+ ssh_port,
130+ public_url,
131+ ssh_host,
132+ database_url: var("DATABASE_URL").context("set DATABASE_URL")?,
133+ secret_key: load_secret_key(&data_dir)?,
134+ data_dir,
135+ log_dir,
136+ r2,
137+ registration_open: flag("REGISTRATION_OPEN", true),
138+ limits: Limits {
139+ max_file_bytes: mb("MAX_FILE_MB", 50)?,
140+ account_quota_bytes: mb("ACCOUNT_QUOTA_MB", 5 * 1024)?,
141+ max_lfs_object_bytes: mb("MAX_LFS_OBJECT_MB", 2 * 1024)?,
142+ max_image_layer_bytes: mb("MAX_IMAGE_LAYER_MB", 10 * 1024)?,
143+ max_manifest_bytes: 4 * 1024 * 1024,
144+ },
145+ smtp,
146+ rybbit,
147+ google,
148+ backup_interval_hours: var("BACKUP_INTERVAL_HOURS").map(|v| v.parse()).transpose()?.unwrap_or(24),
149+ })
150+ }
151+
152+ pub fn repos_dir(&self) -> PathBuf {
153+ self.data_dir.join("repos")
154+ }
155+
156+ pub fn uploads_dir(&self) -> PathBuf {
157+ self.data_dir.join("uploads")
158+ }
159+
160+ pub fn hooks_dir(&self) -> PathBuf {
161+ self.data_dir.join("hooks")
162+ }
163+
164+ /// "https://git.example.com" with no trailing slash.
165+ pub fn base_url(&self) -> String {
166+ self.public_url.as_str().trim_end_matches('/').to_string()
167+ }
168+
169+ /// "git.example.com" or "localhost:8080": what goes in `docker pull`.
170+ pub fn registry_host(&self) -> String {
171+ let host = self.public_url.host_str().unwrap_or("localhost");
172+ match self.public_url.port() {
173+ Some(port) => format!("{host}:{port}"),
174+ None => host.to_string(),
175+ }
176+ }
177+
178+ pub fn secure_cookies(&self) -> bool {
179+ self.public_url.scheme() == "https"
180+ }
181+}
182+
183+/// SECRET_KEY (hex) if set; otherwise a random key persisted in the data dir,
184+/// so development needs no setup and tokens survive restarts.
185+fn load_secret_key(data_dir: &std::path::Path) -> anyhow::Result<Vec<u8>> {
186+ if let Some(hex_key) = var("SECRET_KEY") {
187+ let key = hex::decode(hex_key).context("SECRET_KEY must be hex")?;
188+ anyhow::ensure!(key.len() >= 32, "SECRET_KEY must be at least 32 bytes (64 hex chars)");
189+ return Ok(key);
190+ }
191+ let path = data_dir.join("secret.key");
192+ if let Ok(existing) = std::fs::read_to_string(&path) {
193+ return hex::decode(existing.trim()).context("data/secret.key is corrupt");
194+ }
195+ let key: [u8; 32] = rand::random();
196+ std::fs::write(&path, hex::encode(key))?;
197+ #[cfg(unix)]
198+ {
199+ use std::os::unix::fs::PermissionsExt;
200+ std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
201+ }
202+ Ok(key.to_vec())
203+}
204+
205+pub fn var(name: &str) -> Option<String> {
206+ std::env::var(name).ok().map(|v| v.trim().to_string()).filter(|v| !v.is_empty())
207+}
208+
209+pub fn flag(name: &str, default: bool) -> bool {
210+ match var(name) {
211+ Some(v) => !matches!(v.to_ascii_lowercase().as_str(), "0" | "false" | "no" | "off"),
212+ None => default,
213+ }
214+}
215+
216+fn mb(name: &str, default: u64) -> anyhow::Result<u64> {
217+ Ok(var(name).map(|v| v.parse::<u64>()).transpose().with_context(|| format!("{name} must be a number"))?.unwrap_or(default) * MB)
218+}
+122-0backend/src/dev.rs
@@ -0,0 +1,122 @@
1+//! Hot reload, debug builds only.
2+//!
3+//! One `bun run build` at startup, another whenever a source file under
4+//! `frontend/` changes, and a `reload` frame to every browser holding the
5+//! `/dev-ws` socket afterwards. The page only opens that socket when it was
6+//! built with `PUBLIC_HOT_RELOAD=true`, which this module sets, so a
7+//! production build never carries the client script.
8+
9+use std::{path::PathBuf, process::Command, sync::mpsc, time::Duration};
10+
11+use axum::{
12+ extract::{
13+ State, WebSocketUpgrade,
14+ ws::{Message, WebSocket},
15+ },
16+ response::IntoResponse,
17+};
18+use notify::{RecursiveMode, Watcher, recommended_watcher};
19+use tokio::sync::broadcast::{self, Sender};
20+
21+use crate::AppState;
22+
23+fn frontend_dir() -> PathBuf {
24+ PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../frontend")
25+}
26+
27+fn build_frontend(reload_tx: &Sender<()>) -> anyhow::Result<()> {
28+ tracing::info!("building frontend");
29+ let status = Command::new("bun")
30+ .args(["run", "build"])
31+ .env("PUBLIC_HOT_RELOAD", "true")
32+ .current_dir(frontend_dir())
33+ .status()?;
34+
35+ if status.success() {
36+ let browsers = reload_tx.send(()).unwrap_or_default();
37+ tracing::info!(browsers, "frontend built; reloading browsers");
38+ } else {
39+ // Leave the last good build in place and wait for the next edit.
40+ tracing::error!("frontend build failed");
41+ }
42+ Ok(())
43+}
44+
45+pub fn watch_frontend(reload_tx: Sender<()>) -> anyhow::Result<()> {
46+ build_frontend(&reload_tx)?;
47+
48+ let debounce = Duration::from_millis(
49+ std::env::var("DEBOUNCE_MILLIS")
50+ .ok()
51+ .and_then(|v| v.parse().ok())
52+ .unwrap_or(500),
53+ );
54+
55+ std::thread::spawn(move || {
56+ let (event_tx, event_rx) = mpsc::channel();
57+ let mut watcher =
58+ recommended_watcher(move |event| { let _ = event_tx.send(event); })
59+ .expect("failed to create frontend watcher");
60+ watcher
61+ .watch(&frontend_dir(), RecursiveMode::Recursive)
62+ .expect("failed to watch frontend directory");
63+
64+ while let Ok(event) = event_rx.recv() {
65+ let Ok(event) = event else { continue };
66+ if !is_source_change(&event) {
67+ continue;
68+ }
69+
70+ // Wait for the editor to finish its burst of writes. Trailing
71+ // edge, so the last event can never be dropped.
72+ while event_rx.recv_timeout(debounce).is_ok() {}
73+
74+ if let Err(error) = build_frontend(&reload_tx) {
75+ tracing::error!(%error, "could not build frontend");
76+ }
77+ }
78+ });
79+
80+ Ok(())
81+}
82+
83+/// Ignores the build's own output, so a rebuild cannot trigger another one.
84+fn is_source_change(event: &notify::Event) -> bool {
85+ use notify::EventKind;
86+ matches!(
87+ event.kind,
88+ EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
89+ ) && event.paths.iter().any(|path| {
90+ !path.components().any(|part| {
91+ matches!(
92+ part.as_os_str().to_str(),
93+ Some("dist" | "node_modules" | ".astro" | ".git")
94+ )
95+ })
96+ })
97+}
98+
99+pub async fn ws_handler(ws: WebSocketUpgrade, State(state): State<AppState>) -> impl IntoResponse {
100+ let reload_rx = state.reload_tx.subscribe();
101+ ws.on_upgrade(move |socket| handle_socket(socket, reload_rx))
102+}
103+
104+async fn handle_socket(mut socket: WebSocket, mut reload_rx: broadcast::Receiver<()>) {
105+ loop {
106+ tokio::select! {
107+ message = socket.recv() => match message {
108+ Some(Ok(Message::Ping(payload))) => {
109+ if socket.send(Message::Pong(payload)).await.is_err() { break; }
110+ }
111+ Some(Ok(Message::Close(_))) | Some(Err(_)) | None => break,
112+ _ => {}
113+ },
114+ reload = reload_rx.recv() => match reload {
115+ Ok(()) | Err(broadcast::error::RecvError::Lagged(_)) => {
116+ if socket.send(Message::Text("reload".into())).await.is_err() { break; }
117+ }
118+ Err(broadcast::error::RecvError::Closed) => break,
119+ }
120+ }
121+ }
122+}
+134-0backend/src/error.rs
@@ -0,0 +1,134 @@
1+//! Error types. `AppError` renders an HTML page for the web UI; `ApiError`
2+//! renders JSON for `/api/v1`. The git, registry and LFS protocols have their
3+//! own wire formats and convert from these where they need to.
4+
5+use axum::{
6+ Json,
7+ http::{HeaderValue, StatusCode, header},
8+ response::{IntoResponse, Response},
9+};
10+use serde_json::json;
11+
12+#[derive(Debug)]
13+pub enum AppError {
14+ NotFound,
15+ /// Not signed in: the web UI redirects to /login, APIs answer 401.
16+ Unauthorized,
17+ Forbidden(String),
18+ BadRequest(String),
19+ Conflict(String),
20+ TooLarge(String),
21+ Internal(anyhow::Error),
22+}
23+
24+pub type AppResult<T> = Result<T, AppError>;
25+
26+impl AppError {
27+ pub fn bad(message: impl Into<String>) -> Self {
28+ Self::BadRequest(message.into())
29+ }
30+
31+ pub fn forbidden(message: impl Into<String>) -> Self {
32+ Self::Forbidden(message.into())
33+ }
34+
35+ pub fn conflict(message: impl Into<String>) -> Self {
36+ Self::Conflict(message.into())
37+ }
38+
39+ pub fn status(&self) -> StatusCode {
40+ match self {
41+ Self::NotFound => StatusCode::NOT_FOUND,
42+ Self::Unauthorized => StatusCode::UNAUTHORIZED,
43+ Self::Forbidden(_) => StatusCode::FORBIDDEN,
44+ Self::BadRequest(_) => StatusCode::BAD_REQUEST,
45+ Self::Conflict(_) => StatusCode::CONFLICT,
46+ Self::TooLarge(_) => StatusCode::PAYLOAD_TOO_LARGE,
47+ Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
48+ }
49+ }
50+
51+ /// What it is safe to show the person. Internal details only go to logs.
52+ pub fn public_message(&self) -> String {
53+ match self {
54+ Self::NotFound => "Not found".into(),
55+ Self::Unauthorized => "Sign in required".into(),
56+ Self::Forbidden(m) | Self::BadRequest(m) | Self::Conflict(m) | Self::TooLarge(m) => m.clone(),
57+ Self::Internal(_) => "Something went wrong on our side. It has been logged.".into(),
58+ }
59+ }
60+
61+ fn log(&self) {
62+ if let Self::Internal(error) = self {
63+ tracing::error!(error = ?error, "internal error");
64+ }
65+ }
66+}
67+
68+impl std::fmt::Display for AppError {
69+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70+ match self {
71+ Self::Internal(e) => write!(f, "{e:#}"),
72+ other => f.write_str(&other.public_message()),
73+ }
74+ }
75+}
76+
77+impl From<anyhow::Error> for AppError {
78+ fn from(error: anyhow::Error) -> Self {
79+ Self::Internal(error)
80+ }
81+}
82+
83+impl From<sqlx::Error> for AppError {
84+ fn from(error: sqlx::Error) -> Self {
85+ match error {
86+ sqlx::Error::RowNotFound => Self::NotFound,
87+ other => Self::Internal(other.into()),
88+ }
89+ }
90+}
91+
92+impl From<std::io::Error> for AppError {
93+ fn from(error: std::io::Error) -> Self {
94+ Self::Internal(error.into())
95+ }
96+}
97+
98+impl IntoResponse for AppError {
99+ fn into_response(self) -> Response {
100+ self.log();
101+ if matches!(self, Self::Unauthorized) {
102+ // Pages that need a session send people to sign in. The original
103+ // path is attached by the extractor that raised this, when known.
104+ return (StatusCode::SEE_OTHER, [(header::LOCATION, HeaderValue::from_static("/login"))]).into_response();
105+ }
106+ crate::web::layout::error_page(self.status(), &self.public_message())
107+ }
108+}
109+
110+/// JSON errors for the CLI-facing API: `{"error": "...", "status": 404}`.
111+#[derive(Debug)]
112+pub struct ApiError(pub AppError);
113+
114+pub type ApiResult<T> = Result<T, ApiError>;
115+
116+impl<E: Into<AppError>> From<E> for ApiError {
117+ fn from(error: E) -> Self {
118+ Self(error.into())
119+ }
120+}
121+
122+impl IntoResponse for ApiError {
123+ fn into_response(self) -> Response {
124+ self.0.log();
125+ let status = self.0.status();
126+ let mut response = (status, Json(json!({ "error": self.0.public_message(), "status": status.as_u16() }))).into_response();
127+ if status == StatusCode::UNAUTHORIZED {
128+ response
129+ .headers_mut()
130+ .insert(header::WWW_AUTHENTICATE, HeaderValue::from_static("Bearer realm=\"irongit\""));
131+ }
132+ response
133+ }
134+}
+125-0backend/src/frontend.rs
@@ -0,0 +1,125 @@
1+//! Serving the Astro build.
2+//!
3+//! Debug builds read `frontend/dist` from disk, so a rebuild shows up without
4+//! recompiling the server. Release builds embed the same directory through
5+//! `rust-embed`. Both resolve paths identically, so a URL that works locally
6+//! works in production.
7+
8+use axum::{
9+ extract::State,
10+ http::{StatusCode, Uri, header},
11+ response::{IntoResponse, Response},
12+};
13+use rust_embed::Embed;
14+
15+use crate::{auth::MaybeViewer, state::AppState, web::layout};
16+
17+#[derive(Embed)]
18+#[folder = "../frontend/dist"]
19+// Only read in release builds, but derived in both so a missing or empty
20+// frontend/dist fails at the first `cargo build`, not at release time.
21+#[cfg_attr(debug_assertions, allow(dead_code))]
22+struct Dist;
23+
24+/// Static files and Astro pages. HTML gets the shared head and the nav for
25+/// whoever is signed in.
26+pub async fn serve(State(state): State<AppState>, MaybeViewer(viewer): MaybeViewer, uri: Uri) -> Response {
27+ let path = uri.path().trim_matches('/');
28+ let path = if path.is_empty() { "index.html" } else { path };
29+ // The app shell is a template, not a page.
30+ let found = if path.starts_with("app-shell") { None } else { load(path).await };
31+
32+ let (status, body, served) = match found {
33+ Some((body, served)) => (StatusCode::OK, body, served),
34+ None => match load("404.html").await {
35+ Some((body, served)) => (StatusCode::NOT_FOUND, body, served),
36+ None => return (StatusCode::NOT_FOUND, "Not Found").into_response(),
37+ },
38+ };
39+ if served.ends_with(".html") {
40+ let html = String::from_utf8_lossy(&body);
41+ let filled = layout::inject(&state, viewer.as_ref(), uri.path(), &html);
42+ let mut response = respond(status, filled.into_bytes(), &served);
43+ response.headers_mut().insert(header::CACHE_CONTROL, header::HeaderValue::from_static("no-store"));
44+ return response;
45+ }
46+ respond(status, body, &served)
47+}
48+
49+/// The Astro-built page template the Rust-rendered pages are poured into.
50+/// Read from disk on every call in debug builds so frontend edits show up.
51+pub fn shell() -> Option<String> {
52+ #[cfg(debug_assertions)]
53+ {
54+ std::fs::read_to_string(dist_dir().join("app-shell/index.html")).ok()
55+ }
56+ #[cfg(not(debug_assertions))]
57+ {
58+ static SHELL: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
59+ SHELL
60+ .get_or_init(|| Dist::get("app-shell/index.html").map(|f| String::from_utf8_lossy(&f.data).into_owned()))
61+ .clone()
62+ }
63+}
64+
65+/// Tries the path itself, then `.html`, then `/index.html`, so `/about`,
66+/// `/about/` and `/about.html` all reach `dist/about/index.html`.
67+async fn load(path: &str) -> Option<(Vec<u8>, String)> {
68+ for candidate in [
69+ path.to_string(),
70+ format!("{path}.html"),
71+ format!("{path}/index.html"),
72+ ] {
73+ if let Some(body) = read(&candidate).await {
74+ return Some((body, candidate));
75+ }
76+ }
77+ None
78+}
79+
80+#[cfg(debug_assertions)]
81+async fn read(path: &str) -> Option<Vec<u8>> {
82+ use std::path::{Component, Path};
83+
84+ // Nothing may escape dist/ (`..`, absolute paths, Windows prefixes).
85+ let relative = Path::new(path);
86+ if relative
87+ .components()
88+ .any(|part| !matches!(part, Component::Normal(_)))
89+ {
90+ return None;
91+ }
92+ tokio::fs::read(dist_dir().join(relative)).await.ok()
93+}
94+
95+#[cfg(not(debug_assertions))]
96+async fn read(path: &str) -> Option<Vec<u8>> {
97+ Dist::get(path).map(|file| file.data.into_owned())
98+}
99+
100+#[cfg(debug_assertions)]
101+pub fn dist_dir() -> std::path::PathBuf {
102+ std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../frontend/dist")
103+}
104+
105+fn respond(status: StatusCode, body: Vec<u8>, path: &str) -> Response {
106+ let mime = mime_guess::from_path(path).first_or_octet_stream();
107+ // Never cache while developing; the browser reloads on every rebuild.
108+ // Astro fingerprints everything under _astro/, so it can be cached forever.
109+ let cache = if cfg!(debug_assertions) {
110+ "no-store"
111+ } else if path.starts_with("_astro/") {
112+ "public, max-age=31536000, immutable"
113+ } else {
114+ "public, max-age=3600"
115+ };
116+ (
117+ status,
118+ [
119+ (header::CONTENT_TYPE, mime.as_ref()),
120+ (header::CACHE_CONTROL, cache),
121+ ],
122+ body,
123+ )
124+ .into_response()
125+}
+452-0backend/src/git.rs
@@ -0,0 +1,452 @@
1+//! Reading and managing bare repositories through the system `git`.
2+//!
3+//! The git binary is the reference implementation of every edge case (pack
4+//! formats, protocol v2, fsck), so transport and browsing both go through it.
5+//! Every call passes revisions after `--end-of-options` and paths after `--`,
6+//! so user-controlled names can never be read as flags.
7+
8+use std::{
9+ path::{Path, PathBuf},
10+ process::Stdio,
11+};
12+
13+use anyhow::{Context, bail};
14+use chrono::{DateTime, TimeZone, Utc};
15+use tokio::process::Command;
16+
17+pub const ZERO_SHA: &str = "0000000000000000000000000000000000000000";
18+
19+/// A `git` command isolated from the host's user and system config.
20+pub fn git_command(git_dir: &Path) -> Command {
21+ let mut command = Command::new("git");
22+ command
23+ .arg("--git-dir")
24+ .arg(git_dir)
25+ .env("GIT_CONFIG_NOSYSTEM", "1")
26+ .env("GIT_CONFIG_GLOBAL", "/dev/null")
27+ .env("GIT_TERMINAL_PROMPT", "0")
28+ .env("LC_ALL", "C")
29+ .env_remove("GIT_DIR")
30+ .env_remove("GIT_WORK_TREE")
31+ .stdin(Stdio::null())
32+ .kill_on_drop(true);
33+ command
34+}
35+
36+#[derive(Clone, Debug)]
37+pub struct Git {
38+ pub path: PathBuf,
39+}
40+
41+#[derive(Clone, Debug)]
42+pub struct RefInfo {
43+ /// Full name, e.g. "refs/heads/main".
44+ pub name: String,
45+ pub sha: String,
46+ /// Peeled commit for annotated tags, otherwise the same as `sha`.
47+ pub commit: String,
48+ pub committed_at: Option<DateTime<Utc>>,
49+ pub subject: String,
50+}
51+
52+impl RefInfo {
53+ pub fn short_name(&self) -> &str {
54+ self.name
55+ .strip_prefix("refs/heads/")
56+ .or_else(|| self.name.strip_prefix("refs/tags/"))
57+ .unwrap_or(&self.name)
58+ }
59+
60+ pub fn is_branch(&self) -> bool {
61+ self.name.starts_with("refs/heads/")
62+ }
63+
64+ pub fn is_tag(&self) -> bool {
65+ self.name.starts_with("refs/tags/")
66+ }
67+}
68+
69+#[derive(Clone, Debug)]
70+pub struct TreeEntry {
71+ pub mode: String,
72+ /// "blob", "tree" or "commit" (a submodule).
73+ pub kind: String,
74+ pub sha: String,
75+ pub size: Option<u64>,
76+ pub name: String,
77+}
78+
79+impl TreeEntry {
80+ pub fn is_dir(&self) -> bool {
81+ self.kind == "tree"
82+ }
83+
84+ pub fn is_symlink(&self) -> bool {
85+ self.mode == "120000"
86+ }
87+}
88+
89+#[derive(Clone, Debug)]
90+pub struct Commit {
91+ pub sha: String,
92+ pub parents: Vec<String>,
93+ pub author_name: String,
94+ pub author_email: String,
95+ pub authored_at: DateTime<Utc>,
96+ pub committer_name: String,
97+ pub committed_at: DateTime<Utc>,
98+ pub subject: String,
99+ pub body: String,
100+}
101+
102+impl Commit {
103+ pub fn short_sha(&self) -> &str {
104+ &self.sha[..self.sha.len().min(8)]
105+ }
106+}
107+
108+const COMMIT_FORMAT: &str = "%H%x00%P%x00%an%x00%ae%x00%at%x00%cn%x00%ct%x00%s%x00%b%x1e";
109+
110+fn parse_commits(output: &[u8]) -> Vec<Commit> {
111+ String::from_utf8_lossy(output)
112+ .split('\x1e')
113+ .filter_map(|record| {
114+ let record = record.trim_start_matches('\n');
115+ if record.is_empty() {
116+ return None;
117+ }
118+ let mut f = record.splitn(9, '\0');
119+ let sha = f.next()?.to_string();
120+ let parents = f.next()?.split_whitespace().map(String::from).collect();
121+ let author_name = f.next()?.to_string();
122+ let author_email = f.next()?.to_string();
123+ let authored_at = timestamp(f.next()?);
124+ let committer_name = f.next()?.to_string();
125+ let committed_at = timestamp(f.next()?);
126+ let subject = f.next()?.to_string();
127+ let body = f.next().unwrap_or("").trim_end().to_string();
128+ Some(Commit { sha, parents, author_name, author_email, authored_at, committer_name, committed_at, subject, body })
129+ })
130+ .collect()
131+}
132+
133+fn timestamp(s: &str) -> DateTime<Utc> {
134+ s.trim().parse::<i64>().ok().and_then(|t| Utc.timestamp_opt(t, 0).single()).unwrap_or_default()
135+}
136+
137+/// Accepts names a human could type as a ref: no option-looking or control
138+/// characters. Git's own check-ref-format runs on the result anyway.
139+pub fn plausible_ref(name: &str) -> bool {
140+ !name.is_empty()
141+ && name.len() <= 255
142+ && !name.starts_with('-')
143+ && !name.contains("..")
144+ && !name.bytes().any(|b| b < 0x20 || b == 0x7f || b" ~^:?*[\\".contains(&b))
145+}
146+
147+pub fn is_hex_sha(s: &str) -> bool {
148+ (4..=64).contains(&s.len()) && s.bytes().all(|b| b.is_ascii_hexdigit())
149+}
150+
151+impl Git {
152+ pub fn new(path: impl Into<PathBuf>) -> Self {
153+ Self { path: path.into() }
154+ }
155+
156+ pub fn command(&self) -> Command {
157+ git_command(&self.path)
158+ }
159+
160+ /// Creates a bare repository with server-side defaults.
161+ pub async fn init_bare(path: &Path, default_branch: &str) -> anyhow::Result<Self> {
162+ if let Some(parent) = path.parent() {
163+ tokio::fs::create_dir_all(parent).await?;
164+ }
165+ let output = Command::new("git")
166+ .env("GIT_CONFIG_NOSYSTEM", "1")
167+ .env("GIT_CONFIG_GLOBAL", "/dev/null")
168+ .args(["init", "--bare", "--quiet"])
169+ .arg(format!("--initial-branch={default_branch}"))
170+ .arg(path)
171+ .output()
172+ .await?;
173+ if !output.status.success() {
174+ bail!("git init failed: {}", String::from_utf8_lossy(&output.stderr));
175+ }
176+ let git = Self::new(path);
177+ for (key, value) in [
178+ ("core.logAllRefUpdates", "false"),
179+ ("receive.denyNonFastForwards", "false"),
180+ ("receive.denyDeleteCurrent", "warn"),
181+ ("uploadpack.allowFilter", "true"),
182+ ("uploadpack.allowReachableSHA1InWant", "true"),
183+ ("gc.auto", "6700"),
184+ ] {
185+ git.run(&["config", key, value]).await?;
186+ }
187+ // Sample hooks are noise; ours come from core.hooksPath at push time.
188+ let _ = tokio::fs::remove_dir_all(path.join("hooks")).await;
189+ let _ = tokio::fs::write(path.join("description"), "").await;
190+ Ok(git)
191+ }
192+
193+ /// Runs git and returns stdout, failing with stderr on a non-zero exit.
194+ pub async fn run(&self, args: &[&str]) -> anyhow::Result<Vec<u8>> {
195+ let output = self.command().args(args).output().await.context("could not run git")?;
196+ if !output.status.success() {
197+ bail!(
198+ "git {} failed: {}",
199+ args.first().unwrap_or(&""),
200+ String::from_utf8_lossy(&output.stderr).trim()
201+ );
202+ }
203+ Ok(output.stdout)
204+ }
205+
206+ /// Like `run`, but a non-zero exit is `None` (missing ref, path, ...).
207+ pub async fn run_opt(&self, args: &[&str]) -> anyhow::Result<Option<Vec<u8>>> {
208+ let output = self.command().args(args).output().await.context("could not run git")?;
209+ Ok(output.status.success().then_some(output.stdout))
210+ }
211+
212+ pub async fn refs(&self) -> anyhow::Result<Vec<RefInfo>> {
213+ let out = self
214+ .run(&[
215+ "for-each-ref",
216+ "--sort=-committerdate",
217+ "--format=%(refname)%00%(objectname)%00%(*objectname)%00%(committerdate:unix)%00%(*committerdate:unix)%00%(contents:subject)",
218+ "refs/heads/",
219+ "refs/tags/",
220+ ])
221+ .await?;
222+ Ok(String::from_utf8_lossy(&out)
223+ .lines()
224+ .filter_map(|line| {
225+ let mut f = line.splitn(6, '\0');
226+ let name = f.next()?.to_string();
227+ let sha = f.next()?.to_string();
228+ let peeled = f.next()?.to_string();
229+ let date = f.next()?;
230+ let peeled_date = f.next()?;
231+ let subject = f.next().unwrap_or("").to_string();
232+ let commit = if peeled.is_empty() { sha.clone() } else { peeled };
233+ let date = if date.is_empty() { peeled_date } else { date };
234+ let committed_at = date.parse::<i64>().ok().and_then(|t| Utc.timestamp_opt(t, 0).single());
235+ Some(RefInfo { name, sha, commit, committed_at, subject })
236+ })
237+ .collect())
238+ }
239+
240+ /// Map of ref name to sha for every ref, used to diff before/after a push.
241+ pub async fn ref_snapshot(&self) -> anyhow::Result<std::collections::BTreeMap<String, String>> {
242+ let out = self.run(&["for-each-ref", "--format=%(refname) %(objectname)"]).await?;
243+ Ok(String::from_utf8_lossy(&out)
244+ .lines()
245+ .filter_map(|l| l.split_once(' ').map(|(n, s)| (n.to_string(), s.to_string())))
246+ .collect())
247+ }
248+
249+ pub async fn has_commits(&self) -> anyhow::Result<bool> {
250+ Ok(!self.run(&["for-each-ref", "--count=1", "refs/heads/"]).await?.is_empty())
251+ }
252+
253+ /// Full commit sha for a branch, tag or (abbreviated) sha.
254+ pub async fn resolve_commit(&self, rev: &str) -> anyhow::Result<Option<String>> {
255+ if !plausible_ref(rev) {
256+ return Ok(None);
257+ }
258+ let spec = format!("{rev}^{{commit}}");
259+ Ok(self
260+ .run_opt(&["rev-parse", "--verify", "--quiet", "--end-of-options", &spec])
261+ .await?
262+ .map(|out| String::from_utf8_lossy(&out).trim().to_string())
263+ .filter(|s| !s.is_empty()))
264+ }
265+
266+ /// Entries of the directory at `path` ("" for the root) in `commit`.
267+ pub async fn ls_tree(&self, commit: &str, path: &str) -> anyhow::Result<Option<Vec<TreeEntry>>> {
268+ let spec = if path.is_empty() { format!("{commit}^{{tree}}") } else { format!("{commit}:{path}") };
269+ let Some(out) = self.run_opt(&["ls-tree", "-l", "-z", "--end-of-options", &spec]).await? else {
270+ return Ok(None);
271+ };
272+ let mut entries: Vec<TreeEntry> = out
273+ .split(|b| *b == 0)
274+ .filter(|chunk| !chunk.is_empty())
275+ .filter_map(|chunk| {
276+ let line = String::from_utf8_lossy(chunk);
277+ let (meta, name) = line.split_once('\t')?;
278+ let mut f = meta.split_whitespace();
279+ let mode = f.next()?.to_string();
280+ let kind = f.next()?.to_string();
281+ let sha = f.next()?.to_string();
282+ let size = f.next().and_then(|s| s.parse().ok());
283+ Some(TreeEntry { mode, kind, sha, size, name: name.to_string() })
284+ })
285+ .collect();
286+ // Directories first, then files, each alphabetical.
287+ entries.sort_by(|a, b| b.is_dir().cmp(&a.is_dir()).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())));
288+ Ok(Some(entries))
289+ }
290+
291+ /// The object at `path` in `commit`: (kind, sha, size).
292+ pub async fn object_at(&self, commit: &str, path: &str) -> anyhow::Result<Option<(String, String, u64)>> {
293+ if path.is_empty() {
294+ return Ok(None);
295+ }
296+ // Specs go over stdin, so a path can never be parsed as an option.
297+ self.batch_check(&format!("{commit}:{path}")).await
298+ }
299+
300+ async fn batch_check(&self, spec: &str) -> anyhow::Result<Option<(String, String, u64)>> {
301+ use tokio::io::AsyncWriteExt;
302+ let mut child = self
303+ .command()
304+ .args(["cat-file", "--batch-check=%(objecttype) %(objectname) %(objectsize)"])
305+ .stdin(Stdio::piped())
306+ .stdout(Stdio::piped())
307+ .stderr(Stdio::null())
308+ .spawn()?;
309+ let mut stdin = child.stdin.take().context("no stdin")?;
310+ stdin.write_all(spec.as_bytes()).await?;
311+ stdin.write_all(b"\n").await?;
312+ drop(stdin);
313+ let output = child.wait_with_output().await?;
314+ let line = String::from_utf8_lossy(&output.stdout);
315+ let mut f = line.split_whitespace();
316+ match (f.next(), f.next(), f.next()) {
317+ (Some(kind), Some(sha), Some(size)) if kind != "missing" && kind != "ambiguous" => {
318+ Ok(Some((kind.to_string(), sha.to_string(), size.parse().unwrap_or(0))))
319+ }
320+ _ => Ok(None),
321+ }
322+ }
323+
324+ /// Raw blob contents by sha.
325+ pub async fn read_blob(&self, sha: &str) -> anyhow::Result<Vec<u8>> {
326+ anyhow::ensure!(is_hex_sha(sha), "not a sha");
327+ self.run(&["cat-file", "blob", sha]).await
328+ }
329+
330+ /// Blob contents streamed from git, for raw downloads.
331+ pub fn blob_stream(&self, sha: &str) -> anyhow::Result<tokio::process::Child> {
332+ anyhow::ensure!(is_hex_sha(sha), "not a sha");
333+ Ok(self.command().args(["cat-file", "blob", sha]).stdout(Stdio::piped()).stderr(Stdio::null()).spawn()?)
334+ }
335+
336+ /// History of `rev`, optionally limited to `path`, newest first.
337+ pub async fn log(&self, rev: &str, path: Option<&str>, skip: usize, limit: usize) -> anyhow::Result<Vec<Commit>> {
338+ let format = format!("--format={COMMIT_FORMAT}");
339+ let skip = format!("--skip={skip}");
340+ let limit = format!("--max-count={limit}");
341+ let mut args = vec!["log", &format, &skip, &limit, "--end-of-options", rev, "--"];
342+ if let Some(path) = path.filter(|p| !p.is_empty()) {
343+ args.push(path);
344+ }
345+ Ok(self.run_opt(&args).await?.map(|out| parse_commits(&out)).unwrap_or_default())
346+ }
347+
348+ pub async fn commit(&self, rev: &str) -> anyhow::Result<Option<Commit>> {
349+ Ok(self.log(rev, None, 0, 1).await?.into_iter().next())
350+ }
351+
352+ /// Commits in `new` that are not in `old` (or all of `new` when `old`
353+ /// is the zero sha), newest first, capped at `limit`.
354+ pub async fn new_commits(&self, old: &str, new: &str, limit: usize) -> anyhow::Result<Vec<Commit>> {
355+ let format = format!("--format={COMMIT_FORMAT}");
356+ let limit = format!("--max-count={limit}");
357+ let exclude = format!("^{old}");
358+ let mut args = vec!["log", &format, &limit, "--end-of-options", new];
359+ if old != ZERO_SHA {
360+ args.push(&exclude);
361+ }
362+ args.push("--");
363+ Ok(parse_commits(&self.run(&args).await?))
364+ }
365+
366+ pub async fn count_commits(&self, rev: &str) -> anyhow::Result<u64> {
367+ let out = self.run_opt(&["rev-list", "--count", "--end-of-options", rev, "--"]).await?;
368+ Ok(out.and_then(|o| String::from_utf8_lossy(&o).trim().parse().ok()).unwrap_or(0))
369+ }
370+
371+ /// The patch a commit introduces (against its first parent), truncated.
372+ pub async fn commit_patch(&self, sha: &str, max_bytes: usize) -> anyhow::Result<(String, bool)> {
373+ anyhow::ensure!(is_hex_sha(sha), "not a sha");
374+ let out = self
375+ .run(&["show", "--format=", "--patch", "--find-renames", "--no-color", "--no-ext-diff", "--first-parent", "--end-of-options", sha])
376+ .await?;
377+ let truncated = out.len() > max_bytes;
378+ let slice = &out[..out.len().min(max_bytes)];
379+ Ok((String::from_utf8_lossy(slice).into_owned(), truncated))
380+ }
381+
382+ /// Files changed by a commit: (status, path, additions, deletions).
383+ pub async fn commit_stats(&self, sha: &str) -> anyhow::Result<Vec<(String, String, Option<u64>, Option<u64>)>> {
384+ anyhow::ensure!(is_hex_sha(sha), "not a sha");
385+ let numstat = self.run(&["show", "--format=", "--numstat", "--find-renames", "--first-parent", "-z", "--end-of-options", sha]).await?;
386+ let status = self.run(&["show", "--format=", "--name-status", "--find-renames", "--first-parent", "--end-of-options", sha]).await?;
387+ let statuses: Vec<String> = String::from_utf8_lossy(&status).lines().map(|l| l.chars().next().unwrap_or('M').to_string()).collect();
388+ let mut out = Vec::new();
389+ let text = String::from_utf8_lossy(&numstat).into_owned();
390+ let mut parts = text.split('\0').filter(|s| !s.is_empty()).peekable();
391+ let mut index = 0;
392+ while let Some(record) = parts.next() {
393+ let mut f = record.splitn(3, '\t');
394+ let adds = f.next().and_then(|v| v.parse().ok());
395+ let dels = f.next().and_then(|v| v.parse().ok());
396+ let mut path = f.next().unwrap_or("").to_string();
397+ if path.is_empty() {
398+ // Rename: "\0old\0new" follows.
399+ let _old = parts.next();
400+ path = parts.next().unwrap_or("").to_string();
401+ }
402+ out.push((statuses.get(index).cloned().unwrap_or_else(|| "M".into()), path, adds, dels));
403+ index += 1;
404+ }
405+ Ok(out)
406+ }
407+
408+ pub async fn set_head(&self, branch: &str) -> anyhow::Result<()> {
409+ anyhow::ensure!(plausible_ref(branch), "invalid branch name");
410+ self.run(&["symbolic-ref", "HEAD", &format!("refs/heads/{branch}")]).await?;
411+ Ok(())
412+ }
413+
414+ /// Bytes on disk, from `count-objects` (loose + packed).
415+ pub async fn disk_size(&self) -> anyhow::Result<u64> {
416+ let out = self.run(&["count-objects", "-v"]).await?;
417+ let mut kib = 0u64;
418+ for line in String::from_utf8_lossy(&out).lines() {
419+ if let Some((key, value)) = line.split_once(": ") {
420+ if matches!(key, "size" | "size-pack" | "size-garbage") {
421+ kib += value.trim().parse::<u64>().unwrap_or(0);
422+ }
423+ }
424+ }
425+ Ok(kib * 1024)
426+ }
427+}
428+
429+#[cfg(test)]
430+mod tests {
431+ use super::*;
432+
433+ #[test]
434+ fn rejects_flag_like_refs() {
435+ assert!(plausible_ref("main"));
436+ assert!(plausible_ref("feature/x"));
437+ assert!(!plausible_ref("--upload-pack=evil"));
438+ assert!(!plausible_ref("a..b"));
439+ assert!(!plausible_ref("a b"));
440+ }
441+
442+ #[test]
443+ fn parses_commit_records() {
444+ let raw = b"abc\0p1 p2\0Ann\0ann@x.io\01700000000\0Bob\01700000100\0Subject line\0Body text\n\x1e";
445+ let commits = parse_commits(raw);
446+ assert_eq!(commits.len(), 1);
447+ assert_eq!(commits[0].parents, vec!["p1", "p2"]);
448+ assert_eq!(commits[0].author_email, "ann@x.io");
449+ assert_eq!(commits[0].subject, "Subject line");
450+ assert_eq!(commits[0].body, "Body text");
451+ }
452+}
+243-0backend/src/git_http.rs
@@ -0,0 +1,243 @@
1+//! Git smart HTTP (protocol v0, v1 and v2), the same wire protocol as
2+//! `git http-backend`:
3+//!
4+//! GET /{owner}/{repo}/info/refs?service=git-upload-pack|git-receive-pack
5+//! POST /{owner}/{repo}/git-upload-pack
6+//! POST /{owner}/{repo}/git-receive-pack
7+//!
8+//! Request bodies stream into git's stdin and git's stdout streams back, so
9+//! memory stays flat for any repository size.
10+
11+use std::process::Stdio;
12+
13+use async_compression::tokio::bufread::GzipDecoder;
14+use axum::{
15+ Router,
16+ body::Body,
17+ extract::{Path, Query, Request, State},
18+ http::{HeaderMap, StatusCode, header},
19+ response::{IntoResponse, Response},
20+ routing::{get, post},
21+};
22+use futures::TryStreamExt;
23+use serde::Deserialize;
24+use serde_json::json;
25+use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWriteExt, BufReader};
26+use tokio_util::io::{ReaderStream, StreamReader};
27+
28+use crate::{
29+ analytics,
30+ auth::{self, HeaderAuth, Viewer},
31+ git::Git,
32+ models::Repo,
33+ perm::{self, Access, Area},
34+ push,
35+ state::AppState,
36+ transport::{self, Service, ServiceOptions},
37+};
38+
39+pub fn router() -> Router<AppState> {
40+ Router::new()
41+ .route("/{owner}/{repo}/info/refs", get(info_refs))
42+ .route("/{owner}/{repo}/git-upload-pack", post(upload_pack))
43+ .route("/{owner}/{repo}/git-receive-pack", post(receive_pack))
44+}
45+
46+#[derive(Deserialize)]
47+struct InfoRefsQuery {
48+ service: Option<String>,
49+}
50+
51+fn plain(status: StatusCode, message: &str) -> Response {
52+ (status, [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], format!("{message}\n")).into_response()
53+}
54+
55+/// Resolves the repo and the caller's access, answering the way git clients
56+/// expect: 401 with a Basic challenge whenever credentials might help, so a
57+/// private repo is indistinguishable from a missing one until you sign in.
58+async fn authorize(state: &AppState, headers: &HeaderMap, owner: &str, name: &str, service: Service) -> Result<(Repo, Option<Viewer>), Response> {
59+ let viewer = match auth::authenticate_header(&state.db, headers).await {
60+ Ok(HeaderAuth::Viewer(viewer)) => Some(viewer),
61+ Ok(HeaderAuth::Anonymous) => None,
62+ Ok(HeaderAuth::Invalid) => return Err(auth::basic_challenge("Invalid credentials. Use a personal access token as the password (ig auth token).")),
63+ Err(error) => {
64+ tracing::error!(?error, "git auth lookup failed");
65+ return Err(plain(StatusCode::INTERNAL_SERVER_ERROR, "internal error"));
66+ }
67+ };
68+ let repo = match Repo::by_path(&state.db, owner, name).await {
69+ Ok(repo) => repo,
70+ Err(error) => {
71+ tracing::error!(?error, "repo lookup failed");
72+ return Err(plain(StatusCode::INTERNAL_SERVER_ERROR, "internal error"));
73+ }
74+ };
75+ let access = match &repo {
76+ Some(repo) => perm::repo_access(&state.db, viewer.as_ref(), repo, Area::Repo).await.unwrap_or(Access::None),
77+ None => Access::None,
78+ };
79+ let needed = match service {
80+ Service::UploadPack => Access::Read,
81+ Service::ReceivePack => Access::Write,
82+ };
83+ if access >= needed {
84+ return Ok((repo.expect("access implies repo"), viewer));
85+ }
86+ if viewer.is_none() {
87+ return Err(auth::basic_challenge("Authentication required."));
88+ }
89+ match (&repo, access.can_read()) {
90+ (Some(repo), true) if repo.archived => Err(plain(StatusCode::FORBIDDEN, "This repository is archived and read-only.")),
91+ (Some(_), true) => Err(plain(StatusCode::FORBIDDEN, "You have read access only; pushing needs write access.")),
92+ _ => Err(plain(StatusCode::NOT_FOUND, "Repository not found.")),
93+ }
94+}
95+
96+fn protocol_header(headers: &HeaderMap) -> Option<String> {
97+ headers.get("git-protocol").and_then(|v| v.to_str().ok()).map(str::to_string)
98+}
99+
100+async fn info_refs(
101+ State(state): State<AppState>,
102+ Path((owner, name)): Path<(String, String)>,
103+ Query(query): Query<InfoRefsQuery>,
104+ headers: HeaderMap,
105+) -> Response {
106+ let Some(service) = query.service.as_deref().and_then(Service::from_name) else {
107+ return plain(StatusCode::FORBIDDEN, "Only smart HTTP is supported; upgrade git.");
108+ };
109+ let (repo, viewer) = match authorize(&state, &headers, &owner, &name, service).await {
110+ Ok(found) => found,
111+ Err(response) => return response,
112+ };
113+ let protocol = protocol_header(&headers);
114+ let command = transport::service_command(
115+ &state,
116+ &repo,
117+ service,
118+ viewer.as_ref(),
119+ ServiceOptions { stateless_rpc: true, advertise_refs: true, protocol: protocol.as_deref() },
120+ )
121+ .await;
122+ let output = match command {
123+ Ok(mut command) => command.stdout(Stdio::piped()).stderr(Stdio::piped()).output().await,
124+ Err(error) => return internal(error),
125+ };
126+ let output = match output {
127+ Ok(output) if output.status.success() => output,
128+ Ok(output) => {
129+ tracing::error!(repo = %repo.full_name(), stderr = %String::from_utf8_lossy(&output.stderr), "advertise refs failed");
130+ return plain(StatusCode::INTERNAL_SERVER_ERROR, "git failed");
131+ }
132+ Err(error) => return internal(error.into()),
133+ };
134+
135+ // Protocol v2 replies with capabilities only; v0/v1 lead with a
136+ // "# service=" packet and a flush, as git http-backend does.
137+ let mut body = Vec::with_capacity(output.stdout.len() + 64);
138+ if !protocol.as_deref().is_some_and(|p| p.contains("version=2")) {
139+ let line = format!("# service={}\n", service.name());
140+ body.extend_from_slice(format!("{:04x}{line}0000", line.len() + 4).as_bytes());
141+ }
142+ body.extend_from_slice(&output.stdout);
143+ (
144+ StatusCode::OK,
145+ [
146+ (header::CONTENT_TYPE, format!("application/x-{}-advertisement", service.name())),
147+ (header::CACHE_CONTROL, "no-cache, max-age=0, must-revalidate".to_string()),
148+ ],
149+ body,
150+ )
151+ .into_response()
152+}
153+
154+async fn upload_pack(state: State<AppState>, path: Path<(String, String)>, request: Request) -> Response {
155+ run_service(state, path, request, Service::UploadPack).await
156+}
157+
158+async fn receive_pack(state: State<AppState>, path: Path<(String, String)>, request: Request) -> Response {
159+ run_service(state, path, request, Service::ReceivePack).await
160+}
161+
162+async fn run_service(State(state): State<AppState>, Path((owner, name)): Path<(String, String)>, request: Request, service: Service) -> Response {
163+ let headers = request.headers().clone();
164+ let (repo, viewer) = match authorize(&state, &headers, &owner, &name, service).await {
165+ Ok(found) => found,
166+ Err(response) => return response,
167+ };
168+ let protocol = protocol_header(&headers);
169+ let git = Git::new(repo.disk_path(&state.config));
170+ let before = if service == Service::ReceivePack {
171+ match git.ref_snapshot().await {
172+ Ok(refs) => Some(refs),
173+ Err(error) => return internal(error),
174+ }
175+ } else {
176+ None
177+ };
178+
179+ let command = transport::service_command(
180+ &state,
181+ &repo,
182+ service,
183+ viewer.as_ref(),
184+ ServiceOptions { stateless_rpc: true, advertise_refs: false, protocol: protocol.as_deref() },
185+ )
186+ .await;
187+ let mut child = match command.and_then(|mut c| Ok(c.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn()?)) {
188+ Ok(child) => child,
189+ Err(error) => return internal(error),
190+ };
191+
192+ // Request body -> git stdin, gunzipping when the client compressed it.
193+ let gzip = headers.get(header::CONTENT_ENCODING).and_then(|v| v.to_str().ok()).is_some_and(|v| v.eq_ignore_ascii_case("gzip"));
194+ let body = request.into_body().into_data_stream().map_err(std::io::Error::other);
195+ let reader = StreamReader::new(body);
196+ let mut input: Box<dyn AsyncRead + Send + Unpin> = if gzip { Box::new(GzipDecoder::new(reader)) } else { Box::new(reader) };
197+ let mut stdin = child.stdin.take().expect("piped");
198+ let repo_name = repo.full_name();
199+ tokio::spawn(async move {
200+ if let Err(error) = tokio::io::copy(&mut input, &mut stdin).await {
201+ tracing::warn!(%error, "client request body ended early");
202+ }
203+ let _ = stdin.shutdown().await;
204+ });
205+
206+ let stderr = child.stderr.take().expect("piped");
207+ let log_name = repo_name.clone();
208+ tokio::spawn(async move {
209+ let mut lines = BufReader::new(stderr).lines();
210+ while let Ok(Some(line)) = lines.next_line().await {
211+ tracing::warn!(repo = %log_name, service = service.name(), "git: {line}");
212+ }
213+ });
214+
215+ let stdout = child.stdout.take().expect("piped");
216+ let via_user = viewer.as_ref().map(|v| v.name.clone());
217+ let finish_state = state.clone();
218+ tokio::spawn(async move {
219+ let status = child.wait().await;
220+ match (status, before) {
221+ (Ok(status), Some(before)) if status.success() => {
222+ push::spawn_after_push(finish_state, repo, viewer, before, "http");
223+ }
224+ (Ok(status), _) if status.success() => {
225+ analytics::track(&finish_state, "git_fetch", via_user.as_deref(), &repo.url(), json!({ "via": "http", "visibility": repo.visibility }));
226+ }
227+ (Ok(status), _) => tracing::info!(repo = %repo_name, service = service.name(), %status, "git service exited unsuccessfully"),
228+ (Err(error), _) => tracing::error!(repo = %repo_name, %error, "waiting for git failed"),
229+ }
230+ });
231+
232+ Response::builder()
233+ .status(StatusCode::OK)
234+ .header(header::CONTENT_TYPE, format!("application/x-{}-result", service.name()))
235+ .header(header::CACHE_CONTROL, "no-cache, max-age=0, must-revalidate")
236+ .body(Body::from_stream(ReaderStream::new(stdout)))
237+ .unwrap_or_else(|_| plain(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))
238+}
239+
240+fn internal(error: anyhow::Error) -> Response {
241+ tracing::error!(?error, "git http error");
242+ plain(StatusCode::INTERNAL_SERVER_ERROR, "internal error")
243+}
+204-0backend/src/hook.rs
@@ -0,0 +1,204 @@
1+//! The pre-receive hook. git runs `irongit hook pre-receive` (through the
2+//! script `install` writes) after receiving a pack and before updating refs.
3+//! The new objects sit in a quarantine directory at that point, so this is
4+//! the one place a push can be inspected and refused as a whole.
5+//!
6+//! Limits arrive in the environment from whoever spawned receive-pack:
7+//! IRONGIT_MAX_FILE_BYTES largest blob allowed (bigger belongs in LFS)
8+//! IRONGIT_QUOTA_REMAINING bytes the owner may still add
9+
10+use std::{
11+ collections::HashMap,
12+ io::{BufRead, Write},
13+ path::Path,
14+ process::{Command, Stdio},
15+};
16+
17+use crate::{config::Config, git::ZERO_SHA};
18+
19+pub const ENV_MAX_FILE: &str = "IRONGIT_MAX_FILE_BYTES";
20+pub const ENV_QUOTA_REMAINING: &str = "IRONGIT_QUOTA_REMAINING";
21+pub const ENV_REPO: &str = "IRONGIT_REPO";
22+pub const ENV_PUSHER: &str = "IRONGIT_PUSHER";
23+
24+/// Writes the hook scripts git is pointed at with `core.hooksPath`. They
25+/// re-exec this binary, so the hook always matches the running server.
26+pub async fn install(config: &Config) -> anyhow::Result<()> {
27+ let exe = std::env::current_exe()?;
28+ let dir = config.hooks_dir();
29+ tokio::fs::create_dir_all(&dir).await?;
30+ let log_dir = config.log_dir.display().to_string();
31+ let script = format!(
32+ "#!/bin/sh\n# Written by irongit at startup; edits are overwritten.\nIRONGIT_LOG_DIR='{}' exec '{}' hook pre-receive\n",
33+ log_dir.replace('\'', "'\\''"),
34+ exe.display().to_string().replace('\'', "'\\''")
35+ );
36+ let path = dir.join("pre-receive");
37+ tokio::fs::write(&path, script).await?;
38+ #[cfg(unix)]
39+ {
40+ use std::os::unix::fs::PermissionsExt;
41+ tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).await?;
42+ }
43+ tracing::info!(hook = %path.display(), "installed pre-receive hook");
44+ Ok(())
45+}
46+
47+/// Entry point for `irongit hook <name>`. Exit status is the verdict.
48+pub fn run(name: &str) -> anyhow::Result<()> {
49+ let _guard = std::env::var("IRONGIT_LOG_DIR").ok().and_then(|dir| crate::logging::init_hook(Path::new(&dir)));
50+ match name {
51+ "pre-receive" => match pre_receive() {
52+ Ok(()) => Ok(()),
53+ Err(Rejection(message)) => {
54+ let mut stderr = std::io::stderr();
55+ for line in message.lines() {
56+ let _ = writeln!(stderr, "irongit: {line}");
57+ }
58+ tracing::info!(repo = %std::env::var(ENV_REPO).unwrap_or_default(), pusher = %std::env::var(ENV_PUSHER).unwrap_or_default(), %message, "push rejected");
59+ std::process::exit(1);
60+ }
61+ },
62+ other => anyhow::bail!("unknown hook {other}"),
63+ }
64+}
65+
66+struct Rejection(String);
67+
68+impl<E: std::fmt::Display> From<E> for Rejection {
69+ fn from(error: E) -> Self {
70+ tracing::error!(%error, "pre-receive hook failed");
71+ Rejection(format!("internal error while checking the push: {error}"))
72+ }
73+}
74+
75+fn pre_receive() -> Result<(), Rejection> {
76+ let max_file: u64 = std::env::var(ENV_MAX_FILE).ok().and_then(|v| v.parse().ok()).unwrap_or(u64::MAX);
77+ let quota_remaining: u64 = std::env::var(ENV_QUOTA_REMAINING).ok().and_then(|v| v.parse().ok()).unwrap_or(u64::MAX);
78+
79+ let mut new_tips = Vec::new();
80+ for line in std::io::stdin().lock().lines() {
81+ let line = line?;
82+ let mut parts = line.split_whitespace();
83+ let (Some(_old), Some(new), Some(_name)) = (parts.next(), parts.next(), parts.next()) else { continue };
84+ if new != ZERO_SHA {
85+ new_tips.push(new.to_string());
86+ }
87+ }
88+
89+ // Quarantined objects are what this push adds to disk.
90+ if let Ok(quarantine) = std::env::var("GIT_QUARANTINE_PATH") {
91+ let added = dir_size(Path::new(&quarantine));
92+ if added > quota_remaining {
93+ return Err(Rejection(format!(
94+ "this push adds {} but the account has {} of storage left.\nDelete unused repositories or ask an administrator for more space.",
95+ human(added),
96+ human(quota_remaining)
97+ )));
98+ }
99+ }
100+ if new_tips.is_empty() || max_file == u64::MAX {
101+ return Ok(());
102+ }
103+
104+ // Objects reachable from the new tips but from no existing ref: exactly
105+ // what this push introduces. rev-list prints "sha path" for blobs.
106+ let mut rev_list = Command::new("git")
107+ .args(["rev-list", "--objects", "--no-object-names"])
108+ .args(["--stdin"])
109+ .stdin(Stdio::piped())
110+ .stdout(Stdio::piped())
111+ .spawn()?;
112+ // --no-object-names keeps output parseable; names are recovered below.
113+ {
114+ let mut stdin = rev_list.stdin.take().expect("piped");
115+ for tip in &new_tips {
116+ writeln!(stdin, "{tip}")?;
117+ }
118+ writeln!(stdin, "--not")?;
119+ writeln!(stdin, "--all")?;
120+ }
121+ let listed = rev_list.wait_with_output()?;
122+ if !listed.status.success() {
123+ return Err(Rejection("could not list the pushed objects".into()));
124+ }
125+
126+ let mut check = Command::new("git")
127+ .args(["cat-file", "--batch-check=%(objecttype) %(objectname) %(objectsize)"])
128+ .stdin(Stdio::piped())
129+ .stdout(Stdio::piped())
130+ .spawn()?;
131+ let objects = listed.stdout;
132+ let mut stdin = check.stdin.take().expect("piped");
133+ let writer = std::thread::spawn(move || stdin.write_all(&objects));
134+ let output = check.wait_with_output()?;
135+ let _ = writer.join();
136+
137+ let oversized: Vec<(String, u64)> = String::from_utf8_lossy(&output.stdout)
138+ .lines()
139+ .filter_map(|line| {
140+ let mut f = line.split_whitespace();
141+ let (kind, sha, size) = (f.next()?, f.next()?, f.next()?.parse::<u64>().ok()?);
142+ (kind == "blob" && size > max_file).then(|| (sha.to_string(), size))
143+ })
144+ .collect();
145+ if oversized.is_empty() {
146+ return Ok(());
147+ }
148+
149+ let names = blob_paths(&new_tips, &oversized.iter().map(|(sha, _)| sha.clone()).collect::<Vec<_>>());
150+ let mut message = format!("push rejected: files larger than {} must use Git LFS.\n", human(max_file));
151+ for (sha, size) in oversized.iter().take(20) {
152+ let name = names.get(sha).map(String::as_str).unwrap_or(&sha[..12]);
153+ message.push_str(&format!(" {name} ({})\n", human(*size)));
154+ }
155+ message.push_str("Track them with `git lfs track <pattern>`, then rewrite the commits that added them\n");
156+ message.push_str("(for example `git lfs migrate import --include=<pattern>`) and push again.");
157+ Err(Rejection(message))
158+}
159+
160+/// Recovers file paths for blob shas, for a readable rejection message.
161+fn blob_paths(tips: &[String], wanted: &[String]) -> HashMap<String, String> {
162+ let mut out = HashMap::new();
163+ let Ok(mut child) = Command::new("git")
164+ .args(["rev-list", "--objects", "--stdin"])
165+ .stdin(Stdio::piped())
166+ .stdout(Stdio::piped())
167+ .spawn()
168+ else {
169+ return out;
170+ };
171+ if let Some(mut stdin) = child.stdin.take() {
172+ for tip in tips {
173+ let _ = writeln!(stdin, "{tip}");
174+ }
175+ let _ = writeln!(stdin, "--not");
176+ let _ = writeln!(stdin, "--all");
177+ }
178+ if let Ok(output) = child.wait_with_output() {
179+ for line in String::from_utf8_lossy(&output.stdout).lines() {
180+ if let Some((sha, path)) = line.split_once(' ') {
181+ if wanted.iter().any(|w| w == sha) {
182+ out.entry(sha.to_string()).or_insert_with(|| path.to_string());
183+ }
184+ }
185+ }
186+ }
187+ out
188+}
189+
190+fn dir_size(path: &Path) -> u64 {
191+ let Ok(entries) = std::fs::read_dir(path) else { return 0 };
192+ entries
193+ .flatten()
194+ .map(|entry| match entry.metadata() {
195+ Ok(meta) if meta.is_dir() => dir_size(&entry.path()),
196+ Ok(meta) => meta.len(),
197+ Err(_) => 0,
198+ })
199+ .sum()
200+}
201+
202+fn human(bytes: u64) -> String {
203+ crate::web::ui::bytes(bytes)
204+}
+25-0backend/src/jobs.rs
@@ -0,0 +1,25 @@
1+//! Background work started with the server.
2+
3+use std::time::Duration;
4+
5+use crate::state::AppState;
6+
7+pub fn spawn_all(state: AppState) {
8+ crate::registry::spawn_gc(state.clone());
9+ crate::backup::spawn(state.clone());
10+ tokio::spawn(prune_sessions(state));
11+}
12+
13+/// Expired sessions and device codes are deleted hourly.
14+async fn prune_sessions(state: AppState) {
15+ let mut tick = tokio::time::interval(Duration::from_secs(3600));
16+ loop {
17+ tick.tick().await;
18+ let sessions = sqlx::query("delete from sessions where expires_at < now()").execute(&state.db).await;
19+ let codes = sqlx::query("delete from device_codes where expires_at < now() - interval '1 day'").execute(&state.db).await;
20+ match (sessions, codes) {
21+ (Ok(s), Ok(c)) => tracing::debug!(sessions = s.rows_affected(), device_codes = c.rows_affected(), "pruned"),
22+ (Err(e), _) | (_, Err(e)) => tracing::warn!(error = %e, "pruning failed"),
23+ }
24+ }
25+}
+9-0backend/src/lfs.rs
@@ -0,0 +1,9 @@
1+//! Git LFS batch API backed by R2. Stub.
2+
3+use axum::Router;
4+
5+use crate::state::AppState;
6+
7+pub fn router() -> Router<AppState> {
8+ Router::new()
9+}
+67-0backend/src/logging.rs
@@ -0,0 +1,67 @@
1+//! Logs go to stdout and to files under LOG_DIR (default DATA_DIR/logs):
2+//! server.YYYY-MM-DD.log everything the server does, one request per line
3+//! frontend.YYYY-MM-DD.log browser errors posted to /api/v1/client-logs
4+//! hooks.YYYY-MM-DD.log git hook runs (pre-receive size checks)
5+
6+use std::path::Path;
7+
8+use tracing_appender::{non_blocking::WorkerGuard, rolling};
9+use tracing_subscriber::{EnvFilter, Layer, filter::filter_fn, fmt, layer::SubscriberExt, util::SubscriberInitExt};
10+
11+pub const FRONTEND_TARGET: &str = "frontend";
12+
13+pub fn init(log_dir: &Path) -> anyhow::Result<Vec<WorkerGuard>> {
14+ std::fs::create_dir_all(log_dir)?;
15+ let server_appender = rolling::Builder::new()
16+ .rotation(rolling::Rotation::DAILY)
17+ .filename_prefix("server")
18+ .filename_suffix("log")
19+ .max_log_files(14)
20+ .build(log_dir)?;
21+ let frontend_appender = rolling::Builder::new()
22+ .rotation(rolling::Rotation::DAILY)
23+ .filename_prefix("frontend")
24+ .filename_suffix("log")
25+ .max_log_files(14)
26+ .build(log_dir)?;
27+ let (server_writer, server_guard) = tracing_appender::non_blocking(server_appender);
28+ let (frontend_writer, frontend_guard) = tracing_appender::non_blocking(frontend_appender);
29+
30+ let filter = || EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info,sqlx=warn,russh=warn,tower_http=info"));
31+
32+ tracing_subscriber::registry()
33+ .with(fmt::layer().with_target(true).with_filter(filter()))
34+ .with(
35+ fmt::layer()
36+ .with_ansi(false)
37+ .with_writer(server_writer)
38+ .with_filter(filter())
39+ .with_filter(filter_fn(|meta| meta.target() != FRONTEND_TARGET)),
40+ )
41+ .with(
42+ fmt::layer()
43+ .with_ansi(false)
44+ .with_target(false)
45+ .with_writer(frontend_writer)
46+ .with_filter(filter_fn(|meta| meta.target() == FRONTEND_TARGET)),
47+ )
48+ .init();
49+ Ok(vec![server_guard, frontend_guard])
50+}
51+
52+/// Hooks run as short-lived child processes; they append to their own file.
53+pub fn init_hook(log_dir: &Path) -> Option<WorkerGuard> {
54+ std::fs::create_dir_all(log_dir).ok()?;
55+ let appender = rolling::Builder::new()
56+ .rotation(rolling::Rotation::DAILY)
57+ .filename_prefix("hooks")
58+ .filename_suffix("log")
59+ .max_log_files(14)
60+ .build(log_dir)
61+ .ok()?;
62+ let (writer, guard) = tracing_appender::non_blocking(appender);
63+ tracing_subscriber::registry()
64+ .with(fmt::layer().with_ansi(false).with_writer(writer).with_filter(EnvFilter::new("info")))
65+ .init();
66+ Some(guard)
67+}
+29-0backend/src/mail.rs
@@ -0,0 +1,29 @@
1+//! Outgoing email (verification links). Without SMTP configured the message
2+//! is written to the log instead, which is how development works.
3+
4+use lettre::{
5+ AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor, message::header::ContentType,
6+ transport::smtp::authentication::Credentials,
7+};
8+
9+use crate::state::AppState;
10+
11+pub async fn send(state: &AppState, to: &str, subject: &str, body: &str) -> anyhow::Result<()> {
12+ let Some(smtp) = &state.config.smtp else {
13+ tracing::info!(target: "mail", to, subject, body, "SMTP not configured; email logged instead of sent");
14+ return Ok(());
15+ };
16+ let message = Message::builder()
17+ .from(smtp.from.parse()?)
18+ .to(to.parse()?)
19+ .subject(subject)
20+ .header(ContentType::TEXT_PLAIN)
21+ .body(body.to_string())?;
22+ let transport = AsyncSmtpTransport::<Tokio1Executor>::relay(&smtp.host)?
23+ .port(smtp.port)
24+ .credentials(Credentials::new(smtp.username.clone(), smtp.password.clone()))
25+ .build();
26+ transport.send(message).await?;
27+ tracing::info!(to, subject, "email sent");
28+ Ok(())
29+}
+214-0backend/src/main.rs
@@ -0,0 +1,214 @@
1+//! irongit: git hosting, a container registry and public profiles, served by
2+//! one binary. axum handles HTTP (web UI, JSON API, git smart HTTP, LFS, the
3+//! OCI registry); a built-in SSH server handles git over SSH.
4+//!
5+//! The same binary is also the git hook (`irongit hook pre-receive`) and the
6+//! admin tool (`irongit admin ...`).
7+
8+// Scaffolding is wired up before every caller exists; revisit before release.
9+#![allow(dead_code)]
10+
11+mod analytics;
12+mod api;
13+mod auth;
14+mod backup;
15+mod clientlog;
16+mod config;
17+#[cfg(feature = "hot-reload")]
18+mod dev;
19+mod error;
20+mod frontend;
21+mod git;
22+mod git_http;
23+mod hook;
24+mod jobs;
25+mod lfs;
26+mod logging;
27+mod mail;
28+mod models;
29+mod ops;
30+mod perm;
31+mod push;
32+mod registry;
33+mod routes;
34+mod signed;
35+mod sshkeys;
36+mod ssh;
37+mod state;
38+mod storage;
39+mod transport;
40+mod web;
41+
42+use std::sync::Arc;
43+
44+use anyhow::Context;
45+use clap::{Parser, Subcommand};
46+use sqlx::postgres::PgPoolOptions;
47+use tokio::{net::TcpListener, sync::broadcast};
48+
49+use crate::{config::Config, state::AppState, storage::Storage};
50+
51+#[derive(Parser)]
52+#[command(name = "irongit", version, about = "Git hosting and container registry server")]
53+struct Cli {
54+ #[command(subcommand)]
55+ command: Option<Command>,
56+}
57+
58+#[derive(Subcommand)]
59+enum Command {
60+ /// Run the server (the default).
61+ Serve,
62+ /// Git hook entry point; git runs this, not people.
63+ Hook {
64+ #[arg(value_parser = ["pre-receive"])]
65+ name: String,
66+ },
67+ /// Administrative tasks against the configured database.
68+ Admin {
69+ #[command(subcommand)]
70+ command: AdminCommand,
71+ },
72+}
73+
74+#[derive(Subcommand)]
75+enum AdminCommand {
76+ /// Make an existing user a site admin.
77+ Promote { username: String },
78+ /// Remove site admin from a user.
79+ Demote { username: String },
80+ /// Write, read and delete a test object in R2 to verify credentials.
81+ CheckStorage,
82+ /// Publish a CLI build to R2 so `ig upgrade` and install.sh serve it.
83+ PublishCli {
84+ /// Path to the built `ig` binary.
85+ path: std::path::PathBuf,
86+ /// Version string, e.g. 0.1.0.
87+ #[arg(long)]
88+ version: String,
89+ #[arg(long, default_value = "x86_64-unknown-linux-musl")]
90+ target: String,
91+ },
92+}
93+
94+fn main() -> anyhow::Result<()> {
95+ // .env lives at the workspace root; dotenvy searches parent directories.
96+ let _ = dotenvy::dotenv();
97+ let cli = Cli::parse();
98+ match cli.command.unwrap_or(Command::Serve) {
99+ // Hooks are short-lived and must not spin up a runtime per thread.
100+ Command::Hook { name } => hook::run(&name),
101+ Command::Serve => tokio_main(serve()),
102+ Command::Admin { command } => tokio_main(admin(command)),
103+ }
104+}
105+
106+fn tokio_main<F: std::future::Future<Output = anyhow::Result<()>>>(future: F) -> anyhow::Result<()> {
107+ tokio::runtime::Builder::new_multi_thread().enable_all().build()?.block_on(future)
108+}
109+
110+async fn connect(config: &Config) -> anyhow::Result<sqlx::PgPool> {
111+ let db = PgPoolOptions::new()
112+ .max_connections(20)
113+ .acquire_timeout(std::time::Duration::from_secs(10))
114+ .connect(&config.database_url)
115+ .await
116+ .context("connecting to Postgres (DATABASE_URL)")?;
117+ sqlx::migrate!("./migrations").run(&db).await.context("running migrations")?;
118+ Ok(db)
119+}
120+
121+async fn serve() -> anyhow::Result<()> {
122+ let config = Config::from_env()?;
123+ let _log_guards = logging::init(&config.log_dir)?;
124+ tracing::info!(public_url = %config.public_url, data_dir = %config.data_dir.display(), logs = %config.log_dir.display(), "starting irongit");
125+
126+ let db = connect(&config).await?;
127+ for dir in [config.repos_dir(), config.uploads_dir(), config.hooks_dir()] {
128+ tokio::fs::create_dir_all(&dir).await?;
129+ }
130+ hook::install(&config).await?;
131+
132+ let (reload_tx, _) = broadcast::channel::<()>(16);
133+ #[cfg(feature = "hot-reload")]
134+ if config::flag("FRONTEND_WATCH", cfg!(debug_assertions)) {
135+ dev::watch_frontend(reload_tx.clone())?;
136+ }
137+
138+ let state = AppState {
139+ storage: Storage::new(&config.r2)?,
140+ http: reqwest::Client::builder().timeout(std::time::Duration::from_secs(15)).build()?,
141+ config: Arc::new(config),
142+ db,
143+ reload_tx,
144+ };
145+
146+ jobs::spawn_all(state.clone());
147+ ssh::spawn(state.clone());
148+
149+ let app = routes::router(state.clone());
150+ let address = format!("{}:{}", state.config.host, state.config.port);
151+ let listener = TcpListener::bind(&address).await.with_context(|| format!("binding {address}"))?;
152+ tracing::info!(address = %listener.local_addr()?, "http listening");
153+ axum::serve(listener, app.into_make_service_with_connect_info::<std::net::SocketAddr>())
154+ .with_graceful_shutdown(shutdown_signal())
155+ .await?;
156+ Ok(())
157+}
158+
159+async fn shutdown_signal() {
160+ let ctrl_c = async {
161+ let _ = tokio::signal::ctrl_c().await;
162+ };
163+ let terminate = async {
164+ if let Ok(mut signal) = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
165+ signal.recv().await;
166+ }
167+ };
168+ tokio::select! { _ = ctrl_c => {}, _ = terminate => {} }
169+ tracing::info!("shutting down");
170+}
171+
172+async fn admin(command: AdminCommand) -> anyhow::Result<()> {
173+ let config = Config::from_env()?;
174+ let db = connect(&config).await?;
175+ match command {
176+ AdminCommand::Promote { username } => set_admin(&db, &username, true).await,
177+ AdminCommand::Demote { username } => set_admin(&db, &username, false).await,
178+ AdminCommand::CheckStorage => check_storage(&config).await,
179+ AdminCommand::PublishCli { path, version, target } => {
180+ let storage = Storage::new(&config.r2)?;
181+ api::release::publish(&db, &storage, &path, &version, &target).await
182+ }
183+ }
184+}
185+
186+async fn check_storage(config: &Config) -> anyhow::Result<()> {
187+ let storage = Storage::new(&config.r2)?;
188+ let key = format!("healthcheck/{}", uuid::Uuid::new_v4());
189+ let body = bytes::Bytes::from_static(b"irongit storage check");
190+ storage.put_bytes(&key, body.clone(), "text/plain").await.context("PUT")?;
191+ let size = storage.head(&key).await.context("HEAD")?;
192+ anyhow::ensure!(size == Some(body.len() as u64), "HEAD returned {size:?}");
193+ let read = storage.get_bytes(&key).await.context("GET")?;
194+ anyhow::ensure!(read.as_deref() == Some(&body[..]), "GET returned different bytes");
195+ let presigned = storage.presign_get(&key, std::time::Duration::from_secs(60), None);
196+ let fetched = reqwest::get(presigned).await?.error_for_status()?.bytes().await?;
197+ anyhow::ensure!(fetched == body, "presigned GET returned different bytes");
198+ storage.delete(&key).await.context("DELETE")?;
199+ anyhow::ensure!(storage.head(&key).await?.is_none(), "object still exists after DELETE");
200+ println!("R2 bucket {} OK: put, head, get, presigned get, delete", storage.bucket_name());
201+ Ok(())
202+}
203+
204+async fn set_admin(db: &sqlx::PgPool, username: &str, admin: bool) -> anyhow::Result<()> {
205+ let updated = sqlx::query("update users set is_admin = $2 where account_id = (select id from accounts where name = $1 and kind = 'user')")
206+ .bind(username)
207+ .bind(admin)
208+ .execute(db)
209+ .await?
210+ .rows_affected();
211+ anyhow::ensure!(updated == 1, "no user named {username}");
212+ println!("{username} is {}a site admin", if admin { "now " } else { "no longer " });
213+ Ok(())
214+}
+251-0backend/src/models.rs
@@ -0,0 +1,251 @@
1+//! Rows shared across modules, and the lookups everything needs.
2+
3+use std::path::PathBuf;
4+
5+use chrono::{DateTime, Utc};
6+use serde::Serialize;
7+use sqlx::{FromRow, PgPool};
8+
9+use crate::config::Config;
10+
11+#[derive(Debug, Clone, FromRow, Serialize)]
12+pub struct Account {
13+ pub id: i64,
14+ pub kind: String,
15+ pub name: String,
16+ pub display_name: String,
17+ pub bio: String,
18+ pub location: String,
19+ pub website: String,
20+ pub avatar_key: Option<String>,
21+ pub quota_bytes: Option<i64>,
22+ pub created_at: DateTime<Utc>,
23+ pub updated_at: DateTime<Utc>,
24+}
25+
26+/// Column list for `Account`, as a macro so queries stay `&'static str`
27+/// (sqlx refuses runtime-built SQL): `concat!("select ", account_columns!(), " from ...")`.
28+#[macro_export]
29+macro_rules! account_columns {
30+ () => {
31+ "id, kind, name, display_name, bio, location, website, avatar_key, quota_bytes, created_at, updated_at"
32+ };
33+}
34+
35+impl Account {
36+ pub async fn by_name(db: &PgPool, name: &str) -> sqlx::Result<Option<Self>> {
37+ sqlx::query_as(concat!("select ", account_columns!(), " from accounts where name = $1"))
38+ .bind(name)
39+ .fetch_optional(db)
40+ .await
41+ }
42+
43+ pub async fn by_id(db: &PgPool, id: i64) -> sqlx::Result<Option<Self>> {
44+ sqlx::query_as(concat!("select ", account_columns!(), " from accounts where id = $1"))
45+ .bind(id)
46+ .fetch_optional(db)
47+ .await
48+ }
49+
50+ pub fn is_org(&self) -> bool {
51+ self.kind == "org"
52+ }
53+
54+ /// Display name, falling back to the handle.
55+ pub fn label(&self) -> &str {
56+ if self.display_name.trim().is_empty() { &self.name } else { &self.display_name }
57+ }
58+
59+ pub fn quota(&self, config: &Config) -> u64 {
60+ self.quota_bytes.map(|q| q.max(0) as u64).unwrap_or(config.limits.account_quota_bytes)
61+ }
62+}
63+
64+#[derive(Debug, Clone, FromRow, Serialize)]
65+pub struct Repo {
66+ pub id: i64,
67+ pub owner_id: i64,
68+ pub owner_name: String,
69+ pub owner_kind: String,
70+ pub name: String,
71+ pub description: String,
72+ pub visibility: String,
73+ pub default_branch: String,
74+ pub size_bytes: i64,
75+ pub is_empty: bool,
76+ pub archived: bool,
77+ pub created_at: DateTime<Utc>,
78+ pub updated_at: DateTime<Utc>,
79+ pub pushed_at: Option<DateTime<Utc>>,
80+}
81+
82+/// `select ... from repos r join accounts a` yielding `Repo` rows; append
83+/// conditions with `concat!(repo_select!(), " where ...")`.
84+#[macro_export]
85+macro_rules! repo_select {
86+ () => {
87+ "select r.id, r.owner_id, a.name as owner_name, a.kind as owner_kind, r.name, r.description, r.visibility, \
88+ r.default_branch, r.size_bytes, r.is_empty, r.archived, r.created_at, r.updated_at, r.pushed_at \
89+ from repos r join accounts a on a.id = r.owner_id"
90+ };
91+}
92+
93+impl Repo {
94+ /// Looks a repo up by URL path segments. A trailing ".git" is ignored.
95+ pub async fn by_path(db: &PgPool, owner: &str, name: &str) -> sqlx::Result<Option<Self>> {
96+ let name = name.strip_suffix(".git").unwrap_or(name);
97+ sqlx::query_as(concat!(repo_select!(), " where a.name = $1 and r.name = $2"))
98+ .bind(owner)
99+ .bind(name)
100+ .fetch_optional(db)
101+ .await
102+ }
103+
104+ pub async fn by_id(db: &PgPool, id: i64) -> sqlx::Result<Option<Self>> {
105+ sqlx::query_as(concat!(repo_select!(), " where r.id = $1"))
106+ .bind(id)
107+ .fetch_optional(db)
108+ .await
109+ }
110+
111+ pub fn is_public(&self) -> bool {
112+ self.visibility == "public"
113+ }
114+
115+ pub fn full_name(&self) -> String {
116+ format!("{}/{}", self.owner_name, self.name)
117+ }
118+
119+ pub fn url(&self) -> String {
120+ format!("/{}/{}", self.owner_name, self.name)
121+ }
122+
123+ /// Repos are stored by id, so renames and transfers never move files.
124+ pub fn disk_path(&self, config: &Config) -> PathBuf {
125+ repo_disk_path(config, self.id)
126+ }
127+}
128+
129+pub fn repo_disk_path(config: &Config, id: i64) -> PathBuf {
130+ config.repos_dir().join(format!("{id}.git"))
131+}
132+
133+#[derive(Debug, Clone, FromRow, Serialize)]
134+pub struct Package {
135+ pub id: i64,
136+ pub owner_id: i64,
137+ pub owner_name: String,
138+ pub owner_kind: String,
139+ pub name: String,
140+ pub visibility: String,
141+ pub repo_id: Option<i64>,
142+ pub description: String,
143+ pub pull_count: i64,
144+ pub created_at: DateTime<Utc>,
145+ pub updated_at: DateTime<Utc>,
146+}
147+
148+/// `select ... from packages p join accounts a` yielding `Package` rows.
149+#[macro_export]
150+macro_rules! package_select {
151+ () => {
152+ "select p.id, p.owner_id, a.name as owner_name, a.kind as owner_kind, p.name, p.visibility, p.repo_id, \
153+ p.description, p.pull_count, p.created_at, p.updated_at from packages p join accounts a on a.id = p.owner_id"
154+ };
155+}
156+
157+impl Package {
158+ pub async fn by_path(db: &PgPool, owner: &str, name: &str) -> sqlx::Result<Option<Self>> {
159+ sqlx::query_as(concat!(package_select!(), " where a.name = $1 and p.name = $2"))
160+ .bind(owner)
161+ .bind(name.to_ascii_lowercase())
162+ .fetch_optional(db)
163+ .await
164+ }
165+
166+ pub async fn by_id(db: &PgPool, id: i64) -> sqlx::Result<Option<Self>> {
167+ sqlx::query_as(concat!(package_select!(), " where p.id = $1"))
168+ .bind(id)
169+ .fetch_optional(db)
170+ .await
171+ }
172+
173+ pub fn is_public(&self) -> bool {
174+ self.visibility == "public"
175+ }
176+
177+ /// "owner/name", the image path after the registry host.
178+ pub fn full_name(&self) -> String {
179+ format!("{}/{}", self.owner_name, self.name)
180+ }
181+
182+ pub fn url(&self) -> String {
183+ format!("/{}/-/packages/{}", self.owner_name, self.name)
184+ }
185+}
186+
187+/// Records something an administrator may want to trace later.
188+pub async fn audit(db: &PgPool, actor: Option<i64>, action: &str, target: &str, meta: serde_json::Value, ip: Option<&str>) {
189+ let result = sqlx::query("insert into audit_log (actor_id, action, target, meta, ip) values ($1, $2, $3, $4, $5)")
190+ .bind(actor)
191+ .bind(action)
192+ .bind(target)
193+ .bind(meta)
194+ .bind(ip)
195+ .execute(db)
196+ .await;
197+ if let Err(error) = result {
198+ tracing::warn!(%error, action, "could not write audit log");
199+ }
200+}
201+
202+/// Names: lowercase letters, digits and single hyphens, 1 to 39 characters,
203+/// so they work in URLs, SSH paths and Docker image names unchanged.
204+pub fn valid_account_name(name: &str) -> Result<(), &'static str> {
205+ if name.is_empty() || name.len() > 39 {
206+ return Err("Names must be 1 to 39 characters.");
207+ }
208+ if !name.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') {
209+ return Err("Names may only contain lowercase letters, digits and hyphens.");
210+ }
211+ if name.starts_with('-') || name.ends_with('-') || name.contains("--") {
212+ return Err("Names cannot start or end with a hyphen or contain two in a row.");
213+ }
214+ if RESERVED_NAMES.contains(&name) {
215+ return Err("That name is reserved.");
216+ }
217+ Ok(())
218+}
219+
220+/// Top-level paths the site uses itself.
221+pub const RESERVED_NAMES: &[&str] = &[
222+ "about", "admin", "api", "assets", "avatars", "dashboard", "dev-ws", "docs", "download", "explore", "help",
223+ "install", "join", "login", "logout", "new", "notifications", "organizations", "orgs", "register", "repos",
224+ "search", "settings", "signup", "site", "static", "status", "support", "users", "v2", "www", "irongit",
225+ "root", "git", "ssh", "lfs", "registry", "security", "terms", "privacy", "favicon", "robots", "sitemap",
226+];
227+
228+/// Repo names: letters, digits, '.', '_' and '-', up to 100 characters.
229+pub fn valid_repo_name(name: &str) -> Result<(), &'static str> {
230+ if name.is_empty() || name.len() > 100 {
231+ return Err("Repository names must be 1 to 100 characters.");
232+ }
233+ if !name.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) {
234+ return Err("Repository names may only contain letters, digits, '.', '_' and '-'.");
235+ }
236+ if name.starts_with('.') || name.ends_with(".git") || name == "-" {
237+ return Err("Repository names cannot start with '.' or end with '.git'.");
238+ }
239+ Ok(())
240+}
241+
242+/// Image names below the owner: OCI path components joined by '/'.
243+pub fn valid_package_name(name: &str) -> Result<(), &'static str> {
244+ static PATTERN: once_cell::sync::Lazy<regex::Regex> = once_cell::sync::Lazy::new(|| {
245+ regex::Regex::new(r"^[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*(?:/[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*)*$").unwrap()
246+ });
247+ if name.is_empty() || name.len() > 200 || !PATTERN.is_match(name) {
248+ return Err("Image names must be lowercase path components like 'api' or 'tools/builder'.");
249+ }
250+ Ok(())
251+}
+229-0backend/src/ops.rs
@@ -0,0 +1,229 @@
1+//! Operations shared by the web UI, the JSON API and the admin commands, so
2+//! each rule (names, permissions, quotas, audit) is enforced in one place.
3+
4+use serde_json::json;
5+
6+use crate::{
7+ analytics,
8+ auth::{self, Viewer},
9+ error::{AppError, AppResult},
10+ git::Git,
11+ models::{self, Account, Repo, audit},
12+ perm,
13+ state::AppState,
14+};
15+
16+pub struct NewUser<'a> {
17+ pub username: &'a str,
18+ pub email: &'a str,
19+ /// None for Google-only accounts.
20+ pub password: Option<&'a str>,
21+ pub display_name: &'a str,
22+ /// Set when the email came from a provider that verified it (Google).
23+ pub email_verified: bool,
24+}
25+
26+/// Creates a user. The first user on a fresh install becomes site admin.
27+pub async fn register_user(state: &AppState, new: NewUser<'_>) -> AppResult<i64> {
28+ let username = new.username.trim().to_ascii_lowercase();
29+ models::valid_account_name(&username).map_err(AppError::bad)?;
30+ let email = new.email.trim();
31+ if !valid_email(email) {
32+ return Err(AppError::bad("Enter a valid email address."));
33+ }
34+ let password_hash = match new.password {
35+ Some(password) => {
36+ auth::validate_password(password).map_err(AppError::bad)?;
37+ Some(auth::hash_password_async(password.to_string()).await?)
38+ }
39+ None => None,
40+ };
41+
42+ let mut tx = state.db.begin().await?;
43+ let taken: bool = sqlx::query_scalar("select exists(select 1 from accounts where name = $1)")
44+ .bind(&username)
45+ .fetch_one(&mut *tx)
46+ .await?;
47+ if taken {
48+ return Err(AppError::conflict("That username is taken."));
49+ }
50+ let email_taken: bool = sqlx::query_scalar("select exists(select 1 from emails where email = $1)")
51+ .bind(email)
52+ .fetch_one(&mut *tx)
53+ .await?;
54+ if email_taken {
55+ return Err(AppError::conflict("An account already uses that email. Sign in instead."));
56+ }
57+ let first_user: bool = sqlx::query_scalar("select not exists(select 1 from users)").fetch_one(&mut *tx).await?;
58+ let id: i64 = sqlx::query_scalar("insert into accounts (kind, name, display_name) values ('user', $1, $2) returning id")
59+ .bind(&username)
60+ .bind(new.display_name.trim())
61+ .fetch_one(&mut *tx)
62+ .await?;
63+ sqlx::query("insert into users (account_id, password_hash, is_admin) values ($1, $2, $3)")
64+ .bind(id)
65+ .bind(password_hash)
66+ .bind(first_user)
67+ .execute(&mut *tx)
68+ .await?;
69+ sqlx::query("insert into emails (user_id, email, is_primary, verified_at) values ($1, $2, true, case when $3 then now() end)")
70+ .bind(id)
71+ .bind(email)
72+ .bind(new.email_verified)
73+ .execute(&mut *tx)
74+ .await?;
75+ tx.commit().await?;
76+
77+ if !new.email_verified {
78+ if let Err(error) = send_verification(state, id, email).await {
79+ tracing::warn!(%error, email, "could not send verification email");
80+ }
81+ }
82+ audit(&state.db, Some(id), "user.register", &username, json!({ "first_user": first_user, "google": new.password.is_none() }), None).await;
83+ analytics::track(state, "user_registered", Some(&username), "/register", json!({ "method": if new.password.is_some() { "password" } else { "google" } }));
84+ tracing::info!(user = %username, admin = first_user, "user registered");
85+ Ok(id)
86+}
87+
88+pub fn valid_email(email: &str) -> bool {
89+ let Some((local, domain)) = email.split_once('@') else { return false };
90+ !local.is_empty() && domain.contains('.') && !domain.starts_with('.') && !domain.ends_with('.') && email.len() <= 254 && !email.contains(char::is_whitespace)
91+}
92+
93+/// Emails a verification link. Without SMTP the link is written to the log.
94+pub async fn send_verification(state: &AppState, user_id: i64, email: &str) -> anyhow::Result<()> {
95+ let token = auth::random_token(24);
96+ sqlx::query("update emails set verify_token_hash = $3, verify_sent_at = now() where user_id = $1 and email = $2")
97+ .bind(user_id)
98+ .bind(email)
99+ .bind(auth::sha256_hex(&token))
100+ .execute(&state.db)
101+ .await?;
102+ let link = format!("{}/settings/emails/verify?token={token}", state.config.base_url());
103+ crate::mail::send(
104+ state,
105+ email,
106+ "Verify your email for irongit",
107+ &format!("Confirm this address so commits made with it count on your profile:\n\n{link}\n\nIf you did not sign up, ignore this email."),
108+ )
109+ .await?;
110+ tracing::info!(email, %link, "verification link issued");
111+ Ok(())
112+}
113+
114+/// Creates a repository row and its bare repo on disk.
115+pub async fn create_repo(state: &AppState, actor: &Viewer, owner: &Account, name: &str, description: &str, visibility: &str) -> AppResult<Repo> {
116+ let name = name.trim();
117+ models::valid_repo_name(name).map_err(AppError::bad)?;
118+ if !matches!(visibility, "public" | "private") {
119+ return Err(AppError::bad("Visibility must be public or private."));
120+ }
121+ if !perm::can_create_under(&state.db, actor, owner).await? {
122+ return Err(AppError::forbidden(format!("You cannot create repositories under {}.", owner.name)));
123+ }
124+ let description: String = description.trim().chars().take(350).collect();
125+ let id: i64 = match sqlx::query_scalar(
126+ "insert into repos (owner_id, name, description, visibility) values ($1, $2, $3, $4) returning id",
127+ )
128+ .bind(owner.id)
129+ .bind(name)
130+ .bind(&description)
131+ .bind(visibility)
132+ .fetch_one(&state.db)
133+ .await
134+ {
135+ Ok(id) => id,
136+ Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
137+ return Err(AppError::conflict(format!("{}/{name} already exists.", owner.name)));
138+ }
139+ Err(e) => return Err(e.into()),
140+ };
141+
142+ let path = models::repo_disk_path(&state.config, id);
143+ if let Err(error) = Git::init_bare(&path, "main").await {
144+ sqlx::query("delete from repos where id = $1").bind(id).execute(&state.db).await?;
145+ return Err(AppError::Internal(error.context("initializing repository on disk")));
146+ }
147+ let repo = Repo::by_id(&state.db, id).await?.ok_or(AppError::NotFound)?;
148+ audit(&state.db, Some(actor.id), "repo.create", &repo.full_name(), json!({ "visibility": visibility }), None).await;
149+ analytics::track(state, "repo_created", Some(&actor.name), &repo.url(), json!({ "visibility": visibility, "owner_kind": owner.kind }));
150+ tracing::info!(repo = %repo.full_name(), actor = %actor.name, "repository created");
151+ Ok(repo)
152+}
153+
154+/// Deletes a repository: the row first (so it vanishes immediately), then
155+/// the files in the background.
156+pub async fn delete_repo(state: &AppState, actor: &Viewer, repo: &Repo) -> AppResult<()> {
157+ let access = perm::repo_access(&state.db, Some(actor), repo, perm::Area::Repo).await?;
158+ if !access.is_admin() {
159+ return Err(AppError::forbidden("Only repository admins can delete it."));
160+ }
161+ sqlx::query("delete from repos where id = $1").bind(repo.id).execute(&state.db).await?;
162+ let path = repo.disk_path(&state.config);
163+ let trash = state.config.data_dir.join("trash").join(format!("{}-{}", repo.id, chrono::Utc::now().timestamp()));
164+ tokio::spawn(async move {
165+ if let Some(parent) = trash.parent() {
166+ let _ = tokio::fs::create_dir_all(parent).await;
167+ }
168+ let target = if tokio::fs::rename(&path, &trash).await.is_ok() { trash } else { path };
169+ if let Err(error) = tokio::fs::remove_dir_all(&target).await {
170+ tracing::warn!(%error, path = %target.display(), "could not remove deleted repository files");
171+ }
172+ });
173+ audit(&state.db, Some(actor.id), "repo.delete", &repo.full_name(), json!({}), None).await;
174+ analytics::track(state, "repo_deleted", Some(&actor.name), &repo.url(), json!({}));
175+ Ok(())
176+}
177+
178+pub async fn set_repo_visibility(state: &AppState, actor: &Viewer, repo: &Repo, visibility: &str) -> AppResult<()> {
179+ if !matches!(visibility, "public" | "private") {
180+ return Err(AppError::bad("Visibility must be public or private."));
181+ }
182+ let access = perm::repo_access(&state.db, Some(actor), repo, perm::Area::Repo).await?;
183+ if !access.is_admin() {
184+ return Err(AppError::forbidden("Only repository admins can change visibility."));
185+ }
186+ sqlx::query("update repos set visibility = $2, updated_at = now() where id = $1")
187+ .bind(repo.id)
188+ .bind(visibility)
189+ .execute(&state.db)
190+ .await?;
191+ audit(&state.db, Some(actor.id), "repo.visibility", &repo.full_name(), json!({ "visibility": visibility }), None).await;
192+ analytics::track(state, "repo_visibility_changed", Some(&actor.name), &repo.url(), json!({ "visibility": visibility }));
193+ Ok(())
194+}
195+
196+/// Creates an organization with `actor` as its first owner.
197+pub async fn create_org(state: &AppState, actor: &Viewer, name: &str, display_name: &str) -> AppResult<Account> {
198+ let name = name.trim().to_ascii_lowercase();
199+ models::valid_account_name(&name).map_err(AppError::bad)?;
200+ let mut tx = state.db.begin().await?;
201+ let id: i64 = match sqlx::query_scalar("insert into accounts (kind, name, display_name) values ('org', $1, $2) returning id")
202+ .bind(&name)
203+ .bind(display_name.trim())
204+ .fetch_one(&mut *tx)
205+ .await
206+ {
207+ Ok(id) => id,
208+ Err(sqlx::Error::Database(e)) if e.is_unique_violation() => return Err(AppError::conflict("That name is taken.")),
209+ Err(e) => return Err(e.into()),
210+ };
211+ sqlx::query("insert into org_members (org_id, user_id, role) values ($1, $2, 'owner')")
212+ .bind(id)
213+ .bind(actor.id)
214+ .execute(&mut *tx)
215+ .await?;
216+ tx.commit().await?;
217+ audit(&state.db, Some(actor.id), "org.create", &name, json!({}), None).await;
218+ analytics::track(state, "org_created", Some(&actor.name), &format!("/{name}"), json!({}));
219+ Account::by_id(&state.db, id).await?.ok_or(AppError::NotFound)
220+}
221+
222+/// Bytes an account uses across its repositories, for quota checks.
223+pub async fn account_usage(state: &AppState, account_id: i64) -> AppResult<u64> {
224+ let used: Option<i64> = sqlx::query_scalar("select sum(size_bytes)::bigint from repos where owner_id = $1")
225+ .bind(account_id)
226+ .fetch_one(&state.db)
227+ .await?;
228+ Ok(used.unwrap_or(0).max(0) as u64)
229+}
+161-0backend/src/perm.rs
@@ -0,0 +1,161 @@
1+//! Every access decision goes through here: web pages, the API, git over
2+//! HTTP and SSH, LFS, the registry, raw files and archives. Keeping one
3+//! implementation is what stops a private repo leaking through a side door.
4+
5+use sqlx::PgPool;
6+
7+use crate::{
8+ auth::Viewer,
9+ models::{Account, Package, Repo},
10+};
11+
12+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
13+pub enum Access {
14+ None,
15+ Read,
16+ Write,
17+ Admin,
18+}
19+
20+impl Access {
21+ pub fn can_read(self) -> bool {
22+ self >= Access::Read
23+ }
24+
25+ pub fn can_write(self) -> bool {
26+ self >= Access::Write
27+ }
28+
29+ pub fn is_admin(self) -> bool {
30+ self == Access::Admin
31+ }
32+
33+ fn from_permission(permission: &str) -> Self {
34+ match permission {
35+ "admin" => Access::Admin,
36+ "write" => Access::Write,
37+ "read" => Access::Read,
38+ _ => Access::None,
39+ }
40+ }
41+}
42+
43+/// Which credential scope an operation needs. A token without the scope is
44+/// treated as if nobody were signed in.
45+#[derive(Debug, Clone, Copy)]
46+pub enum Area {
47+ Repo,
48+ Packages,
49+}
50+
51+fn scoped(viewer: Option<&Viewer>, area: Area) -> Option<&Viewer> {
52+ viewer.filter(|v| match area {
53+ Area::Repo => v.scopes.repo,
54+ Area::Packages => v.scopes.packages,
55+ })
56+}
57+
58+/// What `viewer` may do with everything an account owns, before per-repo
59+/// grants: admin for yourself, your org as owner, or a site admin; write as
60+/// an org member.
61+pub async fn owner_access(db: &PgPool, viewer: Option<&Viewer>, owner_id: i64) -> sqlx::Result<Access> {
62+ let Some(viewer) = viewer else { return Ok(Access::None) };
63+ if viewer.site_admin() || viewer.id == owner_id {
64+ return Ok(Access::Admin);
65+ }
66+ let role: Option<String> = sqlx::query_scalar("select role from org_members where org_id = $1 and user_id = $2")
67+ .bind(owner_id)
68+ .bind(viewer.id)
69+ .fetch_optional(db)
70+ .await?;
71+ Ok(match role.as_deref() {
72+ Some("owner") => Access::Admin,
73+ Some("member") => Access::Write,
74+ _ => Access::None,
75+ })
76+}
77+
78+/// Whether `viewer` may create repos and images under `owner`.
79+pub async fn can_create_under(db: &PgPool, viewer: &Viewer, owner: &Account) -> sqlx::Result<bool> {
80+ Ok(owner_access(db, Some(viewer), owner.id).await?.can_write())
81+}
82+
83+pub async fn repo_access(db: &PgPool, viewer: Option<&Viewer>, repo: &Repo, area: Area) -> sqlx::Result<Access> {
84+ let viewer = scoped(viewer, area);
85+ let mut access = owner_access(db, viewer, repo.owner_id).await?;
86+ if access < Access::Admin {
87+ if let Some(viewer) = viewer {
88+ let permission: Option<String> =
89+ sqlx::query_scalar("select permission from repo_collaborators where repo_id = $1 and user_id = $2")
90+ .bind(repo.id)
91+ .bind(viewer.id)
92+ .fetch_optional(db)
93+ .await?;
94+ if let Some(permission) = permission {
95+ access = access.max(Access::from_permission(&permission));
96+ }
97+ }
98+ }
99+ if repo.is_public() {
100+ access = access.max(Access::Read);
101+ }
102+ // Archived repos are read-only for everyone; admins can still unarchive.
103+ if repo.archived && access == Access::Write {
104+ access = Access::Read;
105+ }
106+ Ok(access)
107+}
108+
109+/// Image access is independent of code visibility. A linked repo only adds
110+/// grants for its owner-side people and collaborators, never "public".
111+pub async fn package_access(db: &PgPool, viewer: Option<&Viewer>, package: &Package) -> sqlx::Result<Access> {
112+ let viewer = scoped(viewer, Area::Packages);
113+ let mut access = owner_access(db, viewer, package.owner_id).await?;
114+ if access < Access::Admin {
115+ if let (Some(viewer), Some(repo_id)) = (viewer, package.repo_id) {
116+ let permission: Option<String> =
117+ sqlx::query_scalar("select permission from repo_collaborators where repo_id = $1 and user_id = $2")
118+ .bind(repo_id)
119+ .bind(viewer.id)
120+ .fetch_optional(db)
121+ .await?;
122+ if let Some(permission) = permission {
123+ access = access.max(Access::from_permission(&permission));
124+ }
125+ }
126+ }
127+ if package.is_public() {
128+ access = access.max(Access::Read);
129+ }
130+ Ok(access)
131+}
132+
133+/// SQL condition limiting `repos r` to what a viewer may read, for listings:
134+/// `concat!(repo_select!(), " where ", visible_repos!(), " and ...")`.
135+/// Binds: $1 = viewer id (or null), $2 = site admin flag. Use `visibility_binds`.
136+#[macro_export]
137+macro_rules! visible_repos {
138+ () => {
139+ "(r.visibility = 'public' or $2 or r.owner_id = $1 \
140+ or exists (select 1 from org_members m where m.org_id = r.owner_id and m.user_id = $1) \
141+ or exists (select 1 from repo_collaborators c where c.repo_id = r.id and c.user_id = $1))"
142+ };
143+}
144+
145+/// Same as `visible_repos!` for `packages p`.
146+#[macro_export]
147+macro_rules! visible_packages {
148+ () => {
149+ "(p.visibility = 'public' or $2 or p.owner_id = $1 \
150+ or exists (select 1 from org_members m where m.org_id = p.owner_id and m.user_id = $1) \
151+ or (p.repo_id is not null and exists (select 1 from repo_collaborators c where c.repo_id = p.repo_id and c.user_id = $1)))"
152+ };
153+}
154+
155+/// The ($1, $2) binds for the listing fragments above.
156+pub fn visibility_binds(viewer: Option<&Viewer>, area: Area) -> (Option<i64>, bool) {
157+ match scoped(viewer, area) {
158+ Some(v) => (Some(v.id), v.site_admin()),
159+ None => (None, false),
160+ }
161+}
+193-0backend/src/push.rs
@@ -0,0 +1,193 @@
1+//! What happens after a push lands: activity events, the repo's size and
2+//! default branch, and contribution counts for the heatmap.
3+//!
4+//! Called with the ref snapshot taken before receive-pack ran, so it works
5+//! the same for HTTP and SSH and needs no post-receive hook.
6+
7+use std::collections::{BTreeMap, HashMap};
8+
9+use serde_json::json;
10+
11+use crate::{
12+ analytics,
13+ auth::Viewer,
14+ git::{Git, ZERO_SHA},
15+ models::Repo,
16+ state::AppState,
17+};
18+
19+/// Contributions considered per push; a huge first push is capped.
20+const MAX_CONTRIBUTION_COMMITS: usize = 50_000;
21+
22+#[derive(Debug, Clone)]
23+pub struct RefUpdate {
24+ pub name: String,
25+ pub old: String,
26+ pub new: String,
27+}
28+
29+pub fn diff_refs(before: &BTreeMap<String, String>, after: &BTreeMap<String, String>) -> Vec<RefUpdate> {
30+ let mut updates = Vec::new();
31+ for (name, new) in after {
32+ match before.get(name) {
33+ Some(old) if old == new => {}
34+ Some(old) => updates.push(RefUpdate { name: name.clone(), old: old.clone(), new: new.clone() }),
35+ None => updates.push(RefUpdate { name: name.clone(), old: ZERO_SHA.into(), new: new.clone() }),
36+ }
37+ }
38+ for (name, old) in before {
39+ if !after.contains_key(name) {
40+ updates.push(RefUpdate { name: name.clone(), old: old.clone(), new: ZERO_SHA.into() });
41+ }
42+ }
43+ updates
44+}
45+
46+/// Runs in the background after receive-pack exits successfully.
47+pub fn spawn_after_push(state: AppState, repo: Repo, pusher: Option<Viewer>, before: BTreeMap<String, String>, via: &'static str) {
48+ tokio::spawn(async move {
49+ if let Err(error) = after_push(&state, &repo, pusher.as_ref(), &before, via).await {
50+ tracing::error!(repo = %repo.full_name(), error = ?error, "post-push processing failed");
51+ }
52+ });
53+}
54+
55+pub async fn after_push(state: &AppState, repo: &Repo, pusher: Option<&Viewer>, before: &BTreeMap<String, String>, via: &str) -> anyhow::Result<()> {
56+ let git = Git::new(repo.disk_path(&state.config));
57+ let after = git.ref_snapshot().await?;
58+ let updates = diff_refs(before, &after);
59+ if updates.is_empty() {
60+ return Ok(());
61+ }
62+
63+ // A first push of "master" into a repo whose default is "main" should
64+ // make "master" the default, like every other forge does.
65+ let mut default_branch = repo.default_branch.clone();
66+ let has_default = after.contains_key(&format!("refs/heads/{default_branch}"));
67+ let branches: Vec<&str> = after.keys().filter_map(|k| k.strip_prefix("refs/heads/")).collect();
68+ if !has_default && !branches.is_empty() {
69+ let pick = ["main", "master", "trunk", "develop"]
70+ .into_iter()
71+ .find(|b| branches.contains(b))
72+ .or_else(|| updates.iter().find_map(|u| u.name.strip_prefix("refs/heads/").filter(|_| u.new != ZERO_SHA)))
73+ .unwrap_or(branches[0])
74+ .to_string();
75+ git.set_head(&pick).await?;
76+ default_branch = pick;
77+ }
78+
79+ let size = git.disk_size().await.unwrap_or(repo.size_bytes as u64);
80+ sqlx::query(
81+ "update repos set pushed_at = now(), updated_at = now(), is_empty = $2, size_bytes = $3, default_branch = $4 where id = $1",
82+ )
83+ .bind(repo.id)
84+ .bind(branches.is_empty())
85+ .bind(size as i64)
86+ .bind(&default_branch)
87+ .execute(&state.db)
88+ .await?;
89+
90+ let default_ref = format!("refs/heads/{default_branch}");
91+ let mut total_commits = 0usize;
92+ for update in &updates {
93+ let (count, subject) = if update.new == ZERO_SHA {
94+ (0, String::new())
95+ } else {
96+ let commits = git.new_commits(&update.old, &update.new, 10_000).await.unwrap_or_default();
97+ let subject = git.commit(&update.new).await.ok().flatten().map(|c| c.subject).unwrap_or_default();
98+ (commits.len(), subject)
99+ };
100+ total_commits += count;
101+ sqlx::query(
102+ "insert into push_events (repo_id, pusher_id, ref_name, before_sha, after_sha, commit_count, head_message, via)
103+ values ($1, $2, $3, $4, $5, $6, $7, $8)",
104+ )
105+ .bind(repo.id)
106+ .bind(pusher.map(|p| p.id))
107+ .bind(&update.name)
108+ .bind(&update.old)
109+ .bind(&update.new)
110+ .bind(count as i32)
111+ .bind(subject.chars().take(300).collect::<String>())
112+ .bind(via)
113+ .execute(&state.db)
114+ .await?;
115+
116+ if update.name == default_ref && update.new != ZERO_SHA {
117+ record_contributions(state, repo.id, &git, &update.old, &update.new).await?;
118+ }
119+ }
120+
121+ tracing::info!(repo = %repo.full_name(), pusher = pusher.map(|p| p.name.as_str()).unwrap_or("-"), refs = updates.len(), commits = total_commits, via, "push processed");
122+ analytics::track(
123+ state,
124+ "git_push",
125+ pusher.map(|p| p.name.as_str()),
126+ &repo.url(),
127+ json!({ "refs": updates.len(), "commits": total_commits, "via": via, "visibility": repo.visibility }),
128+ );
129+ Ok(())
130+}
131+
132+/// Credits commits that landed on the default branch to users who verified
133+/// the author email. Keyed by (repo, sha), so re-pushes never double count.
134+async fn record_contributions(state: &AppState, repo_id: i64, git: &Git, old: &str, new: &str) -> anyhow::Result<()> {
135+ let commits = git.new_commits(old, new, MAX_CONTRIBUTION_COMMITS).await?;
136+ if commits.is_empty() {
137+ return Ok(());
138+ }
139+ let mut emails: Vec<String> = commits.iter().map(|c| c.author_email.to_lowercase()).collect();
140+ emails.sort();
141+ emails.dedup();
142+ let owners: HashMap<String, i64> = sqlx::query_as::<_, (String, i64)>(
143+ "select lower(email::text), user_id from emails where verified_at is not null and email = any($1::citext[])",
144+ )
145+ .bind(&emails)
146+ .fetch_all(&state.db)
147+ .await?
148+ .into_iter()
149+ .collect();
150+ if owners.is_empty() {
151+ return Ok(());
152+ }
153+
154+ let (mut shas, mut users, mut days) = (Vec::new(), Vec::new(), Vec::new());
155+ for commit in &commits {
156+ if let Some(user) = owners.get(&commit.author_email.to_lowercase()) {
157+ shas.push(commit.sha.clone());
158+ users.push(*user);
159+ days.push(commit.authored_at.date_naive());
160+ }
161+ }
162+ let inserted = sqlx::query(
163+ "insert into contribution_commits (repo_id, sha, user_id, day)
164+ select $1, * from unnest($2::text[], $3::bigint[], $4::date[])
165+ on conflict do nothing",
166+ )
167+ .bind(repo_id)
168+ .bind(&shas)
169+ .bind(&users)
170+ .bind(&days)
171+ .execute(&state.db)
172+ .await?
173+ .rows_affected();
174+ tracing::info!(repo_id, credited = inserted, "contributions recorded");
175+ Ok(())
176+}
177+
178+#[cfg(test)]
179+mod tests {
180+ use super::*;
181+
182+ #[test]
183+ fn diffs_created_updated_deleted() {
184+ let before = BTreeMap::from([("refs/heads/a".to_string(), "1".to_string()), ("refs/heads/b".to_string(), "2".to_string())]);
185+ let after = BTreeMap::from([("refs/heads/a".to_string(), "3".to_string()), ("refs/heads/c".to_string(), "4".to_string())]);
186+ let mut updates = diff_refs(&before, &after);
187+ updates.sort_by(|x, y| x.name.cmp(&y.name));
188+ assert_eq!(updates.len(), 3);
189+ assert_eq!((updates[0].old.as_str(), updates[0].new.as_str()), ("1", "3"));
190+ assert_eq!(updates[1].new, ZERO_SHA);
191+ assert_eq!(updates[2].old, ZERO_SHA);
192+ }
193+}
+13-0backend/src/registry/mod.rs
@@ -0,0 +1,13 @@
1+//! OCI distribution API (Docker registry) at /v2/. Owned by the registry
2+//! work; this stub only keeps the crate compiling.
3+
4+use axum::Router;
5+
6+use crate::state::AppState;
7+
8+pub fn router() -> Router<AppState> {
9+ Router::new()
10+}
11+
12+/// Garbage collection of orphaned blobs and stale uploads.
13+pub fn spawn_gc(_state: AppState) {}
+67-0backend/src/routes.rs
@@ -0,0 +1,67 @@
1+//! The whole HTTP surface, assembled from each module's router.
2+
3+use std::time::Duration;
4+
5+use axum::{
6+ Json, Router,
7+ extract::Request,
8+ http::{HeaderValue, header},
9+ middleware::{self, Next},
10+ response::Response,
11+ routing::get,
12+};
13+use serde_json::json;
14+use tower_http::trace::TraceLayer;
15+use tracing::Span;
16+
17+use crate::{api, clientlog, frontend, git_http, lfs, registry, state::AppState, web};
18+
19+pub fn router(state: AppState) -> Router {
20+ let app = Router::new()
21+ .route("/api/health", get(health))
22+ .merge(api::router())
23+ .merge(clientlog::router())
24+ .merge(registry::router())
25+ .merge(git_http::router())
26+ .merge(lfs::router())
27+ .merge(web::router())
28+ .route("/_astro/{*path}", get(frontend::serve));
29+
30+ #[cfg(feature = "hot-reload")]
31+ let app = app.route("/dev-ws", get(crate::dev::ws_handler));
32+
33+ app.fallback(frontend::serve)
34+ .layer(middleware::from_fn(security_headers))
35+ .layer(
36+ TraceLayer::new_for_http()
37+ .make_span_with(|request: &Request| {
38+ tracing::info_span!("http", method = %request.method(), path = %request.uri().path())
39+ })
40+ .on_request(())
41+ .on_response(|response: &Response, latency: Duration, _span: &Span| {
42+ let status = response.status().as_u16();
43+ let ms = latency.as_millis() as u64;
44+ if status >= 500 {
45+ tracing::error!(status, ms, "request");
46+ } else {
47+ tracing::info!(status, ms, "request");
48+ }
49+ }),
50+ )
51+ .with_state(state)
52+}
53+
54+async fn health() -> Json<serde_json::Value> {
55+ Json(json!({ "status": "ok", "version": env!("CARGO_PKG_VERSION") }))
56+}
57+
58+/// Defaults every response gets unless a handler set its own (raw file
59+/// views set a stricter CSP).
60+async fn security_headers(request: Request, next: Next) -> Response {
61+ let mut response = next.run(request).await;
62+ let headers = response.headers_mut();
63+ headers.entry(header::X_CONTENT_TYPE_OPTIONS).or_insert(HeaderValue::from_static("nosniff"));
64+ headers.entry("x-frame-options").or_insert(HeaderValue::from_static("DENY"));
65+ headers.entry(header::REFERRER_POLICY).or_insert(HeaderValue::from_static("strict-origin-when-cross-origin"));
66+ response
67+}
+64-0backend/src/signed.rs
@@ -0,0 +1,64 @@
1+//! Short-lived signed values: registry bearer tokens, LFS tokens handed out
2+//! over SSH, the OAuth state cookie. `payload.signature`, both base64url,
3+//! HMAC-SHA256 with SECRET_KEY. A `purpose` string is mixed into the MAC so a
4+//! token minted for one use can never be replayed as another.
5+
6+use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
7+use hmac::{KeyInit, Mac};
8+use serde::{Serialize, de::DeserializeOwned};
9+
10+type HmacSha256 = hmac::Hmac<sha2::Sha256>;
11+
12+#[derive(serde::Serialize, serde::Deserialize)]
13+struct Envelope<T> {
14+ exp: i64,
15+ data: T,
16+}
17+
18+fn mac(key: &[u8], purpose: &str, payload: &str) -> Vec<u8> {
19+ let mut mac = HmacSha256::new_from_slice(key).expect("hmac accepts any key length");
20+ mac.update(purpose.as_bytes());
21+ mac.update(b"\0");
22+ mac.update(payload.as_bytes());
23+ mac.finalize().into_bytes().to_vec()
24+}
25+
26+pub fn sign<T: Serialize>(key: &[u8], purpose: &str, data: &T, ttl_secs: i64) -> String {
27+ let envelope = Envelope { exp: chrono::Utc::now().timestamp() + ttl_secs, data };
28+ let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&envelope).expect("serializable"));
29+ let signature = URL_SAFE_NO_PAD.encode(mac(key, purpose, &payload));
30+ format!("{payload}.{signature}")
31+}
32+
33+pub fn verify<T: DeserializeOwned>(key: &[u8], purpose: &str, token: &str) -> Option<T> {
34+ let (payload, signature) = token.split_once('.')?;
35+ let signature = URL_SAFE_NO_PAD.decode(signature).ok()?;
36+ let mut check = HmacSha256::new_from_slice(key).ok()?;
37+ check.update(purpose.as_bytes());
38+ check.update(b"\0");
39+ check.update(payload.as_bytes());
40+ check.verify_slice(&signature).ok()?;
41+ let envelope: Envelope<T> = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload).ok()?).ok()?;
42+ (envelope.exp > chrono::Utc::now().timestamp()).then_some(envelope.data)
43+}
44+
45+#[cfg(test)]
46+mod tests {
47+ use super::*;
48+
49+ #[test]
50+ fn roundtrip_and_purpose_binding() {
51+ let key = [7u8; 32];
52+ let token = sign(&key, "lfs", &("alice", 3), 60);
53+ assert_eq!(verify::<(String, i32)>(&key, "lfs", &token), Some(("alice".into(), 3)));
54+ assert_eq!(verify::<(String, i32)>(&key, "registry", &token), None);
55+ assert_eq!(verify::<(String, i32)>(&[8u8; 32], "lfs", &token), None);
56+ }
57+
58+ #[test]
59+ fn expired_tokens_fail() {
60+ let key = [1u8; 32];
61+ let token = sign(&key, "x", &1, -5);
62+ assert_eq!(verify::<i32>(&key, "x", &token), None);
63+ }
64+}
+5-0backend/src/ssh.rs
@@ -0,0 +1,5 @@
1+//! Built-in SSH server for git. Stub.
2+
3+use crate::state::AppState;
4+
5+pub fn spawn(_state: AppState) {}
+49-0backend/src/sshkeys.rs
@@ -0,0 +1,49 @@
1+//! Parsing user-supplied SSH public keys (settings page and API).
2+
3+use russh::keys::{HashAlg, PublicKey};
4+
5+pub struct ParsedKey {
6+ /// "algorithm base64", without the comment.
7+ pub normalized: String,
8+ /// "SHA256:...", what `ssh-keygen -lf` prints and what the SSH server looks up.
9+ pub fingerprint: String,
10+ pub algorithm: String,
11+ pub comment: String,
12+}
13+
14+pub fn parse_public_key(text: &str) -> Result<ParsedKey, String> {
15+ let line = text.lines().map(str::trim).find(|l| !l.is_empty() && !l.starts_with('#')).ok_or("Paste a public key.")?;
16+ if line.contains("PRIVATE KEY") {
17+ return Err("That is a private key. Paste the .pub file instead, and keep the private key secret.".into());
18+ }
19+ let key = PublicKey::from_openssh(line).map_err(|e| format!("Not a valid OpenSSH public key ({e})."))?;
20+ let algorithm = key.algorithm().to_string();
21+ if algorithm == "ssh-dss" {
22+ return Err("DSA keys are not accepted; use ed25519.".into());
23+ }
24+ let comment = key.comment().to_string();
25+ let mut bare = key.clone();
26+ bare.set_comment("");
27+ let normalized = bare.to_openssh().map_err(|e| e.to_string())?.trim().to_string();
28+ Ok(ParsedKey { fingerprint: key.fingerprint(HashAlg::Sha256).to_string(), normalized, algorithm, comment })
29+}
30+
31+#[cfg(test)]
32+mod tests {
33+ use super::*;
34+
35+ #[test]
36+ fn parses_ed25519() {
37+ let key = parse_public_key("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJdD7y3aLq454yWBdwLWbieU1ebz9/cu7/QEXn9OIeZJ alice@laptop\n").unwrap();
38+ assert!(key.fingerprint.starts_with("SHA256:"));
39+ assert_eq!(key.comment, "alice@laptop");
40+ assert!(key.normalized.starts_with("ssh-ed25519 AAAA"));
41+ assert!(!key.normalized.contains("alice"));
42+ }
43+
44+ #[test]
45+ fn rejects_garbage_and_private_keys() {
46+ assert!(parse_public_key("hello").is_err());
47+ assert!(parse_public_key("-----BEGIN OPENSSH PRIVATE KEY-----").is_err());
48+ }
49+}
+21-0backend/src/state.rs
@@ -0,0 +1,21 @@
1+//! Shared application state, cloned into every handler.
2+
3+use std::sync::Arc;
4+
5+use sqlx::PgPool;
6+use tokio::sync::broadcast;
7+
8+use crate::{config::Config, storage::Storage};
9+
10+#[derive(Clone)]
11+pub struct AppState {
12+ pub db: PgPool,
13+ pub config: Arc<Config>,
14+ pub storage: Storage,
15+ /// Outbound HTTP (analytics, email APIs). R2 has its own client.
16+ pub http: reqwest::Client,
17+ /// Fan-out to the browsers connected to `/dev-ws`. Debug builds only;
18+ /// nothing sends on it in a release build.
19+ #[allow(dead_code)]
20+ pub reload_tx: broadcast::Sender<()>,
21+}
+254-0backend/src/storage.rs
@@ -0,0 +1,254 @@
1+//! R2 through its S3 API. Requests are presigned with rusty-s3 and sent with
2+//! reqwest, so the same signer produces both the URLs the server calls and
3+//! the short-lived URLs it hands to clients (LFS, image layer pulls, CLI
4+//! downloads) so their bytes never pass through this server.
5+
6+use std::{path::Path, time::Duration};
7+
8+use anyhow::{Context, bail};
9+use bytes::Bytes;
10+use futures::{StreamExt, TryStreamExt, stream};
11+use reqwest::{Body, StatusCode};
12+use rusty_s3::{Bucket, Credentials, S3Action, UrlStyle, actions::ListObjectsV2};
13+use tokio::io::AsyncReadExt;
14+use url::Url;
15+
16+use crate::config::R2Config;
17+
18+/// Objects up to this size go up in one PUT; larger ones use multipart.
19+const SINGLE_PUT_MAX: u64 = 64 * 1024 * 1024;
20+/// R2 requires every part except the last to be the same size.
21+const PART_SIZE: u64 = 32 * 1024 * 1024;
22+const PART_CONCURRENCY: usize = 4;
23+/// How long URLs the server signs for its own requests stay valid.
24+const SELF_TTL: Duration = Duration::from_secs(15 * 60);
25+
26+#[derive(Clone)]
27+pub struct Storage {
28+ bucket: Bucket,
29+ credentials: Credentials,
30+ http: reqwest::Client,
31+}
32+
33+pub struct ObjectInfo {
34+ pub key: String,
35+ pub size: u64,
36+ pub last_modified: String,
37+}
38+
39+impl Storage {
40+ pub fn new(config: &R2Config) -> anyhow::Result<Self> {
41+ let endpoint: Url = config.endpoint.parse().context("R2 endpoint is not a URL")?;
42+ let bucket = Bucket::new(endpoint, UrlStyle::Path, config.bucket.clone(), "auto")
43+ .context("invalid R2 bucket configuration")?;
44+ let credentials = Credentials::new(config.access_key_id.clone(), config.secret_access_key.clone());
45+ let http = reqwest::Client::builder()
46+ .connect_timeout(Duration::from_secs(10))
47+ .build()?;
48+ Ok(Self { bucket, credentials, http })
49+ }
50+
51+ pub fn bucket_name(&self) -> &str {
52+ self.bucket.name()
53+ }
54+
55+ /// A URL anyone can GET for `ttl`. `filename` sets Content-Disposition.
56+ pub fn presign_get(&self, key: &str, ttl: Duration, filename: Option<&str>) -> Url {
57+ let mut action = self.bucket.get_object(Some(&self.credentials), key);
58+ if let Some(name) = filename {
59+ action
60+ .query_mut()
61+ .insert("response-content-disposition", format!("attachment; filename=\"{name}\""));
62+ }
63+ action.sign(ttl)
64+ }
65+
66+ /// A URL anyone can PUT a body to for `ttl`.
67+ pub fn presign_put(&self, key: &str, ttl: Duration) -> Url {
68+ self.bucket.put_object(Some(&self.credentials), key).sign(ttl)
69+ }
70+
71+ /// Size of an object, or None when it does not exist.
72+ pub async fn head(&self, key: &str) -> anyhow::Result<Option<u64>> {
73+ let url = self.bucket.head_object(Some(&self.credentials), key).sign(SELF_TTL);
74+ let response = self.http.head(url).send().await?;
75+ match response.status() {
76+ StatusCode::NOT_FOUND => Ok(None),
77+ // content_length() reports the (empty) HEAD body, so read the header.
78+ status if status.is_success() => Ok(response
79+ .headers()
80+ .get(reqwest::header::CONTENT_LENGTH)
81+ .and_then(|v| v.to_str().ok()?.parse().ok())),
82+ status => bail!("R2 HEAD {key} failed: {status}"),
83+ }
84+ }
85+
86+ pub async fn get_bytes(&self, key: &str) -> anyhow::Result<Option<Bytes>> {
87+ let url = self.bucket.get_object(Some(&self.credentials), key).sign(SELF_TTL);
88+ let response = self.http.get(url).send().await?;
89+ match response.status() {
90+ StatusCode::NOT_FOUND => Ok(None),
91+ status if status.is_success() => Ok(Some(response.bytes().await?)),
92+ status => bail!("R2 GET {key} failed: {status}"),
93+ }
94+ }
95+
96+ pub async fn put_bytes(&self, key: &str, body: Bytes, content_type: &str) -> anyhow::Result<()> {
97+ let url = self.bucket.put_object(Some(&self.credentials), key).sign(SELF_TTL);
98+ let response = self
99+ .http
100+ .put(url)
101+ .header(reqwest::header::CONTENT_TYPE, content_type)
102+ .body(body)
103+ .send()
104+ .await?;
105+ if !response.status().is_success() {
106+ bail!("R2 PUT {key} failed: {} {}", response.status(), response.text().await.unwrap_or_default());
107+ }
108+ Ok(())
109+ }
110+
111+ /// Uploads a local file, switching to multipart above 64 MiB.
112+ pub async fn put_file(&self, key: &str, path: &Path) -> anyhow::Result<u64> {
113+ let size = tokio::fs::metadata(path).await?.len();
114+ if size <= SINGLE_PUT_MAX {
115+ let file = tokio::fs::File::open(path).await?;
116+ let url = self.bucket.put_object(Some(&self.credentials), key).sign(SELF_TTL);
117+ let response = self
118+ .http
119+ .put(url)
120+ .header(reqwest::header::CONTENT_LENGTH, size)
121+ .body(Body::wrap_stream(tokio_util::io::ReaderStream::new(file)))
122+ .send()
123+ .await?;
124+ if !response.status().is_success() {
125+ bail!("R2 PUT {key} failed: {} {}", response.status(), response.text().await.unwrap_or_default());
126+ }
127+ return Ok(size);
128+ }
129+ self.put_file_multipart(key, path, size).await?;
130+ Ok(size)
131+ }
132+
133+ async fn put_file_multipart(&self, key: &str, path: &Path, size: u64) -> anyhow::Result<()> {
134+ let url = self.bucket.create_multipart_upload(Some(&self.credentials), key).sign(SELF_TTL);
135+ let response = self.http.post(url).send().await?.error_for_status()?;
136+ let created = rusty_s3::actions::CreateMultipartUpload::parse_response(&response.text().await?)
137+ .map_err(|e| anyhow::anyhow!("bad CreateMultipartUpload response: {e}"))?;
138+ let upload_id = created.upload_id().to_string();
139+
140+ let parts = size.div_ceil(PART_SIZE);
141+ let result: anyhow::Result<Vec<String>> = stream::iter(0..parts)
142+ .map(|index| {
143+ let upload_id = upload_id.clone();
144+ async move {
145+ let offset = index * PART_SIZE;
146+ let length = PART_SIZE.min(size - offset);
147+ let mut file = tokio::fs::File::open(path).await?;
148+ tokio::io::AsyncSeekExt::seek(&mut file, std::io::SeekFrom::Start(offset)).await?;
149+ let mut buffer = Vec::with_capacity(length as usize);
150+ file.take(length).read_to_end(&mut buffer).await?;
151+ let url = self
152+ .bucket
153+ .upload_part(Some(&self.credentials), key, (index + 1) as u16, &upload_id)
154+ .sign(SELF_TTL);
155+ let response = self.http.put(url).body(buffer).send().await?.error_for_status()?;
156+ let etag = response
157+ .headers()
158+ .get(reqwest::header::ETAG)
159+ .and_then(|v| v.to_str().ok())
160+ .context("R2 part upload returned no ETag")?
161+ .to_string();
162+ anyhow::Ok(etag)
163+ }
164+ })
165+ .buffered(PART_CONCURRENCY)
166+ .try_collect()
167+ .await;
168+
169+ let etags = match result {
170+ Ok(etags) => etags,
171+ Err(error) => {
172+ let abort = self
173+ .bucket
174+ .abort_multipart_upload(Some(&self.credentials), key, &upload_id)
175+ .sign(SELF_TTL);
176+ let _ = self.http.delete(abort).send().await;
177+ return Err(error);
178+ }
179+ };
180+
181+ let complete = self.bucket.complete_multipart_upload(
182+ Some(&self.credentials),
183+ key,
184+ &upload_id,
185+ etags.iter().map(String::as_str),
186+ );
187+ let url = complete.sign(SELF_TTL);
188+ let response = self.http.post(url).body(complete.body()).send().await?;
189+ if !response.status().is_success() {
190+ bail!("R2 complete multipart {key} failed: {} {}", response.status(), response.text().await.unwrap_or_default());
191+ }
192+ Ok(())
193+ }
194+
195+ pub async fn delete(&self, key: &str) -> anyhow::Result<()> {
196+ let url = self.bucket.delete_object(Some(&self.credentials), key).sign(SELF_TTL);
197+ let response = self.http.delete(url).send().await?;
198+ if !(response.status().is_success() || response.status() == StatusCode::NOT_FOUND) {
199+ bail!("R2 DELETE {key} failed: {}", response.status());
200+ }
201+ Ok(())
202+ }
203+
204+ /// Every object under `prefix`, following continuation tokens.
205+ pub async fn list(&self, prefix: &str) -> anyhow::Result<Vec<ObjectInfo>> {
206+ let mut out = Vec::new();
207+ let mut token: Option<String> = None;
208+ loop {
209+ let mut action = self.bucket.list_objects_v2(Some(&self.credentials));
210+ action.with_prefix(prefix);
211+ if let Some(token) = &token {
212+ action.with_continuation_token(token.clone());
213+ }
214+ let response = self.http.get(action.sign(SELF_TTL)).send().await?.error_for_status()?;
215+ let parsed = ListObjectsV2::parse_response(&response.text().await?)
216+ .map_err(|e| anyhow::anyhow!("bad ListObjectsV2 response: {e}"))?;
217+ out.extend(parsed.contents.into_iter().map(|c| ObjectInfo {
218+ key: c.key,
219+ size: c.size,
220+ last_modified: c.last_modified,
221+ }));
222+ match parsed.next_continuation_token {
223+ Some(next) => token = Some(next),
224+ None => break,
225+ }
226+ }
227+ Ok(out)
228+ }
229+}
230+
231+/// R2 key layout, in one place so nothing writes outside its prefix.
232+pub mod keys {
233+ pub fn lfs(oid: &str) -> String {
234+ format!("lfs/{}/{}/{oid}", &oid[..2], &oid[2..4])
235+ }
236+
237+ /// `digest` is "sha256:<hex>".
238+ pub fn blob(digest: &str) -> String {
239+ let hex = digest.trim_start_matches("sha256:");
240+ format!("registry/blobs/sha256/{}/{hex}", &hex[..2])
241+ }
242+
243+ pub fn avatar(account_id: i64, hash: &str) -> String {
244+ format!("avatars/{account_id}/{hash}")
245+ }
246+
247+ pub fn cli_release(version: &str, target: &str) -> String {
248+ format!("releases/cli/{version}/ig-{target}")
249+ }
250+
251+ pub fn backup(stamp: &str, name: &str) -> String {
252+ format!("backups/{stamp}/{name}")
253+ }
254+}
+90-0backend/src/transport.rs
@@ -0,0 +1,90 @@
1+//! Spawning `git upload-pack` / `git receive-pack` for a repository, shared
2+//! by smart HTTP and SSH so both enforce the same limits and hooks.
3+
4+use tokio::process::Command;
5+
6+use crate::{auth::Viewer, git::git_command, hook, models::Repo, ops, state::AppState};
7+
8+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9+pub enum Service {
10+ UploadPack,
11+ ReceivePack,
12+}
13+
14+impl Service {
15+ pub fn from_name(name: &str) -> Option<Self> {
16+ match name {
17+ "git-upload-pack" => Some(Self::UploadPack),
18+ "git-receive-pack" => Some(Self::ReceivePack),
19+ _ => None,
20+ }
21+ }
22+
23+ pub fn name(self) -> &'static str {
24+ match self {
25+ Self::UploadPack => "git-upload-pack",
26+ Self::ReceivePack => "git-receive-pack",
27+ }
28+ }
29+
30+ fn subcommand(self) -> &'static str {
31+ match self {
32+ Self::UploadPack => "upload-pack",
33+ Self::ReceivePack => "receive-pack",
34+ }
35+ }
36+}
37+
38+pub struct ServiceOptions<'a> {
39+ pub stateless_rpc: bool,
40+ pub advertise_refs: bool,
41+ /// Value of the client's Git-Protocol header / GIT_PROTOCOL env.
42+ pub protocol: Option<&'a str>,
43+}
44+
45+/// Bytes the repo's owner may still add before hitting their quota.
46+pub async fn quota_remaining(state: &AppState, repo: &Repo) -> anyhow::Result<u64> {
47+ let owner = crate::models::Account::by_id(&state.db, repo.owner_id).await?.ok_or_else(|| anyhow::anyhow!("owner missing"))?;
48+ let used = ops::account_usage(state, owner.id).await.map_err(|e| anyhow::anyhow!("{e}"))?;
49+ Ok(owner.quota(&state.config).saturating_sub(used))
50+}
51+
52+/// The git command for `service`, with hooks and limits wired in. The caller
53+/// sets stdio and spawns it.
54+pub async fn service_command(
55+ state: &AppState,
56+ repo: &Repo,
57+ service: Service,
58+ pusher: Option<&Viewer>,
59+ options: ServiceOptions<'_>,
60+) -> anyhow::Result<Command> {
61+ let path = repo.disk_path(&state.config);
62+ let mut command = git_command(&path);
63+ if let Some(protocol) = options.protocol.filter(|p| p.len() < 200) {
64+ command.env("GIT_PROTOCOL", protocol);
65+ }
66+ if service == Service::ReceivePack {
67+ let remaining = quota_remaining(state, repo).await?;
68+ // receive.maxInputSize = 0 means unlimited, so never pass 0.
69+ let max_input = remaining.clamp(1, 8 * 1024 * 1024 * 1024);
70+ command
71+ .arg("-c")
72+ .arg(format!("core.hooksPath={}", state.config.hooks_dir().display()))
73+ .arg("-c")
74+ .arg(format!("receive.maxInputSize={max_input}"))
75+ .args(["-c", "receive.fsckObjects=true", "-c", "receive.advertisePushOptions=false"])
76+ .env(hook::ENV_MAX_FILE, state.config.limits.max_file_bytes.to_string())
77+ .env(hook::ENV_QUOTA_REMAINING, remaining.to_string())
78+ .env(hook::ENV_REPO, repo.full_name())
79+ .env(hook::ENV_PUSHER, pusher.map(|p| p.name.as_str()).unwrap_or(""));
80+ }
81+ command.arg(service.subcommand());
82+ if options.stateless_rpc {
83+ command.arg("--stateless-rpc");
84+ }
85+ if options.advertise_refs {
86+ command.arg("--advertise-refs");
87+ }
88+ command.arg(&path);
89+ Ok(command)
90+}
+9-0backend/src/web/admin.rs
@@ -0,0 +1,9 @@
1+//! Site admin panel at /admin. Stub; filled in by the admin work.
2+
3+use axum::Router;
4+
5+use crate::state::AppState;
6+
7+pub fn router() -> Router<AppState> {
8+ Router::new()
9+}
+53-0backend/src/web/avatars.rs
@@ -0,0 +1,53 @@
1+//! /avatars/{name}: the uploaded picture from R2, or a generated pattern.
2+
3+use std::time::Duration;
4+
5+use axum::{
6+ extract::{Path, State},
7+ http::{StatusCode, header},
8+ response::{IntoResponse, Response},
9+};
10+
11+use crate::{models::Account, state::AppState, web::ui};
12+
13+pub async fn serve(State(state): State<AppState>, Path(name): Path<String>) -> Response {
14+ let account = match Account::by_name(&state.db, &name).await {
15+ Ok(account) => account,
16+ Err(error) => {
17+ tracing::error!(%error, "avatar lookup failed");
18+ None
19+ }
20+ };
21+ if let Some(key) = account.as_ref().and_then(|a| a.avatar_key.as_deref()) {
22+ // Pictures are small and change rarely: proxy them with long caching
23+ // (the URL carries a version) instead of redirecting to R2.
24+ match state.storage.get_bytes(key).await {
25+ Ok(Some(bytes)) => {
26+ let mime = if bytes.starts_with(b"\x89PNG") {
27+ "image/png"
28+ } else if bytes.starts_with(b"RIFF") {
29+ "image/webp"
30+ } else if bytes.starts_with(b"GIF8") {
31+ "image/gif"
32+ } else {
33+ "image/jpeg"
34+ };
35+ return (
36+ StatusCode::OK,
37+ [(header::CONTENT_TYPE, mime), (header::CACHE_CONTROL, "public, max-age=86400")],
38+ bytes,
39+ )
40+ .into_response();
41+ }
42+ Ok(None) => tracing::warn!(key, "avatar object missing in R2"),
43+ Err(error) => tracing::error!(%error, key, "avatar fetch failed"),
44+ }
45+ }
46+ let _ = Duration::ZERO;
47+ (
48+ StatusCode::OK,
49+ [(header::CONTENT_TYPE, "image/svg+xml"), (header::CACHE_CONTROL, "public, max-age=3600")],
50+ ui::identicon_svg(&name),
51+ )
52+ .into_response()
53+}
+234-0backend/src/web/layout.rs
@@ -0,0 +1,234 @@
1+//! Wraps server-rendered markup in the Astro-built shell (dist/app-shell).
2+//!
3+//! The shell carries the stylesheet, scripts and footer; this fills its three
4+//! markers: `<meta name="ig-head">`, `<ig-nav></ig-nav>`, `<ig-body></ig-body>`.
5+//! Static Astro pages get the same head and nav through `inject`.
6+
7+use axum::{
8+ extract::FromRequestParts,
9+ http::{StatusCode, header, request::Parts},
10+ response::{Html, IntoResponse, Response},
11+};
12+use maud::{DOCTYPE, Markup, PreEscaped, html};
13+
14+use crate::{
15+ auth::{MaybeViewer, Viewer},
16+ error::AppError,
17+ frontend,
18+ state::AppState,
19+ web::ui,
20+};
21+
22+const HEAD_MARKER: &str = r#"<meta name="ig-head">"#;
23+const NAV_MARKER: &str = "<ig-nav></ig-nav>";
24+const BODY_MARKER: &str = "<ig-body></ig-body>";
25+
26+/// Request context for pages: who is looking, and the app state.
27+pub struct Ctx {
28+ pub viewer: Option<Viewer>,
29+ pub state: AppState,
30+ pub path: String,
31+}
32+
33+impl FromRequestParts<AppState> for Ctx {
34+ type Rejection = AppError;
35+
36+ async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
37+ let MaybeViewer(viewer) = MaybeViewer::from_request_parts(parts, state).await?;
38+ Ok(Ctx { viewer, state: state.clone(), path: parts.uri.path().to_string() })
39+ }
40+}
41+
42+impl Ctx {
43+ pub fn viewer_id(&self) -> Option<i64> {
44+ self.viewer.as_ref().map(|v| v.id)
45+ }
46+
47+ pub fn render(&self, page: Page) -> Response {
48+ render_page(&self.state, self.viewer.as_ref(), &self.path, page)
49+ }
50+}
51+
52+pub struct Page {
53+ pub title: String,
54+ pub description: Option<String>,
55+ pub body: Markup,
56+ pub status: StatusCode,
57+ pub noindex: bool,
58+ pub og_image: Option<String>,
59+}
60+
61+impl Page {
62+ pub fn new(title: impl Into<String>, body: Markup) -> Self {
63+ Self { title: title.into(), description: None, body, status: StatusCode::OK, noindex: false, og_image: None }
64+ }
65+
66+ pub fn description(mut self, description: impl Into<String>) -> Self {
67+ self.description = Some(description.into());
68+ self
69+ }
70+
71+ pub fn status(mut self, status: StatusCode) -> Self {
72+ self.status = status;
73+ self
74+ }
75+
76+ /// Keep settings, forms and private pages out of search engines.
77+ pub fn noindex(mut self) -> Self {
78+ self.noindex = true;
79+ self
80+ }
81+
82+ pub fn og_image(mut self, url: impl Into<String>) -> Self {
83+ self.og_image = Some(url.into());
84+ self
85+ }
86+}
87+
88+pub fn render_page(state: &AppState, viewer: Option<&Viewer>, path: &str, page: Page) -> Response {
89+ let Some(shell) = frontend::shell() else {
90+ tracing::error!("frontend/dist/app-shell/index.html is missing; run `bun run build` in frontend/");
91+ return (StatusCode::INTERNAL_SERVER_ERROR, "frontend build missing").into_response();
92+ };
93+ let title = if page.title.is_empty() { "irongit".to_string() } else { format!("{} · irongit", page.title) };
94+ let head = head_markup(state, viewer, path, &title, page.description.as_deref(), page.noindex, page.og_image.as_deref());
95+ let nav = nav_markup(viewer, path);
96+ let html = fill(&shell, &head.0, &nav.0, Some(&page.body.0));
97+ let mut response = (page.status, Html(html)).into_response();
98+ response.headers_mut().insert(header::CACHE_CONTROL, header::HeaderValue::from_static("no-store"));
99+ response
100+}
101+
102+/// Head and nav for a static Astro page (landing, docs, 404).
103+pub fn inject(state: &AppState, viewer: Option<&Viewer>, path: &str, html: &str) -> String {
104+ let has_title = html.contains("<title>");
105+ let head = head_markup(state, viewer, path, if has_title { "" } else { "irongit" }, None, false, None);
106+ let nav = nav_markup(viewer, path);
107+ fill(html, &head.0, &nav.0, None)
108+}
109+
110+fn fill(shell: &str, head: &str, nav: &str, body: Option<&str>) -> String {
111+ let mut out = shell.replacen(HEAD_MARKER, head, 1).replacen(NAV_MARKER, nav, 1);
112+ if let Some(body) = body {
113+ out = out.replacen(BODY_MARKER, body, 1);
114+ }
115+ out
116+}
117+
118+fn head_markup(
119+ state: &AppState,
120+ viewer: Option<&Viewer>,
121+ path: &str,
122+ title: &str,
123+ description: Option<&str>,
124+ noindex: bool,
125+ og_image: Option<&str>,
126+) -> Markup {
127+ let base = state.config.base_url();
128+ let rybbit = state.config.rybbit.as_ref();
129+ html! {
130+ @if !title.is_empty() {
131+ title { (title) }
132+ meta property="og:title" content=(title);
133+ }
134+ @if let Some(description) = description {
135+ meta name="description" content=(description);
136+ meta property="og:description" content=(description);
137+ }
138+ meta property="og:site_name" content="irongit";
139+ meta property="og:url" content={ (base) (path) };
140+ link rel="canonical" href={ (base) (path) };
141+ @if let Some(image) = og_image {
142+ meta property="og:image" content=(image);
143+ }
144+ @if noindex {
145+ meta name="robots" content="noindex";
146+ }
147+ @if let Some(viewer) = viewer {
148+ meta name="ig-user" content=(viewer.name);
149+ }
150+ @if let Some(rybbit) = rybbit {
151+ script src={ (rybbit.host.trim_end_matches('/')) "/api/script.js" } data-site-id=(rybbit.site_id) defer {}
152+ }
153+ }
154+}
155+
156+fn nav_markup(viewer: Option<&Viewer>, path: &str) -> Markup {
157+ let active = |prefix: &str| path == prefix || path.starts_with(&format!("{prefix}/"));
158+ html! {
159+ header class="border-b border-edge bg-surface-raised" {
160+ div class="mx-auto flex h-11 max-w-[1280px] items-center gap-3 px-4" {
161+ a href="/" class="flex items-center gap-2 font-semibold text-ink no-underline hover:no-underline" {
162+ (ui::logo(20))
163+ span class="tracking-tight" { "irongit" }
164+ }
165+ nav class="flex items-center gap-0.5 text-[13px]" {
166+ a href="/explore" class={ "rounded-[4px] px-2 py-1 no-underline hover:bg-surface-hover hover:no-underline " @if active("/explore") { "text-ink" } @else { "text-ink-dim" } } { "Explore" }
167+ a href="/docs" class={ "rounded-[4px] px-2 py-1 no-underline hover:bg-surface-hover hover:no-underline " @if active("/docs") { "text-ink" } @else { "text-ink-dim" } } { "Docs" }
168+ }
169+ div class="ml-auto flex items-center gap-2" {
170+ @if let Some(viewer) = viewer {
171+ a href="/new" class="btn btn-sm" data-track="nav_new_repo_clicked" { "+ New" }
172+ details class="relative" {
173+ summary class="flex cursor-pointer list-none items-center gap-1 rounded-[4px] p-0.5 hover:bg-surface-hover [&::-webkit-details-marker]:hidden" {
174+ (ui::avatar(&viewer.name, viewer.avatar_key.as_deref(), 24))
175+ span class="text-[10px] text-ink-faint" { "▾" }
176+ }
177+ div class="absolute right-0 z-30 mt-1 w-52 overflow-hidden rounded-[4px] border border-edge-strong bg-surface-raised py-1 text-[13px] shadow-lg" {
178+ div class="border-b border-edge px-3 py-1.5 text-xs text-ink-dim" { "Signed in as " strong class="text-ink" { (viewer.name) } }
179+ a href={ "/" (viewer.name) } class="block px-3 py-1 text-ink no-underline hover:bg-surface-hover" { "Your profile" }
180+ a href={ "/" (viewer.name) "?tab=repositories" } class="block px-3 py-1 text-ink no-underline hover:bg-surface-hover" { "Your repositories" }
181+ a href={ "/" (viewer.name) "?tab=packages" } class="block px-3 py-1 text-ink no-underline hover:bg-surface-hover" { "Your images" }
182+ a href="/organizations/new" class="block px-3 py-1 text-ink no-underline hover:bg-surface-hover" { "New organization" }
183+ a href="/settings/profile" class="block px-3 py-1 text-ink no-underline hover:bg-surface-hover" { "Settings" }
184+ @if viewer.site_admin() {
185+ a href="/admin" class="block px-3 py-1 text-ink no-underline hover:bg-surface-hover" { "Site admin" }
186+ }
187+ form method="post" action="/logout" class="border-t border-edge mt-1 pt-1" data-track-submit="logout" {
188+ button type="submit" class="block w-full cursor-pointer px-3 py-1 text-left text-ink hover:bg-surface-hover" { "Sign out" }
189+ }
190+ }
191+ }
192+ } @else {
193+ a href={ "/login?next=" (crate::auth::urlencode(path)) } class="btn btn-sm" { "Sign in" }
194+ a href="/register" class="btn btn-sm btn-primary" data-track="nav_register_clicked" { "Register" }
195+ }
196+ }
197+ }
198+ }
199+ }
200+}
201+
202+/// Error page with an anonymous nav (no state is available here).
203+pub fn error_page(status: StatusCode, message: &str) -> Response {
204+ let body = html! {
205+ div class="mx-auto max-w-[720px] px-4 py-16" {
206+ p class="font-mono text-xs text-ember" { (status.as_u16()) }
207+ h1 class="mt-1 text-xl font-semibold" { (status.canonical_reason().unwrap_or("Error")) }
208+ p class="mt-2 text-ink-dim" { (message) }
209+ p class="mt-4" { a href="/" { "Go home" } }
210+ }
211+ };
212+ let title = status.canonical_reason().unwrap_or("Error");
213+ let html = match frontend::shell() {
214+ Some(shell) => fill(
215+ &shell,
216+ &html! { title { (title) " · irongit" } meta name="robots" content="noindex"; }.0,
217+ &html! { header class="border-b border-edge bg-surface-raised" { div class="mx-auto flex h-11 max-w-[1280px] items-center gap-2 px-4" { a href="/" class="flex items-center gap-2 font-semibold text-ink no-underline" { (ui::logo(20)) "irongit" } } } }.0,
218+ Some(&body.0),
219+ ),
220+ None => html! { (DOCTYPE) html { body { (body) } } }.0,
221+ };
222+ (status, Html(html)).into_response()
223+}
224+
225+/// For fragments loaded by `data-lazy`: markup without the shell.
226+pub fn fragment(markup: Markup) -> Response {
227+ let mut response = Html(markup.0).into_response();
228+ response.headers_mut().insert(header::CACHE_CONTROL, header::HeaderValue::from_static("no-store"));
229+ response
230+}
231+
232+pub fn raw_html(s: &str) -> PreEscaped<String> {
233+ PreEscaped(s.to_string())
234+}
+14-0backend/src/web/mod.rs
@@ -0,0 +1,14 @@
1+//! Server-rendered pages (maud), poured into the Astro shell.
2+
3+pub mod admin;
4+pub mod avatars;
5+pub mod layout;
6+pub mod ui;
7+
8+use axum::{Router, routing::get};
9+
10+use crate::state::AppState;
11+
12+pub fn router() -> Router<AppState> {
13+ Router::new().route("/avatars/{name}", get(avatars::serve)).merge(admin::router())
14+}
+207-0backend/src/web/ui.rs
@@ -0,0 +1,207 @@
1+//! Small shared pieces of markup.
2+
3+use chrono::{DateTime, Utc};
4+use maud::{Markup, PreEscaped, html};
5+use sha2::{Digest, Sha256};
6+
7+pub fn logo(size: u32) -> Markup {
8+ html! {
9+ svg width=(size) height=(size) viewBox="0 0 32 32" aria-hidden="true" {
10+ rect width="32" height="32" rx="4" fill="#0b0f16" {}
11+ rect x="6" y="20" width="20" height="5" rx="1" fill="#93a0b4" {}
12+ rect x="10" y="15" width="12" height="5" fill="#93a0b4" {}
13+ rect x="13" y="6" width="6" height="9" fill="#f07a1a" {}
14+ }
15+ }
16+}
17+
18+/// URL of an account's avatar. The key's last segment busts caches when the
19+/// picture changes.
20+pub fn avatar_url(name: &str, avatar_key: Option<&str>) -> String {
21+ match avatar_key.and_then(|k| k.rsplit('/').next()) {
22+ Some(version) => format!("/avatars/{name}?v={}", &version[..version.len().min(12)]),
23+ None => format!("/avatars/{name}"),
24+ }
25+}
26+
27+/// Avatars are square with 4px corners, the same as everything else.
28+pub fn avatar(name: &str, avatar_key: Option<&str>, size: u32) -> Markup {
29+ html! {
30+ img src=(avatar_url(name, avatar_key)) width=(size) height=(size) alt=(name) loading="lazy"
31+ class="shrink-0 rounded-[4px] bg-surface-hover object-cover" style={ "width:" (size) "px;height:" (size) "px" };
32+ }
33+}
34+
35+/// Generated avatar for accounts without an upload: a mirrored 5x5 pattern
36+/// in two tones of a hue picked from the name, like a maker's mark.
37+pub fn identicon_svg(name: &str) -> String {
38+ let hash = Sha256::digest(name.to_ascii_lowercase().as_bytes());
39+ let hue = u16::from_be_bytes([hash[0], hash[1]]) % 360;
40+ let saturation = 55 + hash[2] % 25;
41+ let background = format!("hsl({hue} {saturation}% 16%)");
42+ let foreground = format!("hsl({hue} {}% 62%)", saturation + 10);
43+ let accent = format!("hsl({} 90% 60%)", (hue + 150) % 360);
44+ let mut cells = String::new();
45+ for row in 0..5u32 {
46+ for column in 0..3u32 {
47+ let bit = hash[(3 + row * 3 + column) as usize];
48+ if bit % 2 == 0 {
49+ continue;
50+ }
51+ let fill = if bit % 7 == 0 { &accent } else { &foreground };
52+ for x in [column, 4 - column] {
53+ cells.push_str(&format!(r#"<rect x="{}" y="{}" width="10" height="10" fill="{fill}"/>"#, 10 + x * 10, 10 + row * 10));
54+ if column == 2 {
55+ break;
56+ }
57+ }
58+ }
59+ }
60+ format!(
61+ r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 70 70" shape-rendering="crispEdges"><rect width="70" height="70" fill="{background}"/>{cells}</svg>"#
62+ )
63+}
64+
65+pub fn ago(at: DateTime<Utc>) -> String {
66+ let seconds = (Utc::now() - at).num_seconds();
67+ let (n, unit) = match seconds {
68+ s if s < 45 => return "just now".into(),
69+ s if s < 3600 => ((s + 30) / 60, "minute"),
70+ s if s < 86_400 => ((s + 1800) / 3600, "hour"),
71+ s if s < 30 * 86_400 => ((s + 43_200) / 86_400, "day"),
72+ s if s < 365 * 86_400 => (s / (30 * 86_400), "month"),
73+ s => (s / (365 * 86_400), "year"),
74+ };
75+ let n = n.max(1);
76+ format!("{n} {unit}{} ago", if n == 1 { "" } else { "s" })
77+}
78+
79+/// Relative time with the exact time on hover.
80+pub fn time(at: DateTime<Utc>) -> Markup {
81+ html! { time datetime=(at.to_rfc3339()) title=(at.format("%Y-%m-%d %H:%M UTC")) { (ago(at)) } }
82+}
83+
84+pub fn bytes(n: u64) -> String {
85+ const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
86+ let mut value = n as f64;
87+ let mut unit = 0;
88+ while value >= 1024.0 && unit < UNITS.len() - 1 {
89+ value /= 1024.0;
90+ unit += 1;
91+ }
92+ if unit == 0 { format!("{n} B") } else if value < 10.0 { format!("{value:.1} {}", UNITS[unit]) } else { format!("{value:.0} {}", UNITS[unit]) }
93+}
94+
95+pub fn plural(n: i64, one: &str, many: &str) -> String {
96+ format!("{n} {}", if n == 1 { one } else { many })
97+}
98+
99+pub fn visibility_tag(visibility: &str) -> Markup {
100+ html! {
101+ @if visibility == "private" {
102+ span class="tag border-warn/50 text-warn" { "Private" }
103+ } @else {
104+ span class="tag" { "Public" }
105+ }
106+ }
107+}
108+
109+pub fn alert_error(message: Option<&str>) -> Markup {
110+ html! {
111+ @if let Some(message) = message {
112+ div class="alert alert-error mb-3" role="alert" { (message) }
113+ }
114+ }
115+}
116+
117+pub fn alert_ok(message: Option<&str>) -> Markup {
118+ html! {
119+ @if let Some(message) = message {
120+ div class="alert alert-ok mb-3" role="status" { (message) }
121+ }
122+ }
123+}
124+
125+/// A box with a copyable value: clone URLs, tokens, pull commands.
126+pub fn copy_field(id: &str, value: &str, track: &str) -> Markup {
127+ html! {
128+ div class="flex" {
129+ input id=(id) class="input rounded-r-none font-mono text-xs" readonly value=(value) onclick="this.select()";
130+ button type="button" class="btn rounded-l-none border-l-0 text-xs" data-copy-target={ "#" (id) } data-track=(track) { "Copy" }
131+ }
132+ }
133+}
134+
135+pub fn empty_state(title: &str, body: Markup) -> Markup {
136+ html! {
137+ div class="box px-6 py-10 text-center" {
138+ p class="font-semibold" { (title) }
139+ div class="mt-1 text-ink-dim" { (body) }
140+ }
141+ }
142+}
143+
144+/// Skeleton rows shown until a `data-lazy` fragment loads.
145+pub fn skeleton_rows(rows: usize) -> Markup {
146+ html! {
147+ div class="space-y-2 p-3" aria-hidden="true" {
148+ @for i in 0..rows {
149+ div class="skeleton h-4" style={ "width:" (60 + (i * 37) % 35) "%" } {}
150+ }
151+ }
152+ }
153+}
154+
155+pub fn lazy(url: &str, rows: usize) -> Markup {
156+ html! { div data-lazy=(url) aria-busy="true" { (skeleton_rows(rows)) } }
157+}
158+
159+pub fn pager(base: &str, page: i64, has_next: bool) -> Markup {
160+ let sep = if base.contains('?') { '&' } else { '?' };
161+ html! {
162+ @if page > 1 || has_next {
163+ div class="mt-3 flex justify-center gap-2" {
164+ @if page > 1 {
165+ a class="btn btn-sm" href={ (base) (sep) "page=" (page - 1) } { "Newer" }
166+ }
167+ @if has_next {
168+ a class="btn btn-sm" href={ (base) (sep) "page=" (page + 1) } { "Older" }
169+ }
170+ }
171+ }
172+ }
173+}
174+
175+pub fn icon_folder() -> Markup {
176+ PreEscaped(r#"<svg width="16" height="16" viewBox="0 0 16 16" class="shrink-0 fill-accent" aria-hidden="true"><path d="M1.75 2A1.75 1.75 0 0 0 0 3.75v8.5C0 13.216.784 14 1.75 14h12.5A1.75 1.75 0 0 0 16 12.25v-7A1.75 1.75 0 0 0 14.25 3.5H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.76 5.55 1.5 5 1.5H1.75Z"/></svg>"#.into())
177+}
178+
179+pub fn icon_file() -> Markup {
180+ PreEscaped(r#"<svg width="16" height="16" viewBox="0 0 16 16" class="shrink-0 fill-ink-faint" aria-hidden="true"><path d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z"/></svg>"#.into())
181+}
182+
183+pub fn icon_repo() -> Markup {
184+ PreEscaped(r#"<svg width="16" height="16" viewBox="0 0 16 16" class="shrink-0 fill-ink-dim" aria-hidden="true"><path d="M2 2.5A2.5 2.5 0 0 1 4.5 0h8.75a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1 0-1.5h1.75v-2h-8a1 1 0 0 0-.714 1.7.75.75 0 1 1-1.072 1.05A2.495 2.495 0 0 1 2 11.5Zm10.5-1h-8a1 1 0 0 0-1 1v6.708A2.486 2.486 0 0 1 4.5 9h8ZM5 12.25a.25.25 0 0 1 .25-.25h3.5a.25.25 0 0 1 .25.25v3.25a.25.25 0 0 1-.4.2l-1.45-1.087a.249.249 0 0 0-.3 0L5.4 15.7a.25.25 0 0 1-.4-.2Z"/></svg>"#.into())
185+}
186+
187+pub fn icon_package() -> Markup {
188+ PreEscaped(r#"<svg width="16" height="16" viewBox="0 0 16 16" class="shrink-0 fill-ink-dim" aria-hidden="true"><path d="m8.878.392 5.25 3.045c.54.314.872.89.872 1.514v6.098a1.75 1.75 0 0 1-.872 1.514l-5.25 3.045a1.75 1.75 0 0 1-1.756 0l-5.25-3.045A1.75 1.75 0 0 1 1 11.049V4.951c0-.624.332-1.201.872-1.514L7.122.392a1.75 1.75 0 0 1 1.756 0ZM7.875 1.69l-4.63 2.685L8 7.133l4.755-2.758-4.63-2.685a.248.248 0 0 0-.25 0ZM2.5 5.677v5.372c0 .09.047.171.125.216l4.625 2.683V8.432Zm6.25 8.271 4.625-2.683a.25.25 0 0 0 .125-.216V5.677L8.75 8.432Z"/></svg>"#.into())
189+}
190+
191+#[cfg(test)]
192+mod tests {
193+ use super::*;
194+
195+ #[test]
196+ fn byte_formatting() {
197+ assert_eq!(bytes(512), "512 B");
198+ assert_eq!(bytes(1536), "1.5 KB");
199+ assert_eq!(bytes(50 * 1024 * 1024), "50 MB");
200+ }
201+
202+ #[test]
203+ fn identicons_are_stable_and_distinct() {
204+ assert_eq!(identicon_svg("alice"), identicon_svg("Alice"));
205+ assert_ne!(identicon_svg("alice"), identicon_svg("bob"));
206+ }
207+}
+14-0build.sh
@@ -0,0 +1,14 @@
1+#!/bin/sh
2+# Production build: the server and the ig CLI, both x86_64 Linux.
3+# The frontend is rebuilt without PUBLIC_HOT_RELOAD first: dev builds set it,
4+# and whatever is in frontend/dist at compile time is what gets embedded.
5+set -e
6+
7+cd "$(dirname "$0")"
8+
9+(cd frontend && bun install --frozen-lockfile && bun run build)
10+cargo build --release -p irongit --no-default-features
11+cargo build --release -p ig --target x86_64-unknown-linux-musl
12+
13+echo "server: target/release/irongit"
14+echo "cli: target/x86_64-unknown-linux-musl/release/ig"
+16-0cli/Cargo.toml
@@ -0,0 +1,16 @@
1+[package]
2+name = "ig"
3+version = "0.1.0"
4+edition = "2024"
5+
6+[dependencies]
7+anyhow = "1.0.104"
8+clap = { version = "4.6.7", features = ["derive", "env"] }
9+dirs = "7.0.0"
10+hex = "0.4.3"
11+irongit-shared = { path = "../shared" }
12+reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "json", "blocking"] }
13+serde = { version = "1.0.229", features = ["derive"] }
14+serde_json = "1.0.151"
15+sha2 = "0.11.0"
16+toml = "1.1.7"
+1-0cli/src/main.rs
@@ -0,0 +1 @@
1+fn main() {}
+14-0dev.sh
@@ -0,0 +1,14 @@
1+#!/bin/sh
2+# Development server on PORT (7878). Installs frontend dependencies if needed
3+# (rust-embed needs frontend/dist to exist before the server compiles), then
4+# restarts the server on Rust or SQL changes. The server itself rebuilds the
5+# frontend and reloads open browsers.
6+set -e
7+
8+cd "$(dirname "$0")"
9+
10+[ -f .env ] || { echo "missing .env (see secrets.md)"; exit 1; }
11+[ -d frontend/node_modules ] || (cd frontend && bun install)
12+[ -d frontend/dist ] || (cd frontend && bun run build)
13+
14+exec watchexec -r -e rs,toml,sql -w backend -w shared -- cargo run -p irongit
+11-0frontend/astro.config.mjs
@@ -0,0 +1,11 @@
1+// @ts-check
2+import { defineConfig } from "astro/config";
3+import tailwindcss from "@tailwindcss/vite";
4+
5+export default defineConfig({
6+ // `directory` emits about/index.html, which the Rust server resolves for
7+ // /about, /about/ and /about.html alike. There is no `astro dev` here: the
8+ // Rust server is the only server, and it rebuilds this on change.
9+ build: { format: "directory" },
10+ vite: { plugins: [tailwindcss()] },
11+});
+671-0frontend/bun.lock
@@ -0,0 +1,671 @@
1+{
2+ "lockfileVersion": 2,
3+ "configVersion": 1,
4+ "workspaces": {
5+ "": {
6+ "name": "astrum-frontend",
7+ "dependencies": {
8+ "@tailwindcss/vite": "^4.1.18",
9+ "astro": "^7.3.3",
10+ "tailwindcss": "^4.1.18",
11+ },
12+ },
13+ },
14+ "packages": {
15+ "@astrojs/compiler-binding": ["@astrojs/compiler-binding@0.4.1", "", { "optionalDependencies": { "@astrojs/compiler-binding-android-arm64": "0.4.1", "@astrojs/compiler-binding-darwin-arm64": "0.4.1", "@astrojs/compiler-binding-darwin-x64": "0.4.1", "@astrojs/compiler-binding-linux-arm64-gnu": "0.4.1", "@astrojs/compiler-binding-linux-arm64-musl": "0.4.1", "@astrojs/compiler-binding-linux-x64-gnu": "0.4.1", "@astrojs/compiler-binding-linux-x64-musl": "0.4.1", "@astrojs/compiler-binding-wasm32-wasi": "0.4.1", "@astrojs/compiler-binding-win32-arm64-msvc": "0.4.1", "@astrojs/compiler-binding-win32-x64-msvc": "0.4.1" } }, "sha512-ZYVDW1P58OXyxqZbOMp+/2U9KA7y2esIx9r4pWdpZ/l6fCZhaw4ul+D7EgpRVxYLkyTWqzANbTTUFGPFP8zDHA=="],
16+
17+ "@astrojs/compiler-binding-android-arm64": ["@astrojs/compiler-binding-android-arm64@0.4.1", "", { "os": "android", "cpu": "arm64" }, "sha512-Wtx2DZORuNTLASLLZ8hlrjFaJe1LUloatcCn9EPq7knBhRpjMGXeJANIJMeBZG4igvz7x+3nyAJM2iwCSW9xog=="],
18+
19+ "@astrojs/compiler-binding-darwin-arm64": ["@astrojs/compiler-binding-darwin-arm64@0.4.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7iwXcU+hB60Y1SvOfnWHNioT72gDsDQKaDipXh2Onwp/TvQMozhbvhy13wJR8Af940a0h6PbddCeJFpj791UDA=="],
20+
21+ "@astrojs/compiler-binding-darwin-x64": ["@astrojs/compiler-binding-darwin-x64@0.4.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-pifjICos49RXhSVKSDu5gDB0/8j0O7DmxSp1DaWW2PyLG5ekwSkcrfOZto5JqhdI7QYMZB3XOiXpsdOXpS4npA=="],
22+
23+ "@astrojs/compiler-binding-linux-arm64-gnu": ["@astrojs/compiler-binding-linux-arm64-gnu@0.4.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-hsZMlNWc6LqWHh9LlSwTq1/XCY7pxA5rkkKIdZRV8mgbOz9OwLvjEoe5Jry13eIZjMUQO8XnQyzk8o/Pjw2YFA=="],
24+
25+ "@astrojs/compiler-binding-linux-arm64-musl": ["@astrojs/compiler-binding-linux-arm64-musl@0.4.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-a3EW27f+Z9uf57vYmpLgUriA1y9wDhKz57PT8D8XlFZt2HZEWhUxKuxI1jvKVI9p8okYee11WXEm/FHY1G9yoQ=="],
26+
27+ "@astrojs/compiler-binding-linux-x64-gnu": ["@astrojs/compiler-binding-linux-x64-gnu@0.4.1", "", { "os": "linux", "cpu": "x64" }, "sha512-XruPTKB/SS4DFzeAjQ1ZyM0ieHDjapHkOSnnzRApZ4FXY2YAI7UY3mETjfJiIS/acyZyrnPH5jObv1gATTdUJQ=="],
28+
29+ "@astrojs/compiler-binding-linux-x64-musl": ["@astrojs/compiler-binding-linux-x64-musl@0.4.1", "", { "os": "linux", "cpu": "x64" }, "sha512-E8j7lGanqNpudtkUbsFaOLJhmwFe4Ecqux7K9hkj4wULIPNZRM2bYZNcMSj8r1oWvaUiwZl8LaaNwv3+14jqhw=="],
30+
31+ "@astrojs/compiler-binding-wasm32-wasi": ["@astrojs/compiler-binding-wasm32-wasi@0.4.1", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.2.4" }, "cpu": "none" }, "sha512-vNLxIv41ZE+nmPFtw24cRW5ZXG+ZPcAGtgigtjCP8lvcxKI7u2GrXoMsCgEH5fMw8WCEJpwLvsVBg15rJKAkyA=="],
32+
33+ "@astrojs/compiler-binding-win32-arm64-msvc": ["@astrojs/compiler-binding-win32-arm64-msvc@0.4.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-/xTDgRsFDoTh0/+KFXJ74DY4Jqn8CoSHwa8UP5RAblHfnRHkenwfSl4zYDET61O9vXKLk09UzHjjq09yP20/AQ=="],
34+
35+ "@astrojs/compiler-binding-win32-x64-msvc": ["@astrojs/compiler-binding-win32-x64-msvc@0.4.1", "", { "os": "win32", "cpu": "x64" }, "sha512-0x5J6iHZO0Fjo/CVzoIbXKzWnGESftDbEUQ/oXQ61HhUAFoR7+T1jfToZSNAS+gYyBXHSTpMWYC7lzarbhClcg=="],
36+
37+ "@astrojs/compiler-rs": ["@astrojs/compiler-rs@0.4.1", "", { "dependencies": { "@astrojs/compiler-binding": "0.4.1" } }, "sha512-//NmAuRhy7eU9iP0ceI5Pymy55M+nUZ//kS6XeOyhBWdDib2KbJOyFaVJ52k7sDkJwTKVox7oPu3SpYiVnGm5A=="],
38+
39+ "@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.11.0", "", { "dependencies": { "@types/hast": "^3.0.4", "@types/mdast": "^4.0.4", "js-yaml": "^4.3.0", "picomatch": "^4.0.4", "retext-smartypants": "^6.2.0", "shiki": "^4.0.2", "smol-toml": "^1.6.0", "unified": "^11.0.5" } }, "sha512-3rzxJ+xbo0+8YyqOzLziIN32wmsHdCjEVz2sGOpRxJ+Ben/KiLph4ItxBy1abEL+E8fkRzqjg0rfXmaHJGw9JA=="],
40+
41+ "@astrojs/markdown-satteri": ["@astrojs/markdown-satteri@0.4.1", "", { "dependencies": { "@astrojs/internal-helpers": "0.11.0", "@astrojs/prism": "4.0.2", "github-slugger": "^2.0.0", "satteri": "^0.10.3" } }, "sha512-EniHbFNa6SQHDih7JnpPos3NWXO6hdt4raBcOosfGmlfc3gP9CI/sESA3VOf1PgJZ7SFfewlZW9Livn0JugEZg=="],
42+
43+ "@astrojs/prism": ["@astrojs/prism@4.0.2", "", { "dependencies": { "prismjs": "^1.30.0" } }, "sha512-KTivpmnz6lDsC6o9H4+DNm2SrE/GHzw8cNAvEJwAvUT+eoaEnn/4NtbDNfRRaxaJHdp15gf+tfHAWiXR4wB3BA=="],
44+
45+ "@astrojs/telemetry": ["@astrojs/telemetry@3.3.3", "", { "dependencies": { "ci-info": "^4.4.0", "dset": "^3.1.4", "is-docker": "^4.0.0", "package-manager-detector": "^1.6.0" } }, "sha512-C1TLn5sPJr0x4vk56piHWKbnqlEB8BKyte5Y45V02U+D7BGO5eMqZDH5aPjnkXQWJggvmsTXxH03QMZ9NgWLzQ=="],
46+
47+ "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
48+
49+ "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
50+
51+ "@babel/parser": ["@babel/parser@7.29.9", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA=="],
52+
53+ "@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="],
54+
55+ "@bruits/satteri-darwin-arm64": ["@bruits/satteri-darwin-arm64@0.10.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-27KTVl4TJkVahMy/ohyA7qd4938G5UNneFUz/PsScYfpIhj0IVAS23mpcJXdPF44sa6nva198lmV/cKIb2YPyA=="],
56+
57+ "@bruits/satteri-darwin-x64": ["@bruits/satteri-darwin-x64@0.10.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-IjnLe3nKspq6qaeqGgjT7MT8VrTV74yWRlaag7ZdNsI8TDAYZ0iPxMCo+9KQZHUk5EyVB+reBI/PFWL5KuFw9Q=="],
58+
59+ "@bruits/satteri-linux-arm64-gnu": ["@bruits/satteri-linux-arm64-gnu@0.10.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-glkYXZCJywjP13v67eAyAMSJdF+ncvEbYvgi/wOtffL9tQ27lr/zsyzUfgs+ovjJ9d8JNQKiXeiArJcX8PJL9w=="],
60+
61+ "@bruits/satteri-linux-arm64-musl": ["@bruits/satteri-linux-arm64-musl@0.10.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-yWdgG1g17Nh2QyGVlFUxGRa3FEFwiMcpZEyMNWkbM3deC94cmVc+/i9OuyFpdKuWo3GkgoCtYVOoxk1uCnCZIA=="],
62+
63+ "@bruits/satteri-linux-x64-gnu": ["@bruits/satteri-linux-x64-gnu@0.10.5", "", { "os": "linux", "cpu": "x64" }, "sha512-FVaLoPT1fBgGl0J+AYebyyXJYBachGl8Oyyrf1lye4RTqCB4S0Gwkj1uM9RJyThUOvx5VUmAT1CnNh1SFHA+kw=="],
64+
65+ "@bruits/satteri-linux-x64-musl": ["@bruits/satteri-linux-x64-musl@0.10.5", "", { "os": "linux", "cpu": "x64" }, "sha512-EHpVAx2bqW3GINHTKkljtxVfQmVDGWIuwOYOP5YghTj+0PkBa2o8oKPRtQ9Kbsr1Fye8jtUcDjhwj2jMNugZKg=="],
66+
67+ "@bruits/satteri-wasm32-wasi": ["@bruits/satteri-wasm32-wasi@0.10.5", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.2.3" }, "cpu": "none" }, "sha512-ypz8c/Zmipxp4IoeDa228Gstv6TLzVmNs3yC6wKCoNSOjx1iwpgzu87Y3hTkXFdwChVGU85qeUDuOIarGUZQLw=="],
68+
69+ "@bruits/satteri-win32-arm64-msvc": ["@bruits/satteri-win32-arm64-msvc@0.10.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-siTV88nb0LRqNpkL2gXboqCwVdq95sLtzMHS1/3eONV2gLbB3NAK46wmSMvCO/yquBvI2lvaFIfd8P12ecsxBw=="],
70+
71+ "@bruits/satteri-win32-x64-msvc": ["@bruits/satteri-win32-x64-msvc@0.10.5", "", { "os": "win32", "cpu": "x64" }, "sha512-C3IfPvfvMXmlzBxaMPKFS1XiuV9pu2mC7YqkPk7PSvTgPZ8gbdASIpHpztDLvTTQjqZ0z1Ol8tK5X+V6XXC0wQ=="],
72+
73+ "@capsizecss/unpack": ["@capsizecss/unpack@4.0.1", "", { "dependencies": { "fontkitten": "^1.0.3" } }, "sha512-CuNiSqg7+e1cO/GjffyMOm5Tt2jUF9CWHHnvQ/UkqvtkGfHdgwEC0wpmq7fkN3gxwpRnrAN0WzO3vREKmNolMQ=="],
74+
75+ "@clack/core": ["@clack/core@1.5.1", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ=="],
76+
77+ "@clack/prompts": ["@clack/prompts@1.8.1", "", { "dependencies": { "@clack/core": "1.5.1", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ=="],
78+
79+ "@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="],
80+
81+ "@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
82+
83+ "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
84+
85+ "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="],
86+
87+ "@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="],
88+
89+ "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="],
90+
91+ "@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="],
92+
93+ "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="],
94+
95+ "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="],
96+
97+ "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="],
98+
99+ "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="],
100+
101+ "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="],
102+
103+ "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="],
104+
105+ "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="],
106+
107+ "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="],
108+
109+ "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="],
110+
111+ "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="],
112+
113+ "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="],
114+
115+ "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="],
116+
117+ "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="],
118+
119+ "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="],
120+
121+ "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="],
122+
123+ "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="],
124+
125+ "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="],
126+
127+ "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="],
128+
129+ "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="],
130+
131+ "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="],
132+
133+ "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="],
134+
135+ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="],
136+
137+ "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
138+
139+ "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.3" }, "os": "darwin", "cpu": "arm64" }, "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA=="],
140+
141+ "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.3" }, "os": "darwin", "cpu": "x64" }, "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw=="],
142+
143+ "@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.4", "", { "dependencies": { "@img/sharp-wasm32": "0.35.4" }, "os": "freebsd" }, "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA=="],
144+
145+ "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg=="],
146+
147+ "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw=="],
148+
149+ "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA=="],
150+
151+ "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A=="],
152+
153+ "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w=="],
154+
155+ "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.3", "", { "os": "linux", "cpu": "none" }, "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ=="],
156+
157+ "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q=="],
158+
159+ "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA=="],
160+
161+ "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw=="],
162+
163+ "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw=="],
164+
165+ "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.3" }, "os": "linux", "cpu": "arm" }, "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A=="],
166+
167+ "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.3" }, "os": "linux", "cpu": "arm64" }, "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ=="],
168+
169+ "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.3" }, "os": "linux", "cpu": "ppc64" }, "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ=="],
170+
171+ "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.3" }, "os": "linux", "cpu": "none" }, "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA=="],
172+
173+ "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.3" }, "os": "linux", "cpu": "s390x" }, "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ=="],
174+
175+ "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.3" }, "os": "linux", "cpu": "x64" }, "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA=="],
176+
177+ "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" }, "os": "linux", "cpu": "arm64" }, "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA=="],
178+
179+ "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.3" }, "os": "linux", "cpu": "x64" }, "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg=="],
180+
181+ "@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.4", "", { "dependencies": { "@emnapi/runtime": "^1.11.3" } }, "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA=="],
182+
183+ "@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.4", "", { "dependencies": { "@img/sharp-wasm32": "0.35.4" }, "cpu": "none" }, "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA=="],
184+
185+ "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw=="],
186+
187+ "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw=="],
188+
189+ "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.4", "", { "os": "win32", "cpu": "x64" }, "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg=="],
190+
191+ "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
192+
193+ "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
194+
195+ "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
196+
197+ "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="],
198+
199+ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
200+
201+ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, "sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g=="],
202+
203+ "@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="],
204+
205+ "@oxc-project/types": ["@oxc-project/types@0.150.0", "", {}, "sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw=="],
206+
207+ "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.9", "", { "os": "android", "cpu": "arm" }, "sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw=="],
208+
209+ "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.9", "", { "os": "android", "cpu": "arm64" }, "sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg=="],
210+
211+ "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw=="],
212+
213+ "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw=="],
214+
215+ "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.9", "", { "os": "freebsd", "cpu": "x64" }, "sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g=="],
216+
217+ "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.9", "", { "os": "linux", "cpu": "arm" }, "sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw=="],
218+
219+ "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw=="],
220+
221+ "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA=="],
222+
223+ "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.9", "", { "os": "linux", "cpu": "ppc64" }, "sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw=="],
224+
225+ "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.9", "", { "os": "linux", "cpu": "s390x" }, "sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ=="],
226+
227+ "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.9", "", { "os": "linux", "cpu": "x64" }, "sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q=="],
228+
229+ "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.9", "", { "os": "linux", "cpu": "x64" }, "sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ=="],
230+
231+ "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.9", "", { "os": "none", "cpu": "arm64" }, "sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg=="],
232+
233+ "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww=="],
234+
235+ "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.9", "", { "os": "win32", "cpu": "x64" }, "sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g=="],
236+
237+ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
238+
239+ "@shikijs/core": ["@shikijs/core@4.4.3", "", { "dependencies": { "@shikijs/primitive": "4.4.3", "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5", "hast-util-to-html": "^9.0.5" } }, "sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg=="],
240+
241+ "@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ=="],
242+
243+ "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-EcOQkxdxGQrc1Row/cC2c96/v1dbZqGnEVu1qTuT/MJmp6+cXCvQussowVmCv5Tqr3KuY3c7IbM6HTW3LJ1k9w=="],
244+
245+ "@shikijs/langs": ["@shikijs/langs@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3" } }, "sha512-ePic0yfAJGOF83D5wBHK/00EjK65oahBYxFk5epgq33WRv7X9UuxLEV8PtR0szC0z8dl7INIpIodB99JRFlR+A=="],
246+
247+ "@shikijs/primitive": ["@shikijs/primitive@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ=="],
248+
249+ "@shikijs/themes": ["@shikijs/themes@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3" } }, "sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw=="],
250+
251+ "@shikijs/types": ["@shikijs/types@4.4.3", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g=="],
252+
253+ "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
254+
255+ "@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="],
256+
257+ "@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.3", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.3", "@tailwindcss/oxide-darwin-arm64": "4.3.3", "@tailwindcss/oxide-darwin-x64": "4.3.3", "@tailwindcss/oxide-freebsd-x64": "4.3.3", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", "@tailwindcss/oxide-linux-x64-musl": "4.3.3", "@tailwindcss/oxide-wasm32-wasi": "4.3.3", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA=="],
258+
259+ "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.3", "", { "os": "android", "cpu": "arm64" }, "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw=="],
260+
261+ "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw=="],
262+
263+ "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw=="],
264+
265+ "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw=="],
266+
267+ "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ=="],
268+
269+ "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w=="],
270+
271+ "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA=="],
272+
273+ "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w=="],
274+
275+ "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img=="],
276+
277+ "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.3", "", { "dependencies": { "@emnapi/core": "^1.11.1", "@emnapi/runtime": "^1.11.1", "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ=="],
278+
279+ "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ=="],
280+
281+ "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.3", "", { "os": "win32", "cpu": "x64" }, "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw=="],
282+
283+ "@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="],
284+
285+ "@tybys/wasm-util": ["@tybys/wasm-util@0.10.4", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A=="],
286+
287+ "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
288+
289+ "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
290+
291+ "@types/hast": ["@types/hast@3.0.5", "", { "dependencies": { "@types/unist": "*" } }, "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g=="],
292+
293+ "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
294+
295+ "@types/nlcst": ["@types/nlcst@2.0.3", "", { "dependencies": { "@types/unist": "*" } }, "sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA=="],
296+
297+ "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
298+
299+ "@ungap/structured-clone": ["@ungap/structured-clone@1.4.0", "", {}, "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ=="],
300+
301+ "am-i-vibing": ["am-i-vibing@0.4.0", "", { "dependencies": { "process-ancestry": "^0.1.0" }, "bin": { "am-i-vibing": "dist/cli.mjs" } }, "sha512-MxT4XZL7pzLHpuvhDKdMaQHMGGkJDLluKBLsbstn+8wv9sWcFT6h+0ve9qkml95amVTZtZV83gQe2hY+ojgHLg=="],
302+
303+ "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
304+
305+ "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
306+
307+ "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
308+
309+ "astro": ["astro@7.3.3", "", { "dependencies": { "@astrojs/compiler-rs": "^0.4.0", "@astrojs/internal-helpers": "0.11.0", "@astrojs/markdown-satteri": "0.4.1", "@astrojs/telemetry": "3.3.3", "@capsizecss/unpack": "^4.0.0", "@clack/prompts": "^1.1.0", "@oslojs/encoding": "^1.1.0", "am-i-vibing": "^0.4.0", "aria-query": "^5.3.2", "axobject-query": "^4.1.0", "ci-info": "^4.4.0", "clsx": "^2.1.1", "common-ancestor-path": "^2.0.0", "cookie": "^2.0.1", "devalue": "^5.8.1", "diff": "^9.0.0", "dset": "^3.1.4", "es-module-lexer": "^2.0.0", "esbuild": "^0.28.0", "find-proc": "0.2.0", "flattie": "^1.1.1", "fontace": "~0.4.1", "get-tsconfig": "5.0.0-beta.4", "github-slugger": "^2.0.0", "html-escaper": "3.0.3", "http-cache-semantics": "^4.2.0", "js-yaml": "^4.3.0", "jsonc-parser": "^3.3.1", "magic-string": "^1.0.0", "magicast": "^0.5.2", "mrmime": "^2.0.1", "neotraverse": "^1.0.1", "obug": "^2.1.1", "p-limit": "^7.3.0", "p-queue": "^9.1.0", "package-manager-detector": "^1.6.0", "piccolore": "^0.1.3", "picomatch": "^4.0.4", "shiki": "^4.0.2", "smol-toml": "^1.6.0", "svgo": "^4.0.2", "tinyclip": "^0.1.12", "tinyexec": "^1.0.4", "tinyglobby": "^0.2.15", "ultrahtml": "^1.6.0", "unifont": "~0.7.5", "unstorage": "^1.17.5", "verkit": "^0.4.0", "vite": "^8.0.13", "vitefu": "^1.1.2", "xxhash-wasm": "^1.1.0", "yargs-parser": "^22.0.0", "zod": "^4.5.4" }, "optionalDependencies": { "sharp": "^0.35.4" }, "peerDependencies": { "@astrojs/markdown-remark": "^7.3.0" }, "optionalPeers": ["@astrojs/markdown-remark"], "bin": { "astro": "./bin/astro.mjs" } }, "sha512-NF08hk3edFkVmr9avf9HW/rQyf6NrpbVDyYj2cVN3JfijDhfJYh1ivWVTY5PUd5+rRZn/Vtu4iaeyNlP0Iv6mg=="],
310+
311+ "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
312+
313+ "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
314+
315+ "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
316+
317+ "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
318+
319+ "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
320+
321+ "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
322+
323+ "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
324+
325+ "ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="],
326+
327+ "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
328+
329+ "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
330+
331+ "commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
332+
333+ "common-ancestor-path": ["common-ancestor-path@2.0.0", "", {}, "sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng=="],
334+
335+ "cookie": ["cookie@2.0.1", "", {}, "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w=="],
336+
337+ "cookie-es": ["cookie-es@1.2.3", "", {}, "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw=="],
338+
339+ "crossws": ["crossws@0.3.5", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA=="],
340+
341+ "css-select": ["css-select@6.0.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^7.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "nth-check": "^2.1.1" } }, "sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw=="],
342+
343+ "css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="],
344+
345+ "css-what": ["css-what@7.0.0", "", {}, "sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ=="],
346+
347+ "csso": ["csso@5.0.5", "", { "dependencies": { "css-tree": "~2.2.0" } }, "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ=="],
348+
349+ "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="],
350+
351+ "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
352+
353+ "destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="],
354+
355+ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
356+
357+ "devalue": ["devalue@5.9.4", "", {}, "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg=="],
358+
359+ "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
360+
361+ "diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
362+
363+ "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
364+
365+ "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
366+
367+ "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
368+
369+ "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
370+
371+ "dset": ["dset@3.1.4", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="],
372+
373+ "enhanced-resolve": ["enhanced-resolve@5.25.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w=="],
374+
375+ "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
376+
377+ "es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="],
378+
379+ "esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="],
380+
381+ "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
382+
383+ "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
384+
385+ "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="],
386+
387+ "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="],
388+
389+ "fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="],
390+
391+ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
392+
393+ "find-proc": ["find-proc@0.2.0", "", {}, "sha512-a6h2nTrgB3g5Wrn4au9Ux4kMk7IJtyF6oA8uGn+mk3weLZssUtc1cp6meHIzzuW2o1srrBAxzkmXLbqjRkgB6w=="],
394+
395+ "flattie": ["flattie@1.1.1", "", {}, "sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ=="],
396+
397+ "fontace": ["fontace@0.4.1", "", { "dependencies": { "fontkitten": "^1.0.2" } }, "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw=="],
398+
399+ "fontkitten": ["fontkitten@1.0.3", "", { "dependencies": { "tiny-inflate": "^1.0.3" } }, "sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw=="],
400+
401+ "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
402+
403+ "get-tsconfig": ["get-tsconfig@5.0.0-beta.4", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-7nF7C9fIPFEMHgEMEfgIlO9wDdZ8CyHw27rWciFZfHvHDReIiPhsYuzPRXsfvBCqFy1l8RRyyWV7QLM+ZhUJsQ=="],
404+
405+ "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
406+
407+ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
408+
409+ "h3": ["h3@1.15.11", "", { "dependencies": { "cookie-es": "^1.2.3", "crossws": "^0.3.5", "defu": "^6.1.6", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg=="],
410+
411+ "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
412+
413+ "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
414+
415+ "html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="],
416+
417+ "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
418+
419+ "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="],
420+
421+ "iron-webcrypto": ["iron-webcrypto@1.2.1", "", {}, "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg=="],
422+
423+ "is-docker": ["is-docker@4.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-LHE+wROyG/Y/0ZnbktRCoTix2c1RhgWaZraMZ8o1Q7zCh0VSrICJQO5oqIIISrcSBtrXv0o233w1IYwsWCjTzA=="],
424+
425+ "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
426+
427+ "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
428+
429+ "js-yaml": ["js-yaml@4.3.2", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA=="],
430+
431+ "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="],
432+
433+ "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
434+
435+ "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
436+
437+ "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
438+
439+ "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
440+
441+ "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
442+
443+ "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
444+
445+ "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
446+
447+ "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
448+
449+ "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
450+
451+ "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
452+
453+ "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
454+
455+ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
456+
457+ "lru-cache": ["lru-cache@11.5.3", "", {}, "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg=="],
458+
459+ "magic-string": ["magic-string@1.4.1", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow=="],
460+
461+ "magicast": ["magicast@0.5.5", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "source-map-js": "^1.2.1" } }, "sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA=="],
462+
463+ "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="],
464+
465+ "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
466+
467+ "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
468+
469+ "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
470+
471+ "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
472+
473+ "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
474+
475+ "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
476+
477+ "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
478+
479+ "nanoid": ["nanoid@3.3.19", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="],
480+
481+ "neotraverse": ["neotraverse@1.0.1", "", {}, "sha512-WmmLty1YWwJl9yZi77v2dVIV6X2kuYV8YYBI/G3LWGKdGHmHUvL1z7FW0iDvEvGAwNEoc5x1tOOOyDnf5jJw/w=="],
482+
483+ "nlcst-to-string": ["nlcst-to-string@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0" } }, "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA=="],
484+
485+ "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
486+
487+ "node-mock-http": ["node-mock-http@1.0.5", "", {}, "sha512-KQyt/wLjG3TAc7DOUhpqWzgd4ERxR80JOlTK5VE5R1S12IaPVN5qkj4klBce9HPG1Njuup4Sb5bljaT34lIyjw=="],
488+
489+ "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
490+
491+ "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
492+
493+ "obug": ["obug@2.2.1", "", {}, "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q=="],
494+
495+ "ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
496+
497+ "ohash": ["ohash@2.0.12", "", {}, "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw=="],
498+
499+ "oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="],
500+
501+ "oniguruma-to-es": ["oniguruma-to-es@4.3.6", "", { "dependencies": { "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA=="],
502+
503+ "p-limit": ["p-limit@7.3.3", "", { "dependencies": { "yocto-queue": "^1.2.1" } }, "sha512-SKgVvDU5TNIxQ4r30U75BNLC6a+6GOEjngrk/ysM5+Zu3oJuk1SC5ApzIzY/7PjCEjoVPMWJ0zho/4QUiEB7TQ=="],
504+
505+ "p-queue": ["p-queue@9.3.3", "", { "dependencies": { "eventemitter3": "^5.0.4", "p-timeout": "^7.0.0" } }, "sha512-NXAOdnEe5FsZJfT4oK84lE1Y5cFFdWlRuOo5tww8DyNMxyRXwn39fIkUtNLKppcPC+UYU/bXujNCUGDv01y7CA=="],
506+
507+ "p-timeout": ["p-timeout@7.0.2", "", {}, "sha512-prbX4Z3YszrFNgH+MW5Zoeq3baXrMtP/MQnFeET90UB/GtGcGDQ5Usg9OCy6ETjTTntOw1SL2z9fMPUppN3Guw=="],
508+
509+ "package-manager-detector": ["package-manager-detector@1.8.0", "", {}, "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A=="],
510+
511+ "piccolore": ["piccolore@0.1.3", "", {}, "sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw=="],
512+
513+ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
514+
515+ "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
516+
517+ "postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="],
518+
519+ "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="],
520+
521+ "process-ancestry": ["process-ancestry@0.1.0", "", {}, "sha512-tGqJW/UnclpYASFcM6Xh8D8l/BMtaQ9+CSG0vlJSJTcdMM4lDRv4c6H0Pdcsfted+bVczdYSfk2fdukg2gQkZg=="],
522+
523+ "property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="],
524+
525+ "radix3": ["radix3@1.1.2", "", {}, "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA=="],
526+
527+ "readdirp": ["readdirp@5.1.1", "", {}, "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA=="],
528+
529+ "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="],
530+
531+ "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="],
532+
533+ "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="],
534+
535+ "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
536+
537+ "retext-smartypants": ["retext-smartypants@6.2.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "nlcst-to-string": "^4.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ=="],
538+
539+ "rolldown": ["rolldown@1.2.9", "", { "dependencies": { "@oxc-project/types": "=0.150.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.9", "@rolldown/binding-android-arm64": "1.2.9", "@rolldown/binding-darwin-arm64": "1.2.9", "@rolldown/binding-darwin-x64": "1.2.9", "@rolldown/binding-freebsd-x64": "1.2.9", "@rolldown/binding-linux-arm-gnueabihf": "1.2.9", "@rolldown/binding-linux-arm64-gnu": "1.2.9", "@rolldown/binding-linux-arm64-musl": "1.2.9", "@rolldown/binding-linux-ppc64-gnu": "1.2.9", "@rolldown/binding-linux-s390x-gnu": "1.2.9", "@rolldown/binding-linux-x64-gnu": "1.2.9", "@rolldown/binding-linux-x64-musl": "1.2.9", "@rolldown/binding-openharmony-arm64": "1.2.9", "@rolldown/binding-win32-arm64-msvc": "1.2.9", "@rolldown/binding-win32-x64-msvc": "1.2.9" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg=="],
540+
541+ "satteri": ["satteri@0.10.5", "", { "dependencies": { "@types/estree-jsx": "^1.0.5", "@types/hast": "^3.0.5", "@types/mdast": "^4.0.4", "@types/unist": "^3.0.3" }, "optionalDependencies": { "@bruits/satteri-darwin-arm64": "0.10.5", "@bruits/satteri-darwin-x64": "0.10.5", "@bruits/satteri-linux-arm64-gnu": "0.10.5", "@bruits/satteri-linux-arm64-musl": "0.10.5", "@bruits/satteri-linux-x64-gnu": "0.10.5", "@bruits/satteri-linux-x64-musl": "0.10.5", "@bruits/satteri-wasm32-wasi": "0.10.5", "@bruits/satteri-win32-arm64-msvc": "0.10.5", "@bruits/satteri-win32-x64-msvc": "0.10.5" } }, "sha512-Ao1LKpAEa9Wdg0otgbVKViZHEq9ebdXe4DMrp3s9vQAU0HNIuHnFEuMuOcm0ZIXyV0Yzxj91NvhLpvXZJO/5ZQ=="],
542+
543+ "sax": ["sax@1.6.1", "", {}, "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q=="],
544+
545+ "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
546+
547+ "sharp": ["sharp@0.35.4", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.4", "@img/sharp-darwin-x64": "0.35.4", "@img/sharp-freebsd-wasm32": "0.35.4", "@img/sharp-libvips-darwin-arm64": "1.3.3", "@img/sharp-libvips-darwin-x64": "1.3.3", "@img/sharp-libvips-linux-arm": "1.3.3", "@img/sharp-libvips-linux-arm64": "1.3.3", "@img/sharp-libvips-linux-ppc64": "1.3.3", "@img/sharp-libvips-linux-riscv64": "1.3.3", "@img/sharp-libvips-linux-s390x": "1.3.3", "@img/sharp-libvips-linux-x64": "1.3.3", "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", "@img/sharp-libvips-linuxmusl-x64": "1.3.3", "@img/sharp-linux-arm": "0.35.4", "@img/sharp-linux-arm64": "0.35.4", "@img/sharp-linux-ppc64": "0.35.4", "@img/sharp-linux-riscv64": "0.35.4", "@img/sharp-linux-s390x": "0.35.4", "@img/sharp-linux-x64": "0.35.4", "@img/sharp-linuxmusl-arm64": "0.35.4", "@img/sharp-linuxmusl-x64": "0.35.4", "@img/sharp-webcontainers-wasm32": "0.35.4", "@img/sharp-win32-arm64": "0.35.4", "@img/sharp-win32-ia32": "0.35.4", "@img/sharp-win32-x64": "0.35.4" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA=="],
548+
549+ "shiki": ["shiki@4.4.3", "", { "dependencies": { "@shikijs/core": "4.4.3", "@shikijs/engine-javascript": "4.4.3", "@shikijs/engine-oniguruma": "4.4.3", "@shikijs/langs": "4.4.3", "@shikijs/themes": "4.4.3", "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g=="],
550+
551+ "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
552+
553+ "smol-toml": ["smol-toml@1.8.0", "", {}, "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ=="],
554+
555+ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
556+
557+ "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
558+
559+ "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
560+
561+ "svgo": ["svgo@4.1.0", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^6.0.0", "css-tree": "^3.0.1", "css-what": "^7.0.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "1.6.1" }, "bin": { "svgo": "bin/svgo.js" } }, "sha512-bkxnTg1kSU0guhIBmibA6UUhrQmPVA1XsQLN+ylCd+UWzbnLkySOcXpyk1mrl05f+pcaCx2eHb+sp6BgMZWX+Q=="],
562+
563+ "tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
564+
565+ "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
566+
567+ "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="],
568+
569+ "tinyclip": ["tinyclip@0.1.15", "", {}, "sha512-uo33abH+Ays0xYaDysoBt494Hb3hsEczMpcC0MwFl773pazORx4fmvKhclhR1wonUbB6vvpRsvVMwnhfqeMc+A=="],
570+
571+ "tinyexec": ["tinyexec@1.3.1", "", {}, "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA=="],
572+
573+ "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
574+
575+ "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
576+
577+ "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
578+
579+ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
580+
581+ "ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="],
582+
583+ "ultrahtml": ["ultrahtml@1.7.0", "", {}, "sha512-2xRd0VHoAQE4M+vF/DvFFB7pUV0ZxTW1TLi7lHQWnF/Sb5TPeEUV/l+hxcNnGO00ZXGnR0voCMmYRKQf+rvJ2g=="],
584+
585+ "uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="],
586+
587+ "undici": ["undici@8.10.2", "", {}, "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ=="],
588+
589+ "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
590+
591+ "unifont": ["unifont@0.7.5", "", { "dependencies": { "css-tree": "^3.1.0", "ohash": "^2.0.11", "undici": "^8.0.0" } }, "sha512-ULe/Cs+ZIsq+dcFofNkhqielCrUJnb5mr+Yc4EBM2VlL+6OZR6+cjtI2mT1bJvRBrVncqHAbLURxmPLcCXzWMg=="],
592+
593+ "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
594+
595+ "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
596+
597+ "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
598+
599+ "unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
600+
601+ "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
602+
603+ "unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="],
604+
605+ "verkit": ["verkit@0.4.1", "", {}, "sha512-mMVzj0TXExtVdlDEq+Mzp0eyOuyznNpFobNM3uAqe3HVm/Ps2c+u17BligMkZjZCA6EiXpoon8iRTer3iu40SQ=="],
606+
607+ "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
608+
609+ "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
610+
611+ "vite": ["vite@8.3.0", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", "rolldown": "~1.2.6", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ=="],
612+
613+ "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="],
614+
615+ "xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="],
616+
617+ "yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="],
618+
619+ "yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="],
620+
621+ "zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="],
622+
623+ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
624+
625+ "@bruits/satteri-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="],
626+
627+ "@tailwindcss/node/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
628+
629+ "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="],
630+
631+ "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
632+
633+ "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g=="],
634+
635+ "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" }, "bundled": true }, "sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g=="],
636+
637+ "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.4", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A=="],
638+
639+ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
640+
641+ "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
642+
643+ "csso/css-tree": ["css-tree@2.2.1", "", { "dependencies": { "mdn-data": "2.0.28", "source-map-js": "^1.0.1" } }, "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA=="],
644+
645+ "vite/lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
646+
647+ "csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="],
648+
649+ "vite/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="],
650+
651+ "vite/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="],
652+
653+ "vite/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="],
654+
655+ "vite/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="],
656+
657+ "vite/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="],
658+
659+ "vite/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="],
660+
661+ "vite/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="],
662+
663+ "vite/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="],
664+
665+ "vite/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="],
666+
667+ "vite/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="],
668+
669+ "vite/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="],
670+ }
671+}
+16-0frontend/package.json
@@ -0,0 +1,16 @@
1+{
2+ "name": "irongit-frontend",
3+ "type": "module",
4+ "version": "0.1.0",
5+ "private": true,
6+ "scripts": {
7+ "build": "astro build",
8+ "check": "astro check",
9+ "astro": "astro"
10+ },
11+ "dependencies": {
12+ "@tailwindcss/vite": "^4.1.18",
13+ "astro": "^7.3.3",
14+ "tailwindcss": "^4.1.18"
15+ }
16+}
+6-0frontend/public/favicon.svg
@@ -0,0 +1,6 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
2+ <rect width="32" height="32" rx="4" fill="#0b0f16"/>
3+ <rect x="6" y="20" width="20" height="5" rx="1" fill="#93a0b4"/>
4+ <rect x="10" y="15" width="12" height="5" fill="#93a0b4"/>
5+ <rect x="13" y="6" width="6" height="9" fill="#f07a1a"/>
6+</svg>
+6-0frontend/public/robots.txt
@@ -0,0 +1,6 @@
1+User-agent: *
2+Disallow: /admin
3+Disallow: /settings
4+Disallow: /api/
5+Disallow: /v2/
6+Allow: /
+58-0frontend/src/components/DevReload.astro
@@ -0,0 +1,58 @@
1+---
2+// Emitted only when the build ran with PUBLIC_HOT_RELOAD=true, which
3+// backend/src/dev.rs sets. A production build contains none of this.
4+const hotReload = import.meta.env.PUBLIC_HOT_RELOAD === "true";
5+---
6+
7+{
8+ hotReload && (
9+ <script is:inline>
10+ (() => {
11+ const protocol = location.protocol === "https:" ? "wss:" : "ws:";
12+ let delay = 250;
13+ let reconnectTimer;
14+ let stableTimer;
15+ let reloading = false;
16+ let socket;
17+
18+ function connect() {
19+ if (
20+ reloading ||
21+ document.visibilityState === "hidden" ||
22+ socket?.readyState === WebSocket.OPEN ||
23+ socket?.readyState === WebSocket.CONNECTING
24+ )
25+ return;
26+
27+ const ws = (socket = new WebSocket(`${protocol}//${location.host}/dev-ws`));
28+ ws.onopen = () => {
29+ console.info("[reload] connected");
30+ // Only treat the backoff as recovered once it has held.
31+ stableTimer = setTimeout(() => { delay = 250; }, 5000);
32+ };
33+ ws.onmessage = (event) => {
34+ if (event.data === "reload" && !reloading) {
35+ reloading = true;
36+ ws.close();
37+ location.reload();
38+ }
39+ };
40+ ws.onclose = () => {
41+ clearTimeout(stableTimer);
42+ if (reloading) return;
43+ // The server is usually just restarting; back off and retry.
44+ clearTimeout(reconnectTimer);
45+ reconnectTimer = setTimeout(connect, delay + Math.random() * 250);
46+ delay = Math.min(delay * 2, 5000);
47+ };
48+ }
49+
50+ document.addEventListener("visibilitychange", () => {
51+ if (document.visibilityState === "visible") connect();
52+ });
53+ window.addEventListener("online", connect);
54+ connect();
55+ })();
56+ </script>
57+ )
58+}
+63-0frontend/src/layouts/Layout.astro
@@ -0,0 +1,63 @@
1+---
2+// One shell for everything. Static pages fill <slot />; the Rust server
3+// fills the markers at request time:
4+// <meta name="ig-head"> title, description, Open Graph, analytics
5+// <ig-nav></ig-nav> top bar with the signed-in user
6+// <ig-body></ig-body> page content (app-shell.astro only)
7+import DevReload from "../components/DevReload.astro";
8+import "../styles/global.css";
9+
10+interface Props {
11+ title?: string;
12+ description?: string;
13+ /** Static pages get a centered column; the app shell sizes itself. */
14+ contained?: boolean;
15+}
16+
17+const { title, description, contained = true } = Astro.props;
18+---
19+
20+<!doctype html>
21+<html lang="en">
22+ <head>
23+ <meta charset="utf-8" />
24+ <meta name="viewport" content="width=device-width, initial-scale=1" />
25+ <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
26+ <script is:inline>
27+ try {
28+ const theme = localStorage.getItem("ig-theme");
29+ if (theme === "light" || theme === "dark") document.documentElement.dataset.theme = theme;
30+ } catch {}
31+ </script>
32+ {title && <title>{title}</title>}
33+ {description && <meta name="description" content={description} />}
34+ <meta name="ig-head" />
35+ </head>
36+ <body class="flex min-h-screen flex-col">
37+ <ig-nav></ig-nav>
38+ <main class="flex-1">
39+ {
40+ contained ? (
41+ <div class="mx-auto w-full max-w-[1100px] px-4 py-6">
42+ <slot />
43+ </div>
44+ ) : (
45+ <slot />
46+ )
47+ }
48+ </main>
49+ <footer class="border-t border-edge">
50+ <div class="mx-auto flex max-w-[1280px] flex-wrap items-center gap-x-4 gap-y-1 px-4 py-3 text-xs text-ink-faint">
51+ <span>irongit</span>
52+ <a href="/docs" class="text-ink-faint">Docs</a>
53+ <a href="/docs/cli" class="text-ink-faint">CLI</a>
54+ <a href="/explore" class="text-ink-faint">Explore</a>
55+ <button type="button" data-theme-toggle class="cursor-pointer text-ink-faint hover:text-ink">Toggle theme</button>
56+ </div>
57+ </footer>
58+ <DevReload />
59+ <script>
60+ import "../scripts/app.ts";
61+ </script>
62+ </body>
63+</html>
+10-0frontend/src/pages/404.astro
@@ -0,0 +1,10 @@
1+---
2+import Layout from "../layouts/Layout.astro";
3+---
4+
5+<Layout title="Not found · irongit">
6+ <h1 class="text-xl font-semibold">Not found</h1>
7+ <p class="mt-2 text-ink-dim">
8+ That page isn't here, or you don't have access to it. <a href="/">Go home</a>.
9+ </p>
10+</Layout>
+6-0frontend/src/pages/app-shell.astro
@@ -0,0 +1,6 @@
1+---
2+// Template for pages the Rust server renders. Never served directly.
3+import Layout from "../layouts/Layout.astro";
4+---
5+
6+<Layout contained={false}><ig-body></ig-body></Layout>
+62-0frontend/src/pages/index.astro
@@ -0,0 +1,62 @@
1+---
2+import Layout from "../layouts/Layout.astro";
3+---
4+
5+<Layout
6+ title="irongit: git hosting and a container registry"
7+ description="Host git repositories and Docker images together. Public or private, per repo and per image. One CLI."
8+ contained={false}
9+>
10+ <section class="border-b border-edge">
11+ <div class="mx-auto grid max-w-[1100px] gap-8 px-4 py-12 md:grid-cols-[1.1fr_1fr] md:py-16">
12+ <div>
13+ <p class="font-mono text-xs tracking-widest text-ember uppercase">Self-hosted forge</p>
14+ <h1 class="mt-3 text-3xl leading-tight font-semibold md:text-4xl">
15+ Your code and your images,<br />in one place you control.
16+ </h1>
17+ <p class="mt-4 max-w-prose text-[15px] text-ink-dim">
18+ Git over HTTPS and SSH, a Docker registry, Git LFS, organizations and public profiles. Code privacy and image
19+ privacy are set separately, so a private repo can ship a public image.
20+ </p>
21+ <div class="mt-6 flex flex-wrap gap-2">
22+ <a href="/register" class="btn btn-primary px-4 py-1.5 text-sm" data-track="landing_register_clicked">Create an account</a>
23+ <a href="/explore" class="btn px-4 py-1.5 text-sm" data-track="landing_explore_clicked">Explore repositories</a>
24+ </div>
25+ </div>
26+ <div class="box overflow-hidden font-mono text-[12.5px] leading-6">
27+ <div class="box-head text-ink-dim">terminal</div>
28+ <pre class="overflow-x-auto bg-surface-sunken p-3"><span class="text-ink-faint">$</span> curl -fsSL <span id="host">this-site</span>/install.sh | sh
29+<span class="text-ink-faint">$</span> ig login
30+<span class="text-ink-faint">$</span> ig repo create api --private
31+<span class="text-ink-faint">$</span> git push origin main
32+<span class="text-ink-faint">$</span> docker push <span class="host">this-site</span>/you/api:1.0
33+<span class="text-ink-faint">$</span> ig image visibility you/api public</pre>
34+ </div>
35+ </div>
36+ </section>
37+
38+ <section class="mx-auto grid max-w-[1100px] gap-px overflow-hidden px-4 py-10 sm:grid-cols-2 lg:grid-cols-3">
39+ {
40+ [
41+ ["Git, the real thing", "Smart HTTP and a built-in SSH server. Push, clone, fetch with any git client. Files over the size limit go to Git LFS."],
42+ ["Container registry", "OCI distribution API. Pulls stream straight from object storage, so big images cost the server nothing."],
43+ ["Separate privacy", "Every repo and every image is public or private on its own. Link an image to its repo, or don't."],
44+ ["Organizations", "Shared namespaces with owners and members, plus per-repo collaborators for everyone else."],
45+ ["Profiles", "A public page per person with a contribution heatmap built from commits on default branches."],
46+ ["One CLI", "ig handles login, repos, images, SSH keys and the git and docker credential helpers."],
47+ ].map(([title, body]) => (
48+ <div class="border border-edge p-4">
49+ <h2 class="text-[14px] font-semibold">{title}</h2>
50+ <p class="mt-1.5 text-[13px] text-ink-dim">{body}</p>
51+ </div>
52+ ))
53+ }
54+ </section>
55+</Layout>
56+
57+<script>
58+ const host = location.host;
59+ document.querySelectorAll("#host, .host").forEach((el) => (el.textContent = host));
60+ const install = document.getElementById("host");
61+ if (install) install.textContent = `${location.protocol}//${host}`;
62+</script>
+228-0frontend/src/scripts/app.ts
@@ -0,0 +1,228 @@
1+// Behaviour shared by every page, static or rendered by the Rust server.
2+// Everything hangs off data attributes so server templates need no JS.
3+
4+type Props = Record<string, string | number | boolean>;
5+
6+declare global {
7+ interface Window {
8+ rybbit?: {
9+ event: (name: string, props?: Props) => void;
10+ identify?: (id: string) => void;
11+ };
12+ }
13+}
14+
15+// --- Analytics --------------------------------------------------------------
16+
17+export function track(name: string, props?: Props) {
18+ try {
19+ window.rybbit?.event(name, props);
20+ } catch {
21+ /* analytics must never break the page */
22+ }
23+}
24+
25+function initAnalytics() {
26+ const user = document.querySelector<HTMLMetaElement>('meta[name="ig-user"]')?.content;
27+ if (user) {
28+ const identify = () => window.rybbit?.identify?.(user);
29+ if (window.rybbit) identify();
30+ else window.addEventListener("load", identify, { once: true });
31+ }
32+
33+ // <a data-track="repo_clone_copied" data-track-props='{"repo":"a/b"}'>
34+ document.addEventListener("click", (event) => {
35+ const el = (event.target as Element)?.closest<HTMLElement>("[data-track]");
36+ if (!el) return;
37+ let props: Props | undefined;
38+ try {
39+ props = el.dataset.trackProps ? JSON.parse(el.dataset.trackProps) : undefined;
40+ } catch {
41+ props = undefined;
42+ }
43+ track(el.dataset.track!, props);
44+ });
45+
46+ // Forms report their submission: <form data-track-submit="repo_created">.
47+ document.addEventListener("submit", (event) => {
48+ const form = event.target as HTMLFormElement;
49+ if (form?.dataset.trackSubmit) track(form.dataset.trackSubmit);
50+ });
51+}
52+
53+// --- Client logs: browser errors land in DATA_DIR/logs/frontend.log ---------
54+
55+type LogEntry = { level: string; message: string; url: string; at: string; stack?: string };
56+const pending: LogEntry[] = [];
57+let flushTimer: number | undefined;
58+let sent = 0;
59+
60+function queueLog(level: string, message: string, stack?: string) {
61+ if (sent > 200) return; // a broken page should not flood the server
62+ pending.push({ level, message: message.slice(0, 2000), stack: stack?.slice(0, 4000), url: location.href, at: new Date().toISOString() });
63+ clearTimeout(flushTimer);
64+ flushTimer = window.setTimeout(flushLogs, 1000);
65+}
66+
67+function flushLogs() {
68+ if (!pending.length) return;
69+ const batch = pending.splice(0, pending.length);
70+ sent += batch.length;
71+ const body = JSON.stringify({ entries: batch });
72+ if (!navigator.sendBeacon?.("/api/v1/client-logs", new Blob([body], { type: "application/json" }))) {
73+ fetch("/api/v1/client-logs", { method: "POST", body, headers: { "content-type": "application/json" }, keepalive: true }).catch(() => {});
74+ }
75+}
76+
77+function initLogging() {
78+ window.addEventListener("error", (e) => queueLog("error", e.message || "error", e.error?.stack));
79+ window.addEventListener("unhandledrejection", (e) => {
80+ const reason = e.reason;
81+ queueLog("error", `unhandled rejection: ${reason?.message ?? String(reason)}`, reason?.stack);
82+ });
83+ for (const level of ["error", "warn"] as const) {
84+ const original = console[level].bind(console);
85+ console[level] = (...args: unknown[]) => {
86+ original(...args);
87+ queueLog(level, args.map((a) => (a instanceof Error ? a.message : typeof a === "string" ? a : safeJson(a))).join(" "));
88+ };
89+ }
90+ window.addEventListener("pagehide", flushLogs);
91+}
92+
93+function safeJson(value: unknown) {
94+ try {
95+ return JSON.stringify(value);
96+ } catch {
97+ return String(value);
98+ }
99+}
100+
101+// --- Theme ------------------------------------------------------------------
102+
103+function initTheme() {
104+ document.addEventListener("click", (event) => {
105+ const button = (event.target as Element)?.closest("[data-theme-toggle]");
106+ if (!button) return;
107+ const root = document.documentElement;
108+ const current = root.dataset.theme ?? (matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark");
109+ const next = current === "light" ? "dark" : "light";
110+ root.dataset.theme = next;
111+ try {
112+ localStorage.setItem("ig-theme", next);
113+ } catch {
114+ /* private mode */
115+ }
116+ track("theme_changed", { theme: next });
117+ });
118+}
119+
120+// --- Copy buttons: <button data-copy="text"> or data-copy-target="#id" -----
121+
122+function initCopy() {
123+ document.addEventListener("click", async (event) => {
124+ const button = (event.target as Element)?.closest<HTMLElement>("[data-copy], [data-copy-target]");
125+ if (!button) return;
126+ event.preventDefault();
127+ let text = button.dataset.copy ?? "";
128+ if (button.dataset.copyTarget) {
129+ const target = document.querySelector<HTMLInputElement | HTMLElement>(button.dataset.copyTarget);
130+ text = target && "value" in target ? (target as HTMLInputElement).value : (target?.textContent ?? "");
131+ }
132+ try {
133+ await navigator.clipboard.writeText(text);
134+ const original = button.innerHTML;
135+ button.textContent = "Copied";
136+ setTimeout(() => (button.innerHTML = original), 1200);
137+ } catch (error) {
138+ console.warn("copy failed", error);
139+ }
140+ });
141+}
142+
143+// --- Lazy fragments: <div data-lazy="/admin/fragments/users">skeleton</div> --
144+
145+async function loadLazy(el: HTMLElement) {
146+ const url = el.dataset.lazy!;
147+ const started = performance.now();
148+ try {
149+ const response = await fetch(url, { headers: { "x-requested-with": "fetch" } });
150+ if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
151+ el.innerHTML = await response.text();
152+ el.removeAttribute("aria-busy");
153+ el.querySelectorAll<HTMLElement>("[data-lazy]").forEach(loadLazy);
154+ } catch (error) {
155+ el.innerHTML = `<div class="alert alert-error">Could not load this section (${(error as Error).message}). <button class="btn btn-sm" data-retry>Retry</button></div>`;
156+ el.querySelector("[data-retry]")?.addEventListener("click", () => loadLazy(el));
157+ console.error(`lazy load failed: ${url}`, error);
158+ } finally {
159+ const ms = Math.round(performance.now() - started);
160+ if (ms > 1500) console.warn(`slow fragment ${url}: ${ms}ms`);
161+ }
162+}
163+
164+function initLazy() {
165+ document.querySelectorAll<HTMLElement>("[data-lazy]").forEach(loadLazy);
166+}
167+
168+// --- Sidebar collapse (admin) ----------------------------------------------
169+
170+function initSidebar() {
171+ const shell = document.getElementById("admin-shell");
172+ if (!shell) return;
173+ try {
174+ if (localStorage.getItem("ig-sidebar") === "collapsed") shell.dataset.collapsed = "true";
175+ } catch {
176+ /* ignore */
177+ }
178+ document.addEventListener("click", (event) => {
179+ if (!(event.target as Element)?.closest("[data-sidebar-toggle]")) return;
180+ const collapsed = shell.dataset.collapsed !== "true";
181+ shell.dataset.collapsed = String(collapsed);
182+ try {
183+ localStorage.setItem("ig-sidebar", collapsed ? "collapsed" : "open");
184+ } catch {
185+ /* ignore */
186+ }
187+ track("sidebar_toggled", { collapsed });
188+ });
189+}
190+
191+// --- Dangerous forms: the submit button unlocks when the name is typed ------
192+
193+function initConfirmInputs() {
194+ document.querySelectorAll<HTMLInputElement>("input[data-confirm-value]").forEach((input) => {
195+ const form = input.form;
196+ const button = form?.querySelector<HTMLButtonElement>("button[type=submit]");
197+ if (!button) return;
198+ const update = () => (button.disabled = input.value.trim() !== input.dataset.confirmValue);
199+ input.addEventListener("input", update);
200+ update();
201+ });
202+}
203+
204+// --- Auto-submitting selects (filters, branch switchers) --------------------
205+
206+function initAutoSubmit() {
207+ document.addEventListener("change", (event) => {
208+ const el = event.target as HTMLSelectElement;
209+ if (el?.matches?.("[data-autosubmit]")) {
210+ if (el.dataset.navigate !== undefined) location.href = el.value;
211+ else el.form?.requestSubmit();
212+ }
213+ });
214+}
215+
216+function init() {
217+ initLogging();
218+ initAnalytics();
219+ initTheme();
220+ initCopy();
221+ initLazy();
222+ initSidebar();
223+ initConfirmInputs();
224+ initAutoSubmit();
225+}
226+
227+if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init);
228+else init();
+290-0frontend/src/styles/global.css
@@ -0,0 +1,290 @@
1+@import "tailwindcss";
2+
3+/* Pages rendered by the Rust server use Tailwind classes too; scan them. */
4+@source "../../../backend/src";
5+
6+@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
7+
8+@theme {
9+ --font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
10+ --font-mono: ui-monospace, "JetBrains Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace;
11+
12+ --color-surface: #0b0f16;
13+ --color-surface-raised: #121823;
14+ --color-surface-sunken: #080b10;
15+ --color-surface-hover: #1a2230;
16+ --color-edge: #232c3a;
17+ --color-edge-strong: #334052;
18+ --color-ink: #e7ecf3;
19+ --color-ink-dim: #93a0b4;
20+ --color-ink-faint: #637087;
21+ --color-accent: #4f8cff;
22+ --color-accent-ink: #ffffff;
23+ --color-ember: #f07a1a;
24+ --color-ok: #3fb950;
25+ --color-danger: #f85149;
26+ --color-warn: #d29922;
27+
28+ /* Contribution heatmap: forge heat, cold to white-hot. */
29+ --color-heat-0: #161c27;
30+ --color-heat-1: #4a2a12;
31+ --color-heat-2: #8a4413;
32+ --color-heat-3: #d0661a;
33+ --color-heat-4: #ff9a3c;
34+
35+ --radius-card: 4px;
36+}
37+
38+/* Light theme: the system preference unless dark was chosen, or explicit. */
39+@media (prefers-color-scheme: light) {
40+ :root:not([data-theme="dark"]) {
41+ --color-surface: #ffffff;
42+ --color-surface-raised: #f6f8fa;
43+ --color-surface-sunken: #eef1f4;
44+ --color-surface-hover: #eaeef2;
45+ --color-edge: #d8dee6;
46+ --color-edge-strong: #b9c2cd;
47+ --color-ink: #111827;
48+ --color-ink-dim: #556070;
49+ --color-ink-faint: #8590a0;
50+ --color-accent: #2563eb;
51+ --color-heat-0: #eceff3;
52+ --color-heat-1: #ffd8b0;
53+ --color-heat-2: #ffa860;
54+ --color-heat-3: #f07a1a;
55+ --color-heat-4: #b44c00;
56+ color-scheme: light;
57+ }
58+}
59+:root[data-theme="light"] {
60+ --color-surface: #ffffff;
61+ --color-surface-raised: #f6f8fa;
62+ --color-surface-sunken: #eef1f4;
63+ --color-surface-hover: #eaeef2;
64+ --color-edge: #d8dee6;
65+ --color-edge-strong: #b9c2cd;
66+ --color-ink: #111827;
67+ --color-ink-dim: #556070;
68+ --color-ink-faint: #8590a0;
69+ --color-accent: #2563eb;
70+ --color-heat-0: #eceff3;
71+ --color-heat-1: #ffd8b0;
72+ --color-heat-2: #ffa860;
73+ --color-heat-3: #f07a1a;
74+ --color-heat-4: #b44c00;
75+ color-scheme: light;
76+}
77+:root {
78+ color-scheme: dark;
79+}
80+
81+@layer base {
82+ html {
83+ -webkit-text-size-adjust: 100%;
84+ }
85+ body {
86+ @apply bg-surface text-ink font-sans text-sm;
87+ }
88+ a {
89+ @apply text-accent;
90+ }
91+ a:hover {
92+ text-decoration: underline;
93+ }
94+ code,
95+ pre,
96+ kbd {
97+ @apply font-mono;
98+ }
99+ ::selection {
100+ background: color-mix(in srgb, var(--color-accent) 35%, transparent);
101+ }
102+}
103+
104+@layer components {
105+ /* Buttons: square-ish, dense, no pills. */
106+ .btn {
107+ @apply inline-flex items-center justify-center gap-1.5 rounded-[4px] border border-edge-strong bg-surface-raised px-2.5 py-1 text-[13px] font-medium text-ink no-underline select-none cursor-pointer;
108+ }
109+ .btn:hover {
110+ @apply bg-surface-hover no-underline;
111+ }
112+ .btn:disabled {
113+ @apply opacity-50 cursor-not-allowed;
114+ }
115+ .btn-primary {
116+ @apply border-accent bg-accent text-accent-ink;
117+ }
118+ .btn-primary:hover {
119+ background: color-mix(in srgb, var(--color-accent) 85%, black);
120+ }
121+ .btn-danger {
122+ @apply border-danger/60 text-danger;
123+ }
124+ .btn-danger:hover {
125+ @apply bg-danger text-white;
126+ }
127+ .btn-sm {
128+ @apply px-2 py-0.5 text-xs;
129+ }
130+
131+ .input {
132+ @apply w-full rounded-[4px] border border-edge-strong bg-surface-sunken px-2 py-1 text-[13px] text-ink outline-none;
133+ }
134+ .input:focus {
135+ @apply border-accent;
136+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-accent) 30%, transparent);
137+ }
138+ .label {
139+ @apply mb-1 block text-xs font-semibold text-ink-dim;
140+ }
141+ .hint {
142+ @apply mt-1 text-xs text-ink-faint;
143+ }
144+
145+ .box {
146+ @apply rounded-[4px] border border-edge bg-surface;
147+ }
148+ .box-head {
149+ @apply flex items-center gap-2 border-b border-edge bg-surface-raised px-3 py-1.5 text-[13px];
150+ }
151+
152+ /* Small square label: visibility, roles, kinds. */
153+ .tag {
154+ @apply inline-flex items-center rounded-[3px] border border-edge-strong px-1 text-[11px] leading-4 font-medium text-ink-dim;
155+ }
156+
157+ .tabs {
158+ @apply flex gap-0 overflow-x-auto border-b border-edge text-[13px];
159+ }
160+ .tab {
161+ @apply -mb-px flex items-center gap-1.5 border-b-2 border-transparent px-3 py-1.5 whitespace-nowrap text-ink-dim no-underline;
162+ }
163+ .tab:hover {
164+ @apply text-ink no-underline;
165+ }
166+ .tab[aria-current="page"] {
167+ @apply border-ember text-ink font-semibold;
168+ }
169+
170+ .skeleton {
171+ @apply animate-pulse rounded-[3px] bg-surface-hover;
172+ }
173+
174+ .alert {
175+ @apply rounded-[4px] border px-3 py-2 text-[13px];
176+ }
177+ .alert-error {
178+ @apply border-danger/50 bg-danger/10 text-ink;
179+ }
180+ .alert-ok {
181+ @apply border-ok/50 bg-ok/10 text-ink;
182+ }
183+ .alert-info {
184+ @apply border-accent/40 bg-accent/10 text-ink;
185+ }
186+}
187+
188+/* Rendered READMEs and other markdown. */
189+.markdown {
190+ @apply text-[14px] leading-relaxed text-ink;
191+ overflow-wrap: anywhere;
192+}
193+.markdown > * + * {
194+ margin-top: 0.75em;
195+}
196+.markdown h1,
197+.markdown h2,
198+.markdown h3,
199+.markdown h4 {
200+ @apply font-semibold text-ink;
201+ margin-top: 1.25em;
202+}
203+.markdown h1 {
204+ @apply border-b border-edge pb-1 text-xl;
205+}
206+.markdown h2 {
207+ @apply border-b border-edge pb-1 text-lg;
208+}
209+.markdown h3 {
210+ @apply text-base;
211+}
212+.markdown > :first-child {
213+ margin-top: 0;
214+}
215+.markdown ul {
216+ @apply list-disc pl-6;
217+}
218+.markdown ol {
219+ @apply list-decimal pl-6;
220+}
221+.markdown li + li {
222+ margin-top: 0.2em;
223+}
224+.markdown code {
225+ @apply rounded-[3px] bg-surface-hover px-1 py-px text-[12.5px];
226+}
227+.markdown pre {
228+ @apply overflow-x-auto rounded-[4px] border border-edge bg-surface-sunken p-3 text-[12.5px] leading-snug;
229+}
230+.markdown pre code {
231+ @apply bg-transparent p-0;
232+}
233+.markdown blockquote {
234+ @apply border-l-2 border-edge-strong pl-3 text-ink-dim;
235+}
236+.markdown table {
237+ @apply block overflow-x-auto border-collapse text-[13px];
238+}
239+.markdown th,
240+.markdown td {
241+ @apply border border-edge px-2 py-1;
242+}
243+.markdown th {
244+ @apply bg-surface-raised font-semibold;
245+}
246+.markdown img {
247+ @apply inline max-w-full;
248+}
249+.markdown hr {
250+ @apply border-edge;
251+}
252+.markdown input[type="checkbox"] {
253+ @apply mr-1 align-middle;
254+}
255+
256+/* Source views: line numbers in a column that is not selected with the code. */
257+.code-table {
258+ @apply w-full border-collapse font-mono text-[12.5px] leading-[19px];
259+}
260+.code-table td.ln {
261+ @apply w-[1%] min-w-[44px] select-none border-r border-edge pr-2 pl-3 text-right align-top text-ink-faint;
262+}
263+.code-table td.ln a {
264+ @apply text-ink-faint no-underline;
265+}
266+.code-table td.lc {
267+ @apply pl-3 pr-3 align-top whitespace-pre;
268+}
269+.code-table tr:target td {
270+ background: color-mix(in srgb, var(--color-ember) 14%, transparent);
271+}
272+
273+/* Unified diffs. */
274+.diff-add {
275+ background: color-mix(in srgb, var(--color-ok) 14%, transparent);
276+}
277+.diff-del {
278+ background: color-mix(in srgb, var(--color-danger) 14%, transparent);
279+}
280+.diff-hunk {
281+ @apply bg-accent/10 text-ink-dim;
282+}
283+
284+/* Admin shell: collapsible sidebar. */
285+#admin-shell[data-collapsed="true"] .sidebar-label {
286+ display: none;
287+}
288+#admin-shell[data-collapsed="true"] aside {
289+ width: 44px;
290+}
+9-0shared/Cargo.toml
@@ -0,0 +1,9 @@
1+[package]
2+name = "irongit-shared"
3+version = "0.1.0"
4+edition = "2024"
5+
6+[dependencies]
7+chrono = { version = "0.4.45", features = ["serde"] }
8+serde = { version = "1.0.229", features = ["derive"] }
9+serde_json = "1.0.151"
+1-0shared/src/lib.rs
@@ -0,0 +1 @@
1+pub fn placeholder() {}